Compare commits

...

418 Commits

Author SHA1 Message Date
Fraser
afa79587c2 Lint without building cljs (#83718) 2026-10-07 16:06:33 +13:00
Romeo Van Snick
2b271c6987 Remove unreferenced images from app/assets/img and app/img (#82788)
* Remove unreferenced static images

* Remove unreferenced images from app/img

* Remove four more unreferenced images kept by a loose name match
2026-10-07 14:03:22 +11:00
Timothy S. Dean
37d5c5b042 Fix the revision history of an edited transform test (#83818) 2026-10-06 18:19:47 -06:00
Maz Ameli
30d6158ea1 Replace dashboard loading spinner with an instant skeleton (DSN-749) (#83288)
* Replace dashboard loading spinner with an instant skeleton (DSN-749)

Dashboards no longer show a loading spinner when opened. Instead they render
a skeleton of the dashboard shell and card layout instantly:

- New DashboardSkeleton renders the header chrome (title, action buttons,
  tabs), a full-width border, a secondary-background body, filter-widget
  placeholders, and the card grid constrained to a centered 1024px column.
- New DashboardGridSkeleton draws the card grid. When the layout is cached
  in Redux from a prior visit (getLastSeenTabDashcards), it reproduces
  react-grid-layout's real geometry so the cards land where the real ones
  will; heading/text cards render title-style line skeletons. Otherwise a
  generic placeholder layout is used.
- Grid no longer shows a spinner while refetching; the header no longer
  shows a spinner while its collection query loads.

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

* Draw the real dashboard layout before query metadata loads (DSN-749)

fetchDashboard awaited both GET /api/dashboard/:id and the slower
/query_metadata before any of the dashboard reached the store, even though
the GET alone already holds the card layout. On a fresh open it now
publishes the GET response's normalized dashboard and dashcards as soon as
it arrives (dashboardLayoutFetched), so the loading skeleton switches from
the generic layout to the dashboard's real one without waiting for
metadata. The full load transition is unchanged, and a refetch of a
dashboard already on screen publishes nothing early.

The virtual-card copy is extracted into copyVirtualCardsToCards so the
early layout and the fulfilled payload are built the same way.

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

* Render dashboard skeletons inside the real dashboard layout (DSN-749)

Replace the separate full-page dashboard skeleton with skeletons that the
header, filter panel and card grid each render while the dashboard loads,
so the surrounding page, widths and theming are the real ones and can't
drift.

- Grid renders DashboardGridSkeleton when there's no dashboard yet. This
  also covers public links, static embeds, X-rays and SDK dashboards, which
  showed "This dashboard is empty" while loading. Its spinner is restored
  for refetches of an already-open dashboard.
- The skeletons read the dashboard as last seen in the in-memory Redux
  cache: its width (assuming the default fixed width when it isn't
  cached), its tabs (a tab row only for two or more), the cards of the tab
  the user is landing on, and its header filters (excluding inline and
  hidden ones).
- The grid skeleton takes its geometry from the grid constants, including
  a new shared GRID_MARGINS, and drops the heightPx special case.
- The skeletons are marked aria-busy, and the grid announces "Loading…".
- waitForDashboardToLoad waits for a loading dashboard in tests, since
  waitForLoaderToBeRemoved no longer does.

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

* Draw no placeholder cards until the dashboard's layout is known (DSN-749)

While a dashboard that isn't in the in-memory Redux cache loads, the grid
skeleton used to draw a generic layout of placeholder cards, which rarely
matched the dashboard about to appear. It now draws no cards until the
dashboard's real layout is known, so the content area stays empty under
the header skeleton and then fills with the real layout. The grid still
marks itself busy and announces "Loading…".

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

* Size the dashboard header skeleton from the real title (DSN-749)

The header skeleton drew a bare title bar, but the real title area is
taller: the title is an EditableText box with its own padding, border and
line height, and the last-edit label sits on a row beneath it. The header
grew when the dashboard arrived, pushing the page down.

The title and label bars now sit over invisible copies of the real
elements, so the skeleton takes their exact height without copying their
styles. The label row is only reserved when the real header would show
it; a dashboard that isn't cached is assumed to have an edit history.

Also replace a file path in a CSS comment, and note in dashboard_grid.ts
that the grid skeleton's mobile container query repeats MOBILE_BREAKPOINT.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 00:06:34 +00:00
Romeo Van Snick
3d9a6743c6 Hash static images through the rspack build (#82726)
* Declare png and gif module imports for the bundler

* Hash onboarding, upsell and example images through the build

* Assert the Slack app icon link against the bundled asset

* Hash the upsell card story illustration through the build

* Take image sources as props instead of building paths from a base string

* Add a ?url image import that the SDK bundle references instead of inlining

* Hash empty state, upsell and driver logo images through the build

* Hash the map skeleton sprite and the bridge illustration through the build
2026-10-07 10:25:24 +11:00
bryan
1c0661fa03 GHY-4737: Remote sync: name the cause of a diverged-export conflict (#83682)
* GHY-4737: Remote sync: name the cause of a diverged-export conflict

A plain export against a remote that moved past the last sync ended in a
conflict task with conflicts [], so API clients (the mb CLI, agents) saw
"conflict" with no reason: the task row stores only conflicts, and
handle-task-result! drops :message. Record a factual label instead.

Also pin that such a conflict leaves the sync base alone, so a later
export with merge still sees the remote's changes.

* GHY-4737: pin the conflict label on the task row; trim the label comment

* GHY-4737: record the diverged-export cause as a stable token

Review feedback: conflicts otherwise holds short identifiers on the
import path, and an untranslated English sentence would become a wording
contract for clients. Record "remote-changed" and let clients word it.

* GHY-4737: don't let auto-import repeat an unresolved conflict

A conflict is no longer a sync base, so auto-import's "source version
matches last import" skip no longer stops a conflicted read-only
auto-import: it retried, and recorded a new conflict task, every tick.
Skip when the newest task conflicted at the current source version;
a new remote commit or a manual import still proceeds. Review feedback.

* GHY-4737: name why a task stopped in outcome, keep conflicts for what collided

Review feedback: conflicts answers "what collided" (import categories,
merge entities). A diverged export has no collision; the divergence is
why it stopped. Record conflicts [] plus outcome {:kind "remote-changed"}
instead of a token inside conflicts, and add outcome
{:kind "history-rewritten"} to the no-merge-base paths (their conflicts
sentence stays for compatibility). handle-task-result! now writes outcome
on conflicts; the outcome schema and FE type gain both kinds.
2026-10-06 16:46:55 -06:00
Andrei Chernykh
e27d36ca15 Migrate the whitelabel settings off Emotion (#83691) 2026-10-06 15:23:45 -07:00
Nemanja Glumac
d764799f8f Reusable test gate logic (#82830)
* Let each test suite decide whether it should run

run-tests.yml computed a `skip` input for every suite it called, so the
decision for a suite lived in the caller rather than next to the jobs it
governs. A new callable workflow, test-gate.yml, carries the part that is the
same everywhere: the force-run / force-skip / defer verdict, and a paths filter
that only runs on `defer`, over a sparse `.github` checkout.

Each suite now calls the gate with the filters it cares about and reads `run`,
`matched` or `files`. app-db, drivers, semantic-search, loki, e2e-tests,
frontend, embedding-sdk, build-scripts, bundle-size and backend keep no `skip`
input; run-tests only decides whether to call them at all.

static-viz-files-changed moves into backend.yml, its only consumer, and now
runs only when `backend_all` did not already match.

The uberjar and SDK package conditions become the build_uberjar and
build_embedding_sdk_package filters, each the union of what the artifact feeds.

A force-run reports every requested filter as matched, so the project-level
backend, migration and ratchet checks now also run on master and `ci:run-all`.

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

* Drop the redundant always() from workflow conditions

`always() && !cancelled()` is `!cancelled()`: always() only adds "run even when
cancelled", which the second term takes straight back. The four conditions that
paired !cancelled() with a `needs.<job>.result == 'success'` check restated the
implicit rule instead, so they lose both terms and let a plain `if` apply it.

In e2e-tests.yml that also fixes a latent failure: the suite entered when
e2e-matrix-builder had failed, leaving `fromJson` to parse a matrix that was
never produced.

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

* Stop gate jobs from skipping themselves

A skipped job skips everything downstream of it, which is why so many consumers
carried !cancelled() just to override the implicit success(). create-test-plan,
static-viz-files-changed and story-selection now always run and gate their steps
instead, emitting empty outputs, so their consumers keep only their real
condition. create-test-plan is best-effort: a planner failure means the suites
run in full, as a skipped one already did.

The app-db, drivers and semantic-search gates were skipped on a direct dispatch
for the same reason. They pass no filters on that path instead, which already
means "only check for a force-cancel".

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

* Land the affected-tests stats row from the planner action

The upload job existed only to POST what create-test-plan had just computed,
so it re-checked out the repo and re-prepared bun to read one output back.
It is now the action's last step, which also drops the `stats` output that
only ever carried the payload across the job boundary.

A composite action has no `secrets` context, so the endpoint and key arrive as
inputs; an empty endpoint (a fork PR, which gets no secrets) skips the upload
rather than posting nowhere.

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

* Reorder workflows

* Count the bookkeeping jobs in the result checks

`static-viz-files-changed` and `e2e-matrix-builder` feed every job in their
suite but were absent from the `*-result` needs, so a failure in either skipped
the whole suite and the required check reported success.

`sdk-tests` now requires a successful gate, like `e2e-tests`: a failed gate
skips the uberjar and SDK package builds for a reason the suite's own gate
cannot see, and the component tests would run against a missing artifact.

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

* Hand out a test plan only from a clean planner run

The plan artifact is uploaded before the planner's reporting steps, so a
failure after the upload still exported an artifact id and both suites narrowed
from it. Reading the step outcome covers that; the job's `continue-on-error`
means its own `result` is always `success`, so a caller-side check would not.

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

* Run the suites when the test gate changes

test-gate.yml matched no filter that gates a suite, so a diff touching only the
workflow that decides whether every suite runs ran nothing. Putting it in
shared_ci reaches backend, frontend, loki, e2e, the SDK suites and ci-scripts.

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

* Keep file lists out of the test gate

The match step read every paths-filter output through one environment
variable, file lists included. That variable passes the 128 KiB Linux allows
a single one at around 130 changed files, so the gate failed on large PRs and
took every suite down with it.

The gate now lists no files and reads only the names of the matched filters.
The create-test-plan action, the one consumer of paths, runs its own paths
filter instead of taking file lists and infra flags as inputs, so the
run-tests gate asks only for the filters its own jobs read.

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

* cleanup

* Address review: job-level skips, no frontend dispatch, no per-leg profiling

- story-selection, create-test-plan and static-viz-files-changed skip as a
  job again instead of skipping each step. Their consumers carry
  !cancelled() so a skipped one does not skip them, and the planner and
  static-viz probe no longer hold a large runner just to do nothing.
- frontend.yml loses its workflow_dispatch trigger and the skip inputs it
  fed.
- The profiling dispatch no longer picks a leg: the db input and every
  github.event.inputs.db read are gone, so a profile runs every leg of the
  suite.
- semantic-search drops ci:run-semantic-search-tests. The full suite runs on
  a force-run (master, release branches, ci:run-all) or a profile; every
  other run is the Postgres Latest smoke run.

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

* cleanup

* Gate a profiling dispatch like any other run

The backend suites read the dispatch's `suite` input to run everything when
profiled. They now gate a profiling dispatch like any other run: in full on
master and release branches, by the diff anywhere else, with semantic-search
running its smoke leg.

The profiler switch keys off the dispatch event instead. backend.yml sets it
on the test step; app-db, drivers and semantic-search set it in their workflow
env, which the test-driver action inherits, so the stress-test workflows that
dispatch the action directly still never profile.

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

* rename

* cleanup

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <iethree@gmail.com>
2026-10-06 22:05:46 +00:00
Oleksandr Yakushev
7d638168dd [pivot] Optimize node formatting (#83057) 2026-10-07 01:03:57 +03:00
Chris Vandoodewaard
519834f9ee Consolidate sharing e2e tests (#83657)
* Remove duplicate public sharing e2e tests

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

* Merge sharing reproductions e2e tests that share setup

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

* Merge downloads e2e tests that share setup

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

* Merge public sharing e2e tests that share setup

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

* Repair downloads e2e tests that cannot fail

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

* Repair sharing reproductions e2e tests that cannot fail

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

* Drop unused setup in sharing reproductions e2e tests

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

* Remove duplicate subscriptions e2e tests

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

* Repair public sharing e2e tests that cannot fail

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

* Drop unused setup in downloads e2e tests

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

* Drop unused setup in public sharing e2e tests

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

* Remove duplicate alert e2e tests

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

* Merge alert e2e tests that share setup

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

* Repair alert e2e tests that cannot fail

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

* Drop unused setup in alert e2e tests

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

* Remove duplicate public sharing e2e tests

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

* Merge subscriptions e2e tests that share setup

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

* Repair subscriptions e2e tests that cannot fail

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

* Drop unused setup in subscriptions e2e tests

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

* Fold public dashboard link creation e2e test into 20393 repro

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

* Fold 30314 subscription cancel e2e test into 14117 test

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

* Fold 16108 tooltip e2e test into download format test

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

* Merge public sharing e2e tests that share setup

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

* Place merged 18352 card below the Orders card

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

* Type a name before Escape in the 24629 subscriptions e2e test

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

* Repair public sharing e2e tests that cannot fail

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

* Tag sharing e2e tests that need maildev as external

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

* Drop unused setup in public sharing e2e tests

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

* Fix popover check after Escape in dashboard downloads e2e test

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

* Keep the webhook channel in the 12349 alert e2e test

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

* Tighten subscriptions e2e checks found in parity review

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

* Tighten sharing reproductions e2e checks found in parity review

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

* Restore admin and UI-created link coverage in public sharing e2e tests

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

* Fix 7 sharing e2e failures from CI

* Restore 48402 coverage and merge more sharing e2e tests

- Restore the metabase#48402 delete-alert step in the 12349 test and check the delete modal
- Make 18669 check the email filter value and 21559 check the card image
- Merge public dashboard download flag tests with the real downloads
- Merge static-viz, branding, and default-filter subscription tests
- Merge dashboard PDF and PNG download tests and check download parameters
- Merge the two background=false public dashboard tests

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

* Address review on sharing e2e consolidation

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

* Fix sharing e2e CI failures

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:59 -04:00
Chris Vandoodewaard
cc2db95954 Consolidate monitor e2e tests (#83655)
* Fold monitor e2e subset tests into their supersets

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

* Merge monitor e2e tests that share setup

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

* Repair monitor e2e checks that cannot fail

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

* Strengthen weak monitor e2e checks

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

* Flatten monitor erroring questions e2e setup and fix titles

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

* Fold monitor empty task logs test into the task details test

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

* Anchor moved monitor e2e checks on rendered content

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

* Allow extra rows in monitor task runs tab switch check

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

* Fold monitor task logs test into the task details test

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

* Merge monitor task runs tab and details tests

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

* Merge monitor task runs filtering and entity picker tests

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

* Merge monitor MCP analytics tests

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

* Fix review findings in monitor e2e tests

- Cold load the task run details page
- Check the entity loader right after the started-at selection
- Check task rows, not only the tasks table card
- Match only the MCP events query in the tool calls waits
- Remove a redundant empty state check

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:37 -04:00
Chris Vandoodewaard
ae0dbb421a flake fix: displays object details, breadcrumbs, relationships, email links, and 404 error state (#83762)
remove flaky assertion, match similar test
2026-10-06 17:39:22 -04:00
Chris Vandoodewaard
94ef260096 Consolidate explorations e2e tests (#83654)
* Fold explorations picker search test into the manual flow test

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

* Fold explorations click-to-select test into the sidebar tabs test

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

* Merge explorations trash page tests into the trash banner test

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

* Strengthen weak explorations navigation checks

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

* Remove unused addMetricsToExploration e2e helper

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

* Consolidate more explorations e2e tests and fix weak checks

- Merge the Summary document test into the brush explore-further test
- Merge the personal collection test into the trash banner and /trash test
- Check that the toast View action moves to a different page
- Wait for the search request with the typed query in the metrics picker

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

* Address review feedback on explorations e2e consolidation

- Append an unfiltered page to the Summary and check both card embeds
- Check the new page renders after the cartesian toast View
- Load /trash cold before the trash row actions and flatten the trash test
- Merge the sidebar hide pages test into the tabs test
- Share one beforeEach and afterEach under a top-level describe

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:13:42 -04:00
Chris Vandoodewaard
c6b4dee91c run timing workflow daily with a one day lookback (#83780) 2026-10-06 16:52:46 -04:00
Nemanja Glumac
54fe3c4365 Consolidate data studio e2e tests (#83736)
* Remove duplicate data-studio e2e tests

Drop tests whose steps and assertions already run in another e2e test:
a measures-queries identity test that was copied line for line, a
CountIf-segment measure test that matches another one step for step,
an empty-folder snippet test that never exercised folder expansion, a
skipped segment-cycle test whose asserted text does not exist in the
product, and an MBQL SQL-preview LIMIT check that could not fail.

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

* Fold subset data-studio e2e tests into their supersets

Delete tests whose steps already run in a host test with the same setup,
and add their few unique checks to that host: empty states and /new
URLs into the create flows, unsaved-changes guards, breadcrumbs,
dependency graph tabs, read-only views into the edit tests, transform
target errors, the Python library entry and editing, the inspect tabs,
and the SQL-preview LIMIT check on a transform without a limit.

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

* Repair data-studio e2e assertions that could not fail

Make checks go red when the behavior breaks: wait for the run request
before reading the run label (it shows the previous result on mount),
assert values that differ before and after the action (checkpoints,
filtered rows, converted SQL, Python output, rewired dependents after a
new source row), fix selectors that never match (tab roles, a document-
wide get, wrong measure names), assert the outcome of saves that had
nothing after them, and drop checks on buttons that never render.

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

* Strengthen weak data-studio e2e assertions

Give negative checks a positive precondition on the same element so
they cannot pass before the UI mounts, wait for the request before
checks that follow a save or a navigation, use the multi-toast helper
where a previous toast may still show, guard .each loops against empty
sets, and create the metric picker fixtures on the database the picker
shows.

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

* Merge data-studio e2e tests that share setup

Run tests with the same restore, user, fixtures and entry page as one
test, so each restore and page load serves several checks. Every part
keeps its original starting state: read-only parts run first, parts
that mutate data run last, each part starts from its own visit where
the state matters, and toasts are dismissed between parts. Tests that
replace a source or convert a model now create all dependents and run
the replacement once.

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

* Drop unused setup in data-studio e2e tests

Remove restores that a later restore overwrites, intercept aliases that
nothing waits on, duplicate snowplow resets, duplicate helpers and
unused helper parameters. Fix wrong logs, titles and comments.

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

* Fix data-studio e2e merges found in the parity pass

Load the notebook fresh before the second data-picker part, check the
closed popover without the visibility guard, delete segments after a
direct visit as before, remove the failed-run test that also runs in
the runs filter test, and keep the snowplow check for the job creation
flow.

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

* Fix and restore data-studio e2e checks from review

Assert the Python transform output as a scalar, assert the archive
banner instead of a URL check that could not fail, and bring back the
collection checks after a direct library visit. Give the
multiple-template-tags part its own visit without a database pick, run
the non-admin source-replacement check before any sandbox exists, drop
the run-status polls before the run label, and match the join table
label by text.

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

* Add positive checks before data-studio e2e negatives from review

Assert the element or container is there before each remaining
negative check (sync modal, cleared-description hovercard, row
checkboxes before read-only mode, the transform row before deletion,
the transforms header in the empty state), and wait for the dashcard
query after a filter change before checking rows.

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

* Merge more data-studio e2e tests that share setup

Run more tests with the same restore, user and fixtures as one test:
measure queries on one measure fixture, template tags with the data
reference flow, inspect loading with the summary lenses, the transform
reproductions, transform ownership with the inline settings, tag
editing, published-table queries, metric editing and moving, table
dependencies, segment tabs and dependencies, sort orders, and table
details. Each part starts from its own visit where the state matters,
and parts that change data run last.

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

* Fix data-studio e2e tests that failed in CI

Drop the test tables before the writable-database restore again: the
restore cannot drop the source table while the child table still has a
foreign key to it. Submit the transform form by its submit button,
which shows "Failed" for a few seconds after the rejected save. Close
the run error modal with Escape, since it has two close buttons, and
check a tag that is listed in the dropdown, because selected tags are
not listed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:44:38 +02:00
Timofey Kachalov
ed1d3d1004 Data apps: move resource collections into a data-apps namespace and serialize them with the app (#83766) 2026-10-07 00:38:11 +04:00
Jeff Bruemmer
b9b5c25e7a docs - transform indexes (#83598) 2026-10-06 20:26:52 +00:00
Alexander Polyankin
d40e005888 Mock the engines endpoint in the DatabaseEditApp and DatabaseListApp specs (#83782)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:07:08 +02:00
Nemanja Glumac
b1ddf5b6c4 Consolidate dashboard filters 2 e2e specs (#83700)
* Move dashboard-filters-2 e2e specs into dashboard-filters

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

* Remove unused dashboard filter query-stage helpers

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

* Remove duplicate dashboard filter target picker empty state test

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

* Fold dashboard filter reproduction subsets into their supersets

Run the target picker empty-section search in the empty-state test, add a
never-connected card to the disconnected-card reload test, and count public
dashcard requests in the public remapping test.

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

* Repair dashboard filter e2e checks that could not fail

Prove click-through navigation, give negative checks a positive precondition
on the same element, assert filtered results that differ from the unfiltered
card, wait for reverts and stored last-used values, map the first-stage
breakout column in the query-stage test, and check the first card in the
first-stage aggregation test.

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

* Merge dashboard filter e2e tests that share setup

Run the remapping, comma-separated value, sql model field value and public
and embedded remapping reproductions once per setup, and apply a query-stage
filter after checking the mapping options on the same dashboard.

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

* Drop unused dashboard filter e2e setup

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

* Tag the writable database filter linking reproduction as external

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

* Check merged dashboard filter reproductions against the right state

Assert the filtered public dashcard by row count, since its Name column is
outside the card, and clear the last used remapped value before loading it
from the url.

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

* Select the city from the autocomplete suggestions in the city filter test

The city filter searches values instead of listing them, so the selected
value shows as a pill in the search input.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:36:16 +02:00
Nemanja Glumac
fd95839a47 Consolidate dashboard filter behavior e2e specs (#83692)
* Drop temporal unit public and embedded dashboard tests

The multiple column breakouts dashboard test sets temporal unit
parameters on the same dashboard through a public link and a static
embed, and checks the card columns after each change.

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

* Check the auto-wired card keeps its mappings in temporal unit test

Only the manually mapped card was asserted, so overwriting the category
mapping on the auto-wired card went unnoticed. Assert both cards and
drop a negative check that ran before any data loaded.

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

* Merge temporal unit parameter tests that share setup

Fold the model mapping into the question mapping test, the no-breakout
auto-wiring checks into the new card test, the default clearing into the
allowed units test, and the URL value tests and the native query tests
into one test each.

The no-breakout card check now waits for the other new card's
suggestion, and the default clearing checks the unit was excluded before
asserting the default stayed.

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

* Fix temporal unit parameter test titles

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

* Fold temporal unit chevron alignment check into reset test

The chevron alignment test built the same no-default temporal unit
parameter as the temporal unit reset & clear test. Run its check at the
end of that test, in the parameter sidebar it already opens, and assert
both chevrons are present before comparing them.

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

* Fold reset-with-open-popover check into number filter test

The required number filter test already resets a changed value with the
widget's revert icon. Open the widget popover first and assert it closes
after the reset, which covers the standalone reset-with-open-popover
test. Drop that test along with its describe and duplicate beforeEach.

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

* Fold required text filter set-to-default check into reset test

The standalone test deselected all values of a required filter whose
value already equalled its default, then clicked "Set to default" and
asserted the unchanged "4 selections", so it passed even if the button
did nothing. The multi-value text reset test now changes its required
filter to a non-default value first, deselects all, clicks "Set to
default" and asserts the default is applied and the reset icon is gone.

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

* Use a distinct other value in multi-value text reset test

The other value had two selections, as did the default, so every
"2 selections" check passed whether the filter held the default or the
other value. Pick three values so the widget text tells them apart.

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

* Fold auto-wiring subset tests into their supersets

The Undo flow now runs in the "already has a parameter" test, which
undoes before mapping card 1 to Address instead of clearing it by hand.
The auto-connected toast check moves into the add-a-card Undo test, the
replace-a-card flow into the parameter mapping test (now also checking
the replaced card is mapped), and the 8s undo-toast timeout into the
12s timeout test with tighter bounds.

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

* Add positive preconditions to reset & clear negative checks

checkResetAllFiltersHidden asserted "Reset all filters" was absent
without showing the menu had opened; it now sees "Enter fullscreen" in
the same menu first. The first "no status icon" check on the required
filter now asserts the widget shows the default value first.

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

* Retitle number range reset test and drop dead comment

The number "multiple values" test uses "between" parameters, so call it
a range. Remove a commented-out line in the text multi-value test.

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

* Make foreign key auto-wiring filter checks able to fail

Both tests filtered by ID 1 and asserted cells contain "1", which the
unfiltered tables also satisfy, and waited on a card query alias that
matched the initial dashboard load. They now filter by 3, assert the
exact cell text "3", and wait on an alias registered just before the
filter is applied.

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

* Add positive preconditions to auto-wiring absence checks

The card 1 "User.Name" absence check now first confirms card 1 shows
"Select…", and the no-suggestion toast check first confirms card 0 is
mapped to "User.Name", so neither passes before the mapping UI renders.

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

* Merge unsaved and reset draft filter auto-apply tests

Both tests set a draft filter value on the same dashboard with auto-apply
off. Run the reload check first, asserting the widget shows its
placeholder rather than the draft value, then the reset steps.

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

* Drop unused intercepts from auto-wiring spec

Nothing waits on the "@dashboard" alias or reads the card query spy,
and the "@cardQuery" alias has no remaining waits.

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

* Fold cancelled-edit draft filter test into dashboard edit test

Both tests set the same draft filter value before entering edit mode.
The edit test now cancels an edit first and checks the draft value and
apply button survive, then edits and saves as before. Drop the emptied
describe.

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

* Fix stale comment in auto-wiring toast timeout test

The comment said auto-wiring toasts share an id; each toast now gets
its own id, so it states only what the test guards.

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

* Fold auto-apply enable snowplow test into disable test

After disabling auto-apply and seeing one "auto_apply_filters_disabled"
event, the disable test now enables it again and checks the event count
stays at one, covering the standalone enable test.

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

* Check the checkbox state in auto-apply filter reset test

The reset check asserted "not.be.checked" on the "Gadget" label text,
which is never checked, so it passed whether or not the reset worked.
Query the checkbox by its label instead, and assert it is checked after
selecting it so the final unchecked state is meaningful.

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

* Check rows after disabling auto-apply in toggle test

The last step only checked the widget text after disabling auto-apply,
and its log described draft values although the change had already been
applied. Assert the card still shows the 107 Gadget and Widget rows and
describe the step as it runs.

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

* Fold filter type change subset tests into their supersets

The default-operator walk through Number, ID, Date and Location now
runs in the mapping reset test before it saves, and the mapping
cleared/restored checks run in the saved parameter value test, which
already switches Text to Number and back on the same dashboard.

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

* Order filter management checks after the state they test

The hidden filter widget check after saving now has a positive
precondition: the shared setup confirms the "Text" widget is shown
before editing. The operator change test checks the mapping survives
only after "No default" confirms the operator change applied, so the
check can't match the pre-change DOM.

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

* Add menu precondition to auto-apply no-permission test

The test asserted "Edit settings" was absent without showing the
dashboard menu had opened. Assert "Enter fullscreen" in the same menu
first, and title the test after what it checks.

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

* Fix default operator log messages in filter management spec

The Number log said "Between" while asserting "Equal to", and the
Location log said "Date".

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

* Merge dashboard filter default tests that share setup

Both tests built the same two-filter dashboard and visited it with the
same params. After setting Filter Two's default, the remaining test now
sets Filter One's default to "Baz" and checks the URL and widgets keep
Filter Two's value before and after saving.

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

* Fold static list clear-and-restore test into number source spec

The clear-and-restore spec built the same Orders count dashboard with a
number filter and static list source as the number source static list
test. That test now edits its dropdown filter afterwards, switches it to
a text filter (list cleared), connects it to Email, and switches it back
to number (list restored). Move the helpers and delete the spec.

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

* Check number filter dropdown hides raw values behind labels

The number/string label test only checked the label is listed; it now
also checks the raw value "1" is not, as the string/string test does.

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

* Merge dashboard filter source tests that share setup

Each group now builds one dashboard with a distinctly named filter per
case and scopes every widget by name:
- structured Contains, structured Is and native Is question sources,
  archiving each source and checking the plural "2 filters" warning
  for the shared GUI source and the singular one for the SQL source
- string, number and id values remapped to string labels
- static list dropdown, static list search and field search box
- IP address and type/Quantity custom labels on postgres

The filter helpers take the filter name instead of clicking "Text".

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

* Drop unused dataset intercept from filter source spec

Nothing waits on the "@dataset" alias.

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

* Stop restoring twice before the UUID chained filter test

The test sat under the chained filter beforeEach, so it restored the
default snapshot and signed in before restoring postgres-writable
itself. It now has its own describe and does only its own setup.

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

* Retitle UUID chained filter test after what it checks

"should work for all field types" described none of it; the test
checks a UUID primary key column is offered for an ID filter under the
"Update a dashboard filter" click behavior.

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

* Describe the default replacement step in filter defaults test

The log said the step removes the default value and its URL
placeholder, but it replaces Filter Two's default with "Foo".

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

* Fold nodata filter mapping checks into data permissions test

Merge the admin and nodata filter tests into one, and add the nodata
edit-mode checks there: the card actions panel shows without an
"Add a filter" button (shown to the admin in setup), and the filter
mapper shows the key icon. Remove the parameters spec tests that only
covered those nodata checks.

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

* Fold subset dashboard parameter tests into their supersets

Each removed test ran on the same setup as a test that already covered
its first steps, so its remaining steps now run at the end of that test:
- resetting a last used value and reloading: last used value test
- removing a heading dashcard: heading dashcard removal undo test
- removing a question dashcard: question dashcard filter edit test
- own-card connection message: other-tab connection test
- showing all inline parameters while editing one: question dashcard
  filter removal test
- undoing filter moves: single tab filter move test
- dashboard_filter_moved tracking: dashboard_filter_created test

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

* Repair dashboard filter checks that could not fail

- Data permissions: drop the search intercept, whose alias was mangled
  into the URL, along with the unused 403 branch that waited on it.
  Open the filter through its widget instead of matching any "Text",
  drop the "Orders" and dashboard-title clicks that always matched,
  click "Add filter" in the popover without force, and check the
  widget value and URL param.
- Heading filters: check the grabber icon on the heading card holding
  the filter, not on the question card.
- Text card variables: check the Count widget instead of Category twice.
- Public dashboards: map the Count filter to the dashcard's own card so
  its widget renders, and check it does.
- Granular filter preference: wait for the dashcard query after updating
  the header filter before checking the card.

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

* Strengthen weak dashboard parameter assertions

- Filter removal: check the filtered-out rows are present before
  filtering and that matching rows render before checking the others
  are gone; check only one filter widget remains after removal.
- Multiple filters: check the values request count after the reopened
  popover has rendered its values.
- Compare multi-slug URLs with expectSearchParams instead of exact
  search strings, whose param order is not deterministic.
- Inline auto-wiring and dashcard duplication: check the own-card
  message renders before checking the mapper has no Category option.
- Disabled embedded parameter: check the card shows unfiltered data.
- Filter focus: check the move toast renders before checking it has no
  "Show filter" button.

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

* Merge dashcard refetch-on-save tests into one

The four tests shared the same setup. Run their saves in sequence in
one test, so each later count also catches a fetch an earlier save
should not have made: no fetch after saving a disconnected filter or
an unchanged dashboard, then 1 after connecting one card, 2 after
disconnecting it, and 4 after connecting both.

Register the request spy before the setup save instead of after it, so
the disconnected-filter check can fail if that save fetches data.

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

* Drop duplicate dashboard PUT intercept

The dashcard duplication test registered the same updateDashboard
intercept twice; keep the one at the top of the test.

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

* Retitle merged dashboard filter tests

Name the steps each merged test now covers, including the admin and
nodata usage and nodata edit-mode checks in the data permissions test.

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

* Drop auto-apply toast tests for public and signed embeds

The slow dashboard toast is only shown to users who can edit the
dashboard, and the tests could not make the dashboard slow: cy.clock
does not affect performance.now and the card request was never delayed.
Neither test could fail.

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

* Check the other tab message while the inline filter is connected

Also compare the public dashboard URL params regardless of slug order.

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

* Name both toast timeouts in auto-wiring timeout test title

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

* Describe temporal unit parameter priority without an issue reference

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

* Fix dashboard filter tests that failed in CI

The heading text is an input after the parameter sidebar closes, so
check the saved dashcards instead. The add-card sidebar stays open after
a card is added, so pick the next card from it. The auto-wired table card
is below the fold, so check its column header exists.

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

* Restore the non-required filter reset with an open popover test

Only the non-required filter reset closes the popover. The required
filter reset keeps it open, so the check cannot run on the required
number filter. The test now uses the outer describe setup.

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

* Find temporal unit auto-wired cards by name after save

Saved dashcards are ordered by their layout position, so the added
cards no longer have the indexes they had while editing.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:33 +02:00
Maksym Yakubych
190a31be82 Derive the mini model only from what a connection was listed as serving (#83139)
* Derive the mini model only from what a connection was listed as serving

With nothing stored, llm-mini-model resolved to the provider type's mini
model straight from the registry. Connecting lists the account's own
catalog but never checked that guess against it, so a connection whose
listing did not include the cheaper tier ran every quick task — conversation
titles — on a model it could not serve, and the admin page showed the mini
model as unconfigured because the derived value was not among the options.

Every listing now records whether it included the type's mini model on the
connection, and the derivation reads that instead of the registry: the
cheaper model when the connection was seen to offer it, the Metabot model
otherwise. The per-type mini models stay in the registry as the tier a
listing looks for.

* Tighten mini-model docstrings
2026-10-06 17:33:20 +00:00
Nathan Voxland
f062ce0832 Improve error handling in query responses (#82531) 2026-10-06 17:20:30 +00:00
Oleksandr Yakushev
01bd5d9ea5 [metadata] Analysis optimizations (#83635) 2026-10-06 20:09:04 +03:00
Jeff Bruemmer
012e199084 docs - mcp tool documentation gen (#82592) 2026-10-06 12:58:25 -04:00
Andrei Chernykh
a6ed8f0fef Migrate the table info card off Emotion (#83444)
* Migrate the table info card off Emotion

* Use CS overflow utilities in the table info card
2026-10-06 09:57:10 -07:00
John Swanson
e53a1611b4 Log applied sandboxes in the query execution audit log (#78975)
* Log applied sandboxes in the query execution audit log

Sandboxed (row-level security) queries used to show up in Usage
Analytics' query log as completely normal queries, making sandboxing
impossible to debug from the audit log (#71604, UXW-3767).

The logged query blob can't carry this information: it lives in the
query table keyed by a hash computed before preprocessing, so a
sandboxed and an unsandboxed run of the same question share one row.
Instead, record the restrictions per-execution:

- The sandboxing middleware records each sandbox it applies on the
  query under a namespaced key, merged across preprocessing passes.
  Applied sandboxes are derived from the
  :query-permissions/sandboxed-table markers left on each replacement
  stage -- the same source of truth is_sandboxed comes from.
- merge-sandboxing-metadata copies the entries into the results
  metadata (userland queries only), next to is_sandboxed.
- process-userland-query saves them to the new
  query_execution.sandbox_details column and strips them back out of
  the response returned to the client.

Each entry records table_id, sandbox_id, group_id, card_id, and the
login attributes used (attribute name -> target field id). The user's
actual attribute values are only included when
analytics-pii-retention-enabled is set, matching how that setting
gates other user-specific data in Usage Analytics.

The column is exposed in v_query_log (v7) and the shipped Query log
model.

* Give the attached sandbox audit details a named schema

Review feedback:

- Rename `record-sandbox-details` to `attach-sandbox-details`. It puts data
  on the query; it doesn't persist anything.
- Replace the bare `:map` and `::lib.schema/query` schemas with
  `::details-entry`, `::details` and `::query-with-details`, so the key
  sandboxing attaches during preprocessing is typed both where it is
  produced and where `merge-sandboxing-metadata` reads it back.
- Mention sandbox attribute values in the `analytics-pii-retention-enabled`
  description.

* Declare sandbox_details in the schemas master closed

Rebasing onto master brought in closed argument schemas and the checker in
`metabase.util.malli.closed-schemas`, which rejects any undeclared key and any
shape it cannot see. Every schema the new key passes through has to name it:

- `metabase.lib.schema` gains `::sandboxing.details.entry` and
  `::sandboxing.details`, and declares the `::details` key on the query map
  next to the existing sandboxing key. This is the one definition of the entry
  shape; the enterprise middleware and the row schemas below point at it.
- The legacy MBQL query schema declares the same key, since preprocessing
  round-trips the query through legacy and back.
- The result metadata threaded through the rff chain, and the QueryExecution
  insert schema, declare `:sandbox_details`.
- The SDK and batch-processing tracking rows pass the value through, so they
  declare it as a deliberately-open map rather than restating the shape.

Also renumber the migration from v64 to v65: v64 has branched for release, so
a v64 id on master would not roll back with the rest of v65.

* Let the query-with-details conjunct through the closed-schema check

An `:and` conjunct that constrains a single key is an open map, which
`metabase.util.malli.closed-schemas` refuses as an argument schema. Mark it
with `:malli.core/default`, the escape the checker documents for exactly this
shape: every other key is validated by `::lib.schema/query`, the first
conjunct, which is where `::details` is declared.

* Cover the new internal query key in the serialize fixture

`internal-keys-fixture-covers-the-schemas-test` asserts the fixtures carry
every internal key the schemas declare, so the serialization tests cannot
drift. Add `::details` with a schema-valid sample entry.
2026-10-06 09:44:55 -07:00
Alexander Polyankin
2dd2330f97 Create stub databases for missing databases on serdes import (#83574)
* Create stub databases for missing databases on serdes import

* Synthesize stub databases in the import resolver

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

* Drop Database load dependencies and fix tests

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

* Trim the admin database page for stub databases

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

* Test importing content on a missing database as a stub

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

* Exclude stub databases from serdes export

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

* Show database status in the admin list and clarify the stub healthcheck message

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

* Exempt stubs from the network policy, keep is_stub out of serdes, and wait for the save in the stub e2e test

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

* Update the is_stub extract test now that is_stub is never exported

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

* Import every serialized Database as a non-stub

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

* Fix review findings: no Database load deps, empty details skip the network check, name-only config matching

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

* Remove dead missing-database handling now that imports stub missing databases

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

* Test the stub database admin page sections and list request

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

* Memoize every import lookup and drop the cache when an entity's load fails

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

* Drop the Dimension path helper master replaced with serdes/field-path->field-ref

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:42:23 -04:00
Maksym Yakubych
5927b9bce6 Let ee-embedding-provider name the connection semantic search embeds through (#82521)
* Let ee-embedding-provider name the connection semantic search embeds through

* Keep registered embedder names from being shadowed by connection keys

* Skip the connection lookup when ee-embedding-provider names an embedder

* Do not fall back to the openai connection once ee-embedding-provider names one

* Warn when removing the connection semantic search embeds through

* Let the vLLM preflight test see the losing probe start before it is cancelled

* Keep ee-embedding-provider to semantic search and to OpenAI connections
2026-10-06 13:40:29 -03:00
Tyler Plude
355147d6a9 [GHY-4748] Exclude tables hidden in user settings from sync (#83494)
* add table-user-visibility-type to the warehouse schema overlay

* exclude tables hidden in user settings from sync

* test that the 064 user-settings migration keeps hidden tables hidden

* match field sync batches over hidden tables before skipping them

* tighten hidden-table sync tests and docstrings
2026-10-06 09:25:42 -07:00
Nathan Voxland
3e57a280fe Apply the configured path settings to the Snowflake OIDC token file (#83496) 2026-10-06 11:06:09 -05:00
Will Fry
01d4659a3e GDGT-3241: SegmentedControl constrained text + icon props (#83336)
* GDGT-3241 Constrain SegmentedControl items to text and icon props

Items are now typed data (label, icon, ariaLabel) instead of arbitrary
label nodes, and the component owns icon size and spacing.

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

* Update Loki Snapshots

* set line height on SegmentedControl label

* add second click to assert handler doesnt refire on multiple clicks

* interface -> type

* add test

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-06 15:42:18 +00:00
Sébastien
db875cbd2a Add a Refresh button to the embedded question when automatic reruns are off (#83732) 2026-10-06 16:54:33 +02:00
Sébastien
8561804a1a Hide MCP UI tools from clients with inline charts turned off (#83644) 2026-10-06 16:54:00 +02:00
github-automation-metabase
66c6739fdd Auto-update documentation for master (#83697)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-10-06 10:48:35 -04:00
Anderson
5ddcbeeedf Show the metric icon for bookmarked metrics in the app sidebar (#83666)
Show metric icon for bookmarked metrics in the sidebar
2026-10-06 11:19:27 -03:00
Anna Tomka
762512b71d GDGT-3291: drop creator from the create/send notification request schema (#83555)
* GDGT-3291: drop client-sent creator in POST /api/notification/send

* GDGT-3291: clarify why /send drops the client-sent creator

* GDGT-3291: drop creator from the create/send request schema and stop sending it on Send now
2026-10-06 13:58:32 +00:00
Jeff Bruemmer
12f8e514d4 docs - 432 note on remote sync (#83667) 2026-10-06 13:21:59 +00:00
Uladzimir Havenchyk
96e656af6e fix: port undolisting toasts to the toast spec always dark (#83311)
* port remaining toast contents to the always-dark toast surface

* add spacing between toast action buttons

* address review
2026-10-06 13:02:40 +00:00
Andrei Simionescu
83f20bc0b5 Add Claude Opus 5.5 and Sonnet 5.5 to Metabot (#83647) 2026-10-06 14:00:04 +01:00
YB
2d4ff73f16 Bump libthrift to 0.25.0 (#83721)
bump libthrift to 0.25.0 in databricks and hive-like drivers
2026-10-06 14:07:38 +02:00
Nemanja Glumac
6623df6c22 Consolidate permissions e2e tests (#83645)
* Remove duplicate permissions e2e tests

Drop the second copy of the create-queries 'query builder only' group
view test, the admin-permissions blocked-viz test that the nodata
reproduction already runs with the same graph and user, and the
transforms list denial test whose steps run in the grant-after-deny
test. The coverer for the blocked-viz test now scopes its error text to
the query builder, and the grant-after-deny test now asserts the key
image.

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

* Merge permissions e2e tests that share setup

Fold tests into siblings that run the same restore, user, token and
permission graph, keeping every step and assertion in its original
starting state:

- create-queries: 'no' in group view runs after the 'query builder and
  native' save, as a second save for another group
- baseline: the unauthorized-path loop runs as one test; the nodata
  data-load check moves into the nodata reproduction, which also hosts
  the blocked viz-settings reproduction
- reproductions: duplicate-in-view-only-collection follows the
  save-modal reproduction; the two raw-mode pivot/combo checks share
  one test
- application/admin: the application help panel joins the data help
  test; default subscriptions and monitoring checks share one test;
  the collection options check runs inside the collections edit test
- transforms: list page joins create-via-UI; create and run API denials
  share one test
- view-data: the blocked-table QB question joins the native question
  test
- sandboxing via API: existing and new user attributes share one test;
  the three normal-user sandbox checks share one test
- impersonated: drop the outer beforeEach repeated by the inner one

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

* Repair permissions e2e assertions that could not fail

- view-data: the impersonation-then-granular test switched the wrong
  database to Granular; the database-view sandbox test clicked Save
  changes without confirming, then re-read the unsaved draft
- sandboxing via UI: every custom-column case only checked '20 rows',
  which the unsandboxed limit-20 query also shows; expression cases now
  check Gizmo-only rows, literal cases check sandboxed responses
- sandboxing via API: a misspelled columnAssertion and an undefined
  PEOPLE.USER_ID skipped the column checks; the user attribute test
  now checks the saves
- admin help: not.contain with a RegExp is stringified and never fails;
  the stub intercept now registers before the visit
- application settings: drop absence checks for texts that no longer
  exist in that container
- downloads: assert the limited/full download level, and reload after
  saving download permissions
- reproductions: the 20717 regex had a typo and the 22727 text check
  passed on an empty picker; 39221 now performs a real user-local
  setting write before checking for a site settings fetch

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

* Add positive preconditions and waits to permissions e2e tests

Pair negative assertions with a positive check on the same element,
wait for the requests that the following checks depend on, and assert
exclusive category lists for the native-question sandbox. The revoked
subscriptions test now sets up SMTP so the missing Subscriptions button
reflects the permission rather than the absent email channel, and the
download denials check the download button by label as well as icon.

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

* Drop unused setup and fix names in permissions e2e tests

Remove intercepts that are never waited on, an unused erroring question,
a commented-out duplicate line, a reload followed by a visit, and a
data model click that only unhid a table before navigating away. Tag
the misconfiguration spec @external since it uses the writable
Postgres database. Fix the 45073 title, the 14873 describe name, a
typo, unused helper params, the OSS index constant used after the
downgrade, and the title of the no-token View data column test.

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

* Restore the reload before opening the native editor

Clicking a table on the browse page leaves the app on an ad-hoc
question URL, so the native editor helper's hash-only visit would not
reload the page without it.

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

* Test real models in native download permissions tests

The model steps only renamed the card, so they re-tested the plain
question. Convert the card to a model and visit it as one. Also assert
the visible category values before the absent ones, so the absence
checks can't pass before the values have loaded.

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

* Merge two more permissions e2e tests that share setup

- sandboxing via API: the 14990 subscriptions sidebar check runs in the
  sandboxed subscription test, which has the same SMTP setup, sandbox
  and user
- view-data: the unsaved impersonated-then-granular check runs before
  the saved variant, from a fresh visit

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

* Chain permissions e2e tests whose earlier parts save nothing

Run read-only or draft-only tests first and the saving test last, with
a full page load between parts:

- admin-permissions: the 30494 toggle check before collection editing;
  the group-view revision-change check before the database-view one
- view-data: preserve-on-granular and parent inference before the
  group-view granular save
- impersonated: impersonated access checks before the cache check,
  signing back in as admin first
- transforms: deny, grant, then deny again in one test

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

* Fix positive preconditions in permissions e2e tests

The native editor run button is scoped by test id because the hidden
floating run button also carries the Refresh label and stays enabled.
The view data popover omits the current value, so it is checked for
Blocked instead of Can view.

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

* Close the collection permission popover by reloading

Escape does not dismiss the permission popover, so the test now visits
Second collection directly, which also discards the open popover.

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

* Address permissions e2e review feedback

Merge the remaining tests that share setup: the transforms column check
and the save flow, the transform page denial and the API denials, the
adhoc native check and the unauthorized paths, the blocked card check and
the nodata reproduction, and the two database view create queries tests.
Run the steps of merged tests once instead of repeating them, and drop
the second #37811 check.

Remove an unused join and sandbox setup and a final visit with no check,
and assert the mini picker has loaded before checking an absent database.

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

* Check a visible mini picker item before the absent database

The transforms mini picker lists no databases for this user, so the
positive check uses Our analytics.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:08 +00:00
Romeo Van Snick
7907c04a71 Fix parseZone day arithmetic for datetimes without an offset (#83394)
* Fix parseZone day arithmetic for datetimes without an offset

* Assert the sync start time with the formatter the component uses

* Parse both sides of the SmartScalar same-day comparison with parseZone
2026-10-06 19:00:23 +11:00
Romeo Van Snick
41661db787 Keep engines and the timezone list out of the bulk settings payloads (#80138)
* Keep engines and the timezone list out of the bulk settings payloads

Both are large, change only when the instance is upgraded, and are read by a
handful of screens, so inlining them into every document and into
GET /api/session/properties costs every visitor. :include-in-list? false leaves
them out of those payloads while GET /api/setting/:key still serves them.

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

* Fetch engines and the timezone list by key in the screens that need them

The settings payload no longer carries either value, so the consumers read them
through RTK queries against GET /api/setting/:key instead of getSetting. Both
queries hold their entry indefinitely, since neither value changes without a
restart.

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

* Wait for model metadata before opening the Actions menu in e2e

The menu is gated on supportsImplicitActions(), which reads the source table's
metadata, so the test raced GET /api/card/:id/query_metadata. The existing
getModel alias does not cover that request, because a Cypress glob does not
cross a path separator.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 18:59:42 +11:00
Sébastien
0feec26aea Fix flaky cursor-filtering diagnostics test (#83398) 2026-10-06 09:30:32 +02:00
github-automation-metabase
39ea08c8d8 Update translations 2026-10-06 (#83725)
* Update translation sources 2026-10-06

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-10-06 06:22:08 +00:00
Alexander Polyankin
9050b176d6 Move Dimensions to FieldUserSettings in serdes (#83652) 2026-10-06 02:26:31 +00:00
Fraser
145357fbb1 Skip unused addons and font loads in Loki screenshot runs (#83068) 2026-10-06 14:23:32 +13:00
Tyler Plude
e2369cc1d4 [GHY-4755] Only follow a discovered remote sync task the current user started (#83579)
only follow a discovered remote sync task the current user started
2026-10-05 16:28:49 -07:00
bryan
d6b2903ccd Skip the redundant audit DB sync after a fresh install (#83681)
Skip the redundant audit DB sync after a fresh install (GHY-4728)

The install sync in maybe-install-audit-db! sees every analytics view but
never recorded last-analytics-views-checksum, so maybe-sync-audit-db! always
found the views stale on a fresh install and synced the audit DB a second
time. Record the checksum after the install sync. On a Postgres app DB this
removes the second sync (~2s); H2/MySQL still sync again for the engine swap.
2026-10-05 23:17:21 +00:00
Braden Shepherdson
27e4890c7a Metric Dimensions: Healthy remote sync, with unportable but stable IDs (#82751)
There are two significant changes here.

First, properly signal a change to `dependencies` and the remote sync machinery when the dimension (mappings) are edited. They were ignored before because they didn't publish an `:event/card-update`.

Second, for pre-curation metrics (with `:card_schema` 23 and earlier) the `:dimensions` are regenerated on each read, until the first time they are saved by an edit being made. Such metrics originally had *randomized* `:id`s, but since they're not persisted it made them permanently dirty from remote sync's POV. These are now based on the metric card's `:entity_id` as well as the mapped column, which means they are *stable* on any particular instance, *unique* within that metric (since it includes join details, not just the target field ID), but they are not *reproducible* across instances.

That's fine - the instance that created a metric pre-64 will export it with a now-stable ID; that will be persisted by downstream instances on import. Eventually the originating instance will persist them too, when it makes an edit to the metric. Since the generated IDs are now *stable*, all instances will converge on the true IDs.

Apologies for the noise, since this required adding `:entity_id` to a pile of `(t2/select [:model/Card :field :lists] ...)`, but its required by the `:card_schema` upgrades so we need to ensure that it's fetched.

Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: appleby <86076+appleby@users.noreply.github.com>
2026-10-05 22:21:38 +00:00
Andrei Chernykh
6bf81a1340 Migrate the billing help panel off Emotion (#83677) 2026-10-05 15:14:05 -07:00
Chris Vandoodewaard
bff0c3ba61 Consolidate search e2e tests (#83454)
* Remove duplicate search e2e tests

Drop tests whose steps and assertions are fully repeated by another test
in the same describe:
- snowplow "filter used in the URL" tests, each the first step of the
  matching "filter removed from the UI" test
- created_at/last_edited_at=thisday hydrate tests, covered by the
  remove-X tests that load the same URL
- native query hydrate test, covered by the toggle-off test
- "sandboxed" user runs of the created_by/last_edited_by tests: the user
  is not sandboxed in this spec and runs the same path as the normal user

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

* Merge search filter e2e tests that share setup

Run the apply, hydrate-from-URL and remove steps of each search filter in
one test instead of one test each. Every step that loaded its own URL
still loads it, now with a fresh alias, so each step starts from the same
state as before. The setup for each filter now runs once instead of up to
nineteen times.

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

* Merge search bar and recents e2e tests that share setup

- typeahead admin/normal tests run inside the matching universal search
  tests, which use the same users and entry page
- the empty-string search check runs inside the "Enter with no text"
  test, now with an assertion that no search request was sent
- the direct dashboard visit (metabase#35099) runs inside the homepage
  redirect test (metabase#34226), both fixed by the same change
- the long-description overflow check runs inside the 28788 test
- the recents list and keyboard tests run inside the 36868 test

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

* Repair search e2e assertions that could not fail

- expectSearchResultContent passed a callback to .first(), which Cypress
  ignores, so no collection label was ever checked
- the snowplow "type filter removed" check expected content_type [],
  which matches the earlier ["card"] event; the removal event has null
- the trash filter check for zero results passed while results were
  loading; it now waits for the empty state and the URL
- the recently created collection test clicked the modal title and found
  the collection in the root listing; it now opens Recent items

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

* Strengthen weak search e2e assertions

- check the created_at pill and URL after picking Today, since sample
  content is also created today and results alone can't show the filter
- expect exactly one result for last_edited_at=thisday, which excludes
  the Reviews table
- wait for the URL to change before reading results with non-retrying
  checks after a filter is applied or removed
- check that filters survive a new text query, not just the results
- check that full page search opened after Enter (metabase#20018)
- check that "Next page" took effect before changing filters (#65501)

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

* Drop unneeded setup from search filter e2e tests

- the created_by beforeEach restored the snapshot a second time
- the last_edited_by and last_edited_at setups edited questions through
  the query builder (two page loads and a save per question); a PUT as
  the editing user records the same revision that search reads the last
  editor and edit time from. The edit waits a second after creation
  because an edit in the same second is shown as "Created"

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

* Restore search e2e steps narrowed by the merges

- run the non-admin user filter steps for the "sandboxed" user again, in
  the same test as the normal user: it has no data access, so it is a
  different user setup even though no sandbox is applied
- pick two users at once from a fresh page, as the separate "more than
  one user" tests did, instead of adding a user to an existing pick
- hydrate created_by with the original URL parameter order
- check the results heading on the native query hydrate step
- wait for the verified request after the toggle instead of the URL alone
- catch any /api/search request in the empty-query check

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

* Consolidate search scenarios and preserve regression coverage

* Scope search e2e request counts to search context

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

* Consolidate remaining duplicate search e2e coverage

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:52:04 -04:00
bryan
6cb8e9e904 Add API changelog tooling: semantic OpenAPI diff with severity ranking (#82688)
* Add API changelog tooling: semantic OpenAPI diff with severity ranking

Answering "has the API changelog been updated?" currently means reading a
10k-line JSON diff by hand, so it falls to whoever is asked in Slack. This adds
the mechanical part of that job as two mage tasks.

`./bin/mage openapi-diff OLD.json NEW.json [--severity breaking]`

resources/openapi/openapi.json is generated from the Malli endpoint schemas, so
the spec at any git ref is what the API was at that ref. The task parses two
specs into {METHOD /path -> shape} maps, inlines $refs (component schemas change
too), and compares params, request body, and response schemas.

Findings are classified BREAKING / ADDITIVE / DOC_ONLY and sorted breaking-first.
A change is breaking when it requires MORE from the caller or provides LESS to
the caller; anything else is just a change. That rule is directional and it
inverts between request and response, so severity is decided from both schemas
rather than from how a change happens to render:

  request   breaking: newly required, type/enum narrowed, schema closed, field removed
            additive: new OPTIONAL field or param, type/enum widened, made nullable
  response  breaking: field removed, field may now be null
            additive: new field returned

Adding an optional parameter is not breaking; returning extra data is not
breaking. A public diff of two Stripe specs found 663 of 679 changes additive,
which is why filtering matters more than summarizing.

`./bin/mage openapi-staleness`

The self-healing CI job runs only on PRs labelled `openapi-self-healing`, so the
committed spec drifts; it is currently ~38 API commits behind. A stale spec makes
the diff under-report, so this reports drift and names the commits rather than
letting a clean diff read as "no changes". The label gate is deliberate (#67854
introduced it to stage the rollout after #67294 removed the spec entirely over
merge conflicts), so it is left alone here.

Response coverage is partial: ~199 response entries declare a schema and can be
compared, ~1,700 are description-only 2XX/4XX/5XX stubs and cannot be.

Tests cover both directions of every request and response rule, $ref resolution
including the SCIM schemas whose property is literally named "$ref", a guard
against the failure mode in mozilla-ai/otari#1315 (breaking markers dropped at
render time), and a signal-to-noise case asserting additive bulk cannot drown
the breaking few.

* Generate specs from git refs, so the diff is not bound to the committed blob

`./bin/mage openapi-diff --refs <old-ref> <new-ref>` checks each ref out into a
throwaway worktree, runs that ref's own `generate-openapi-spec`, and diffs the
two results. Nothing is committed and no checkout is touched.

This removes the staleness problem rather than reporting it. The committed
`openapi.json` only updates on PRs labelled `openapi-self-healing`, so it lags
source badly: `--committed origin/release-x.63.x origin/master` reports 0
findings, because both refs carry the same stale blob. Generating from source
reports 670 -> 714 operations and 10 removed endpoints for the same pair.

It needs no support from the ref being diffed beyond `generate-openapi-spec`
already existing, so it works on historical refs. `--committed` keeps the fast
path and now prints a warning that its result under-reports, since an empty diff
from a stale spec is indistinguishable from an API that did not change.

Validated against the real v63 -> master delta. The top removals are the three
`GET .../execute` endpoints that the hand-written 0.64 changelog documents as
replaced by `POST .../execute/values`, and it independently surfaces a query
param rename (`pinned_state` -> `pinned-state`) that has no entry yet.

That run also shows a precision limit worth stating: it reports 239 breaking
findings, but 217 are the identical `additionalProperties: false` line from one
PR (#82447, closing mu/defn argument schemas). The classification is right --
closing a schema does require more of the caller -- but as changelog input that
is one entry, not 217. The skill now tells the drafter to collapse findings by
text and treat a repeated finding as a single systematic change.

* Group identical findings so a systematic change reads as one entry

`--grouped` collapses findings with identical text across endpoints and sorts by
blast radius, because the per-endpoint view is the wrong shape for drafting a
changelog.

On the real v63 -> master delta it turns 239 breaking findings into 113 distinct
changes. The largest spans 217 endpoints and is a single upstream PR (#82447,
closing mu/defn argument schemas): one changelog entry, reported once with its
endpoints, instead of 217 identical lines to scroll past.

Grouping is a separate render mode rather than a transformation of the default.
The per-endpoint view answers "what changed on this route?", the grouped view
answers "what distinct changes happened?", and collapsing the former into the
latter would lose the ability to check a specific endpoint.

The long tail is where the individually-interesting entries live: the same run
surfaces a `pinned_state` -> `pinned-state` query param rename that no changelog
entry covers yet.

* Fix review findings: two defects let breaking changes pass silently

Eric's review found 13 issues. The two that mattered both caused false
negatives - the tool reporting nothing where a caller would break.

The depth guard counted nesting levels, not `$ref` hops. `seen` already breaks
cycles, so the counter was doing a different job than its name claimed: past 12
levels both specs collapsed to the literal "<deep>" and compared equal, hiding
every change in 52 of 637 operations. It now advances only on a `$ref` hop, and
52 falls to 17.

`response-lines` compared only top-level properties and delegated anything deeper
to `(widening? new old)`. Swapping those arguments inverts value-set semantics -
which is why the nullable case read correctly - but not object-property
semantics, so a removed nested response field read as additive. It now recurses
the way `body-lines` does.

Chasing the second one surfaced a third of the same shape: `widening?` never
compared array `items`, so a narrowing inside an array was invisible. Found by a
test written for the SCIM `$ref`-named-property case.

Also fixed:

- `committed-spec!` built a shell command by interpolation, so a ref containing
  `$`, a quote, or a space produced a wrong file. Values now ride as positional
  arguments and the shell never parses them.
- The `--grouped` header counted severity-filtered-out changes, inflating the
  count above the body printed beneath it.
- The removed and added endpoint lists were severity-gated, so `--severity
  breaking` hid the added half of an endpoint move - at exactly the severity the
  skill tells a drafter to start with. Both lists now print at every severity.
- The staleness check watched 66 of the 158 files that contain `defendpoint`,
  missing `src/metabase/*/api/*.clj` and all of `enterprise/backend/src`, so it
  could report `OK: spec is current` with dozens of newer endpoint files on disk.
  It also compared `%cI` strings across a history carrying both `+08:00` and `Z`,
  and listed commits from a narrower pathspec than the date came from.
- `DOC_ONLY` was unreachable: a description-only change produced no findings and
  was dropped. Descriptions are what the skill reads to spot changes it cannot
  explain, so a reworded docstring now surfaces as doc-only.
- A misspelled `--severity` fell through to nil and silently printed everything.
  It now exits. `--committed` without `--refs` was accepted and ignored; it now
  exits too.
- Each `--refs` run left two multi-megabyte specs in /tmp.
- `brief` truncated its non-map branch without the "..." its map branch adds.
- The first commit message claimed a test covered the SCIM `$ref`-named property.
  No such test existed; one exists now, along with tests for the depth guard, the
  nested response removal, and the doc-only path.

One correction to that commit message stands: `babashka.json` would have avoided
the `:discouraged-namespace` ratchet entirely, since `mage/doctor.clj` already
uses it unsuppressed. Keeping cheshire for consistency with the three other mage
namespaces that use it; the budget moves 68 -> 69 against current master.

The `de-slop` skill the review could not find does exist, at
~/.claude/skills/de-slop - it is a personal skill, not in the repo.

* Fix second review: four more directions where a finding was inverted or absent

All nine findings addressed. The reviewer supplied a failing test for each of the
first five; all five reproduced on the previous head, and all now pass.

Three of the four major findings were the same mistake in different places: a
schema shape that the output path handed to `widening?` with swapped arguments,
which inverts value-set semantics but not object-property semantics.

- A response array of objects went to the leaf branch, so a removed element field
  read as additive and an added one read as breaking. 24 of the 199 comparable
  response schemas in the committed spec are top-level arrays. Arrays now recurse
  through `items` in the output direction.
- `schema-props` unwraps a nullable `oneOf`, so a nullable OBJECT reached the
  object branch, where only keys and requiredness were compared and the type
  change was invisible. The committed spec has 726 nullable object properties.
  Both `body-lines` and `response-lines` now compare type sets there, with the
  severity test pointing the correct way for each direction.
- `response-lines` discarded the required sets, so a response field the API may
  now omit produced nothing - the same loss of guarantee as one that may now be
  null, which was already breaking. 174 of 637 operations declare `required` in a
  response schema.

The fourth major finding was a shortcut in `widening?`: any empty old schema was
treated as "no constraint", so a field going from Malli `:any` to a concrete type
read as additive. `operations` stored an absent body as `{}`, which made "no body"
and "any value" indistinguishable; an absent body is now nil and `{}` falls
through to the type and enum checks. After ref resolution the committed spec has
1,182 property schemas that are `{}`.

The depth limit is gone rather than re-tuned. `seen` already terminates every
path against a finite component set, so the limit only ever replaced deep nodes
with the same literal on both sides - which compares equal and hides the change.
The MBQL clause schemas form chains long enough to hit it: 17 operations carried
`<deep>`, and now none do while 18 terminate on genuine cycles via `<recursive>`.
A self-diff of the committed spec costs 2.9s instead of 1.4s and still reports 0
findings.

Also fixed:

- `format-epoch` shelled out to `date -r`, which only BSD and macOS read as epoch
  seconds; on GNU coreutils it means "the mtime of this file" and printed nothing,
  silently, because the call was `:quiet?`. Now `java.time`.
- `openapi-staleness` claimed the spec was "current" from endpoint files alone.
  661 of 763 component schemas live outside `.api` namespaces, so a shared-schema
  change moves the generated spec invisibly. The OK message now says what it
  actually checked and points at `--refs`.
- A `--refs` run that could neither generate nor read the second ref leaked the
  first spec: materialization happened in the `let` bindings, outside the `try`
  whose `finally` removes the directory.
- The skill told an agent that two identical committed specs prove the API did not
  change, and that the commit touching `openapi.json` is the PR that changed the
  API. Both are wrong for the same reason - the committed spec only moves on
  `openapi-self-healing` PRs. It now says to re-run without `--committed` before
  reporting no changes, and to search endpoint source for attribution.

* Drop the ratchet bump: babashka.json instead of cheshire, and fix stale docs

The `:discouraged-namespace` increase was avoidable and the rationale I gave for
it was backwards. `babashka.json` is not in the discouraged list, and 6 mage
namespaces already use it unsuppressed against 5 on cheshire - so the
"consistency" argument pointed the other way. Swapping to it removes both the
68 -> 69 bump and the `:clj-kondo/ignore`, and this PR no longer touches
.clj-kondo/ratchets.edn at all. `read-str` keywordizes by default, so the two
spec reads pass `{:key-fn identity}`; this namespace is string-key throughout.
Verified unchanged behavior: self-diff still 0 findings, the real 0.64 change
still 1 breaking.

Three docstring counts were wrong. The `api-source-pathspecs` docstring claimed
238 `/api/ee/` paths (actual 106 - it looks like 106 paths plus 132 operations,
double-counted), 66 files matching `src/metabase/**/api.clj` (actual 65), and 158
files containing `defendpoint` (actual 160). The argument the docstring makes is
unaffected - the narrow pathspec still misses most endpoint files - but the
numbers are now the measured ones.

Also removed documentation that described code this branch already deleted:

- A comment in `resolve-refs` said "Only a $ref hop advances depth" while the
  docstring 17 lines above correctly says there is no depth limit. There is no
  depth counter left; the comment described the previous commit's version.
- Two test comments and a test name referenced a `<deep>` placeholder guard that
  no longer exists, and cited a "52 of 637" measurement of the mechanism that was
  removed. The tests themselves are valid - they assert deep and long-chain
  changes are detected - so only the prose changed.
- SKILL.md quoted "`--committed` reports 0 findings" for v63 vs master as a fixed
  figure. That was true when written and is not now: the blobs have since
  diverged and it reports 1. The underlying point stands, so it is stated
  qualitatively rather than as a number that drifts.
- SKILL.md said `--refs` "works on any ref". The code only attempts that and has
  a documented fallback for refs that will not build with the current toolchain;
  the text now says so.

* Fix third review: object unions and nullable arrays hid breaking changes

- A union of two or more object variants was compared by its first
  variant only, so removing or editing any other variant produced no
  finding. Such unions now compare whole: widening only when every old
  variant survives unchanged.
- Nullable arrays (`oneOf [array, null]`) skipped the array branch, so a
  change to their items was ranked additive. Both array branches now
  unwrap the null first.
- The render test never rendered. It now asserts breaking operations
  print under the breaking headings of both renderers, and a new test
  covers the --severity filter.

* Rank changes to unmodeled schema keywords breaking, and compare bounds, allOf, and tuples

The old widening check returned "safe" for any keyword it did not
recognize, so each review round found another way for a breaking change
to read as additive: allOf, minimum/minLength, map-of value schemas,
prefixItems, nullable enums, closed nullable objects.

`compatible?` replaces it. It takes a direction (:request or :response),
compares unions variant by variant, compares bounds, pattern, and format,
and compares allOf and prefixItems by position. A change to any keyword
it does not model ranks breaking. A false breaking finding costs one
look; a missed one ships undocumented.

Documentation keys (description, title, examples, default) are stripped
before comparison, so a reworded field description is doc-only instead
of an additive schema change.

Also:
- When two summaries are identical, the finding names where the schemas
  differ (e.g. `items.enum: +"measure"`), so distinct changes no longer
  merge in the grouped view.
- Generation deletes the checked-out spec before running, so a generator
  that logs and swallows an error no longer passes the committed spec off
  as fresh. A timeout falls back to the committed spec instead of
  throwing.
- An empty or non-spec input file exits 1 instead of reporting 0 BREAKING.
- openapi-staleness lists commits after the spec commit, not including it.

* Close the gaps an independent check found in the compatible? rewrite

- Only a nullable union unwraps to its object. `string | object` and
  similar unions are compared whole, so a change to the non-object
  variant is no longer hidden.
- Keywords beside an object's properties (allOf, a map-of
  additionalProperties, minProperties) are compared.
- If the field walk finds nothing but the stripped schemas differ, the
  whole schema is ranked instead of falling through to doc-only.
- Inclusive and exclusive numeric bounds compare as one bound.
- A response allOf may gain members (narrowing output); a request allOf
  may lose them.
- An enum or const without a type widening to its type is additive.
- requestBody.required and request content types are compared, and a
  response that declares a schema for the first time is reported as
  additive.
- A missing input file says so.

* Match operations across path-variable renames; count only visible severities in the grouped header

- Operations and path params are keyed by position, not variable name.
  Renaming `{id}` to `{card-id}` leaves the URL a caller sends unchanged,
  so it is now a doc-only "path variable renamed" finding rather than a
  removed endpoint plus an added one. Routes that would merge under
  position-keying (`/api/database/{id}/schemas` and
  `/api/database/{virtual-db}/schemas`) keep their names. Reports still
  print the real spec paths.
- At --severity breaking, the grouped header no longer counts the
  new-endpoint list, which is additive. The list still prints.
- The skill's attribution section no longer opens with the
  openapi.json git log that it then says names the wrong author.

* Rank a changed or dropped request default as breaking

`default` was stripped with the documentation keys, so a default that
flipped (say a query param from false to true) ranked doc-only and
--severity breaking hid it. The schema still validates the same
requests, but a caller who omits the field now gets different
behavior.

Request defaults are now compared: changed or dropped is breaking,
added where there was none is additive. Response defaults are ignored.

* State one definition of breaking that covers every rule

A change is breaking when an existing caller, sending exactly what it
sent before, can now fail or get a different result. The old wording
(requires more / provides less) did not cover changed defaults, and it
presented two conservative rules - removed request fields and changes
to unmodeled keywords - as if they followed from it. Those are now
stated as what the tool does when it cannot tell.

* Skill: point to docs-write, and attribute with all six API source pathspecs

de-slop is a personal skill the repo does not ship; docs-write carries
the Metabase style guide. The attribution git log used three of the six
pathspecs in api-source-pathspecs and missed enterprise api/ dirs and
routes.clj files (35 tracked files, e.g. sso/api/saml.clj).
2026-10-05 21:33:14 +00:00
Andrei Simionescu
515ab1961f Explain BYOK provider failures in Metabot instead of a generic error (#83430)
* Explain BYOK provider failures in Metabot instead of a generic error

* Don't report every provider 403 as a rejected key
2026-10-05 22:23:26 +01:00
Chris Vandoodewaard
935be20efc Consolidate navigation e2e tests (#83541)
* Move navbar e2e spec into onboarding/navbar

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

* Remove navbar e2e tests covered by other e2e tests

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

* Make navbar logo and breadcrumb e2e checks wait for navigation

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

* Check the landing page navbar e2e test lands on a real question

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

* Merge navbar e2e tests that share setup

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

* Preserve navbar coverage and consolidate homepage tests

* Move navbar close checks into the new menu e2e test

The new menu test already runs the same New > Question and New > SQL query
flow and waits for each editor to load. Check the navbar there and remove
the duplicate navbar test.

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

* Merge normal user navbar e2e tests into one test

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:57:32 -04:00
Chris Vandoodewaard
68a21a43e6 Consolidate detail view e2e tests (#83560)
* Merge detail view 404 tests into the tests with the same setup

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

* Fold detail view email link check into the table test

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

* Check that the table detail view shows its loading state

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

* Make table detail loading assertion deterministic

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:56:54 -04:00
Denis Berezin
61cc136bcd UXW-5172 Fix flaky unit test (#83589) 2026-10-06 00:34:47 +05:00
Nemanja Glumac
dc16c79846 Consolidate per type dashboard filter e2e specs (#83581)
* Remove unused SQL dashboard filter fixtures

The specs that imported these fixtures no longer exist.

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

* Make boolean dashboard filter checks able to fail

The field filter card returns one row for both True and False, so the
widget steps only proved the count changed. Assert which boolean values
the card shows after each step.

The parameter-source click behavior read a dashboard filter that was
mapped to the clicked card, so the parameter always matched the clicked
cell. Give the source dashboard an unmapped filter defaulting to False
and expect False on the target.

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

* Cover both dashboard click behavior sources in one boolean test

The column and parameter source tests built the same two dashboards.
Configure both sources on separate columns of one card and click each,
and check that the column source also filters the target card.

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

* Restore the writable database only for boolean field filter tests

Only the field filter tests query many_data_types; the MBQL and variable
tests run on the sample database. Also drop dashboard filters that the
click behavior tests created but never used.

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

* Title the boolean dashboard filter spec as boolean

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

* Run both required SQL filter flows on one dashboard

The text variable and field filter specs repeated the same default
precedence flow on separate dashboards. Map both to one dashboard and
clear, reload and revisit once. Card checks now precede URL checks after
the reload, and the field filter checks also assert which categories
are excluded, so a dropped filter fails.

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

* Wait for the native card mapper before checking its reset mapping

In the Between state the card shows help text without the variable name,
so the check passed before switching back to Equal to.

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

* Make text dashboard filter checks able to fail

A misspelled key left seven filter widget cases without a negative check,
and two cases plus the required test expected a value that the unfiltered
card already shows. Restore the negative checks with values outside each
result, filter on values that change the first rows, and assert the
widget state before the 25533 and 49323 checks run.

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

* Fold text dashboard filter tests that share their setup

Drill with the multi-value Contains filter (metabase#42999) at the end
of the widget test, which already maps that filter, and check the single
and multi-value list pickers (metabase#49323) on one dashboard.

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

* Drop dashboard filter intercepts that are never awaited

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

* Remove the Between case from the number filter widget loop

The metabase#54364 test in the same spec creates a Between filter on the
orders dashboard through the UI and enters min, max and both bounds,
checking exact row counts.

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

* Make number dashboard filter checks able to fail

Every widget case and the required test's default checks expected values
that the unfiltered orders card already shows. Add a value each result
must exclude, and change the value before setting it back to the default
so that step has something to reset.

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

* Make number source dashboard filter checks able to fail

The single-value list renders options without labels, so the label
lookup after searching never matched. The search-box test never checked
the card result.

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

* Share dashboards between number source dashboard filter tests

The static list tests built the same count dashboard, and the card
source tests both edited the orders dashboard. Add both filters to one
dashboard per pair and use each in turn.

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

* Make date dashboard filter checks able to fail

The widget loop waited for each clear but never for the filter it had
just applied, so every wait matched an earlier request. Wait after
applying, check that order 1 drops out of each result, and pick the
All Options case as a fixed date range: its relative range moved with
the current year and would stop matching in 2028. The required test now
checks the card after saving with its default.

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

* Visit the orders dashboard once per date dashboard filter test

The 6660 test visited it again after the shared setup, and the 29122 test
visits it only after changing the locale, so the shared visit was wasted.
Also drop a filter type branch no case uses.

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

* Make location dashboard filter checks able to fail

The widget loop never waited for the filter it had just applied, and the
two exclusion cases expected order 1, which their filters kept. Wait
after applying, exclude order 1's city instead, and check that order 1
drops out of each result.

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

* Check that order 1 drops out of ID dashboard filter results

The expected orders sit just past the rows the unfiltered card renders,
so the checks only held by a narrow margin.

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

* Check the three ID dashboard filter widgets on one dashboard

The primary key, foreign key and implicit join widget tests each built
and saved the same dashboard. Add all three filters and apply each in
turn, clearing it before the next since they would otherwise combine.

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

* Cover both native custom destination click behaviors in one boolean test

The saved question and URL tests each reset and resynced the writable
database to build the same dashboard. Configure the saved question on
the boolean column and the URL on the id column, and click each.

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

* Assert the text/category popover closes without the visibility-asserting helper

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

* Find the remapped ID filter value by widget name

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

* Restore the card query intercept the visualizer helpers wait on

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

* Expect required SQL dashboard filter params in slug order

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

* Pass a runtime boolean filter value through a dashboard click behavior

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

* Drop default-value tests covered by the required-filter tests

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

* Map the boolean click behavior source parameter so its widget renders

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

* Merge boolean click behavior tests into the mapping tests that share their setup

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

* Merge location default and number between tests into the widget tests

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

* Check the three ID default filters on one dashboard

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

* Merge text filter reset check into the list picker test and make set-to-default able to fail

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

* Fold the relative date resolution check into the required date test and assert prior values are gone

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

* Give the number-source search and SQL operator-change checks a state they can fail against

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

* Fix CI failures in the location, boolean and SQL management dashboard filter specs

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:27:40 +02:00
Nemanja Glumac
0e58a35cea Consolidate table editing e2e tests (#83649)
* Move table editing e2e spec into actions folder

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

* Merge table editing e2e tests that share setup

Entry-point checks, edit-grid interactions, and create/delete each run as
one test, so the table setup and page load happen once per flow. The
create/delete group no longer restores the snapshot a second time, and
each test only seeds the tables it reads.

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

* Assert table editing results in the grid instead of the API response

Edits, creates, and deletes are checked by the values and rows the grid
renders after the save, and bulk delete targets rows by their content
rather than by position.

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

* Title the table editing e2e suite after its actions folder

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

* Describe what proves an inline table edit saved

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:52:22 +02:00
Nemanja Glumac
baaf3ef3f8 Consolidate documents e2e tests (#83532)
* Remove duplicate and dead documents e2e tests

Drop the heading anchor-hover test, whose steps and assertion are
repeated by the anchor menu test in the same describe, and a skipped
comment blockquote shortcut test that never runs.

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

* Merge documents e2e tests that share setup

Run tests that start from the same fixture, user and page as one test
each, keeping every step and assertion in its original starting state:
duplicate with cancel and save, deleted card and missing document,
print with undo/redo, card add/rename/replace/resize, notebook question
with time series, no-results footer with Enter, anchor links, comment
editor formatting, resolve flows, comment links, all-comments sidebar,
reactions, top level blocks, email notifications, flexContainer drops
and resizes, ctrl/meta-click navigation, supporting text add/remove/
edit/resize, public document view, smart links, and card menu access
per role. Unwrap the floating-menu formatting test, which was nested
inside another test and never ran.

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

* Repair documents e2e assertions that cannot fail

Make the card resize drag send page coordinates so the height actually
changes, and expect it to grow by the drag distance. Give negatives
something to catch: seed a comment before checking public views hide
it, look up command menu options by their real role, check the real
"Show all comments" link name after the comments request, drop a
standalone card onto a full flexContainer, and assert the sidebar no
longer shows a resolved comment instead of a missing test id. Assert
that ctrl/meta clicks reach the anchor, that undos restore the previous
layout, that the second paragraph gets its own comment, that a flex
resize changes the card width, that strike renders an <s>, that saving
a copied card navigates to a new question, and that read-only card
menus disable each edit item by name.

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

* Strengthen weak documents e2e assertions

Anchor negatives on mounted UI: wait for the heading or paragraph
before checking Save is hidden, check Copy link, Add reaction and Edit
Query before asserting their siblings are absent, look for the
collection picker's Collections before asserting Our analytics is
missing, and scope flexContainer lookups to the document.

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

* Drop unused setup from documents e2e specs

Remove a snowplow reset nothing asserts on, document aliases nothing
reads, an admin pre-visit and a redundant sign-in/sign-out pair, create
the QA Postgres database and smart-link document only in the tests that
use them, and run the flex chart width test outside the card embed
setup it never used.

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

* Align documents e2e titles, logs and comments with what tests check

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

* Restore documents e2e checks lost in consolidation

Reload the copied comment link so it opens cold, as the standalone
deep-link test did, build the 3-card flexContainer from a question card
and reject a model as the fourth, keep the public view's Sign in check,
click the trailing editor paragraph rather than any <p> before adding a
second question, and wait for Add supporting text to be enabled before
clicking it.

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

* Restore documents e2e coverage narrowed by consolidation

Edit the query of an unnamed card before renaming it, edit the document
with comments open before posting a comment so the empty composer's
autofocus is still exercised, check the disabled blockquote shortcut
leaves text as a paragraph, count every read-only card menu item, and
wait for Add supporting text to be enabled before clicking it.

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

* Merge more documents e2e tests that share setup

Fold undo-on-save into the print and undo/redo test, the empty-search
footer into keyboard navigation, comment shortcut blocks into the
markdown blocks test before its reload, comment mentions into the
comment editor test, deleting cards from a flexContainer into the
fourth-card test, and plain links into the smart links test.

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

* Drop more unused setup from documents e2e specs

Stop creating card embed fixtures no search in the block matches, and
alias the public document uuid once in a helper instead of re-inlining
the link chain in each test.

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

* Fix documents e2e failures introduced by consolidation

- Match card menu items by regex: icon aria-labels are part of their names
- Assert closed popovers on the raw selector; H.popover() requires visibility
- Trigger mouseup in documentDoDrag without force so resized views settle
- Scope Comments.closeSidebar to the sidebar; toasts also render a close icon
- Scroll header controls into view after the document scrolls down
- Wait for the final "pro" search results before keyboard navigation
- Resize the card right after inserting it and retry the height check
- Split mentions into its own test; the long thread pushed the dialog offscreen
- Send Mod-Shift-B as a real keyboard does and assert no bold

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

* Fix remaining documents e2e failures from consolidation

- Scroll reloaded comment blocks into view; the thread outgrows the sidebar
- Move the cursor to the document's trailing empty paragraph via the
  document-end shortcut before adding a standalone card, since .is-empty
  only marks the paragraph holding the cursor
- Check the disabled card menu after the fourth-card rejection, matching
  the drop-then-add order the rejection relies on

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

* Address documents e2e review feedback

- Open comments for the first time on a dirty document after a plain
  visit, with the comment already on the server at page load
- Type the comment markdown triggers with real keystrokes
- Check the copied anchor link drops the hash already in the URL
- Move the cursor to the document end before adding links, since the
  shared document now has content under the editor's centre
- Drop the public sidebar check, which the public route can never open
- Drop an inaccurate comment on documentDoDrag's mouseup

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

* Merge the remaining documents e2e tests that share setup

- documents: /new to /new, focus on Enter and the create error toast;
  native SQL into the notebook questions test; the update error toast
  into the formatting test; deleted card and missing document into the
  print and undo test
- comments: resolved thread contents, another user resolving, the
  resolved-comment link, and reactions on resolved and deleted comments
- card-embed-node: Backspace delete into navigation; removing the first
  card into text wrapping
- links, public, downloads: one test each

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:48:08 +02:00
dpsutton
07534d6a33 Fix stale references in backend expert agents and module-conventions skill (#83658)
- backend-module-conventions: the retire-a-changeset precedent cited a
  nonexistent id (v59.2026-07-10T22:29:18). Point at the two real
  precedents and describe both placements they show (same file, same
  release vs. a later release retiring an older one). Drop the last
  em-dash.
- modules-backend-expert: description path is
  .clj-kondo/config/modules/config.edn.
- platform-backend-expert: run kondo hook tests through bin/test-agent
  (they are on the dev classpath) instead of raw clj -X:dev:test, which
  CLAUDE.md says not to use.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:44:18 -05:00
Andrei Chernykh
e4055bbfe4 Redesigned the SAML and OIDC settings pages (#82449)
* Redesign the SAML settings page and its group mapping card

* Apply the LDAP review feedback to the SAML page

* Move the SAML provisioning card below Sign SSO requests

* Address the review and guard the group mapping switches

* Address the review and convert the SSO specs to TS

* Redesign the OIDC settings page and its group mapping card (#82622)

* Sync only mapped groups on OIDC login

* Redesign the OIDC settings page and its group mapping card

* Add the leave guard and an e2e spec to the OIDC page

* Share the SSO group mapping card and drop the legacy widget (#82720)

* Share the SSO group mapping card and drop the legacy widget

* Address the review on the OIDC page and the SSO e2e helpers

* Address the QA findings and the second review on the SSO cards

* Share one group mapping card for LDAP and SAML and trim the specs

* Address the review on the shared group mapping card

* Replace the switch hold hook with optimistic cache patches

* Fix the SSO e2e specs and reset the page forms after a save

* Assert the saved state without racing the OIDC save button label

* Share one GroupMappings type and drop the dead OIDC key

* Share the switch-off field reset across the SSO forms

* Give aria-disabled switches the disabled look in the theme

* Share the self-saving switch behind one settings hook

* Put User provisioning first and unlocked on every SSO page

* Stop ignoring the OIDC attribute map at login

* Fix the SSO page review findings and simplify group mapping

* Treat blank OIDC attribute claims as unset

* Fix the JWT, OIDC and mapping editor review findings

* Keep the LDAP and SAML forms on their saved values

* Fix the remaining SSO review findings in this PR

* Keep OIDC login on the standard email claim

* Type oidc-providers as an admin-list-only setting

* Describe JWT Automatic mapping and require the signing key

* Tighten the SSO specs and stop racing delays

* Restore the blank line in mage util

* Give the SAML identity provider URL field its own description

* Keep JWT mapping drafts and block Enter on the mode control

* Hide JWT New mapping while a draft is open

* Show the env notice on locked LDAP security radios

* Promise account reactivation only on SAML and JWT pages

* Show JWT attribute defaults and env values in the form

* Name the mapping in its row buttons and delete dialog

* Make the JWT e2e spec wait for the mode control

* Clear the tenant attribute keys on SAML and JWT Deactivate

* Trim the SSO specs, comments and a few helpers

* Split the JWT e2e read-only wait from the click

* Trim redundant comments across the SSO pages

* Move each settings card component into its own file
2026-10-05 09:43:48 -07:00
Alexander Polyankin
6c54ed5234 Data apps: actions without a model (#83607) 2026-10-05 16:11:30 +00:00
bryan
6aa6b1560f Revamp backend expert subagents; add shared backend-module-conventions skill (#83426)
* Revamp backend expert subagents; add shared backend-module-conventions skill

Claude routes on each agent's frontmatter description, and every session loads all of them.
The eleven descriptions were ~29KB of <example> blocks; they are now ~440 chars each (~4.9KB
total), shaped as "expert for X. Use when Y. Not for Z (use other-agent)" so neighbouring
scopes stop overlapping.

Shared rules move out of the agent bodies into one skill that every expert preloads via
`skills:`: module layout (.core/.db/.models/.settings/.init/.rest), the `<module>.db` rule
and `[:auto/param]` value binding, v60+ migration layout/IDs/backports, module-boundary
checks, and a before-handing-back checklist.

Each body was rewritten against the current tree: every namespace and path re-verified,
stale facts removed (metabot now OSS, workspaces gone, search engine defaults, entity_id
semantics, permission coalescing, model persistence swap), and a Return contract added.

* De-slop backend expert agents and backend-module-conventions skill

Split sentences over 25 words, make passive voice active, state the present instead of
narrating history, and use one name per thing. Skill headers are now action titles.
Every backticked namespace, path, and setting is unchanged.

* Spell out the retire-via-DELETE pattern for shipped changesets

Learned the hard way via #83425 / #83549: the "never delete a changeset"
rule is right but didn't explain the recovery path, so the first
instinct is to just remove the yaml entry. Add the one-sentence recipe:
append a same-version-id DELETE-from-DATABASECHANGELOG changeset to the
same file.

* Clarify what's actually frozen on a shipped changeset

Dan pushed back on "immutable": checksums auto-recompute, so cosmetic
edits (comments, SQL whitespace, author, rollback block, preconditions)
are fine. What's actually locked is the id and the semantic effect —
change what the changeset DOES and ran instances diverge from fresh
installs.

* Prefer the simple rule over the precise one for shipped changesets

Dan's correction was accurate but "cosmetic edits are fine" invites
rationalization. "Don't edit; add a new one" is 99% correct, needs no
mid-crisis branching, and the 1% waste (a true typo fix) costs less
than one bad call where someone decides their SQL rewrite is cosmetic.
2026-10-05 10:02:31 -06:00
Chris Truter
23c7a5d8f6 Move tsplude to Graphy and appleby to UX West (#83624)
Move tsplude to Graphy and appleby to UX West in team.json

Neither has left the company: appleby moved to UX West and tsplude is moving to Graphy, so the
roster should follow them rather than drop them.

#82809 removed appleby from Metabot without adding them anywhere, leaving them in no team while
GitHub had them in ux-west and ux-west-backend. This closes that gap.

The GitHub org teams still need the same move for tsplude; only the checked-in file changes here.
2026-10-05 08:26:53 -07:00
Nathan Voxland
175bbbb591 Fix handling of query-plan transcripts in explorations (#83418) 2026-10-05 15:08:48 +00:00
Kevin Kelleher
c9246e3a02 Show running subexplorations in the sidebar, change the icon (#82517)
* subexploration icon, loading state

* fix tests after merge
2026-10-05 14:14:17 +00:00
Andrei Simionescu
813771b483 Don't run Metabot tool calls from a model response that failed (#83259) 2026-10-05 14:36:29 +01:00
Chris Vandoodewaard
f1a4c10617 filter orders to speed up nested lookup (#83436) 2026-10-05 12:42:15 +00:00
Paolo Rascunà
457b3c3fa5 Offer DeepSeek Flash under its documented deepseek-flash id (#83397)
Offer DeepSeek Flash under its documented deepseek-flash id

DeepSeek now documents Flash as deepseek-flash, and its /models catalog no
longer lists deepseek-v4-flash. Flash had disappeared from the model picker.

- Offer deepseek-flash ("DeepSeek Flash") in the DeepSeek whitelist and make
  it the DeepSeek mini model.
- Declare deepseek-v4-flash as a retired model of the DeepSeek provider type,
  with the :retired-models mechanism from #83522. A saved
  deepseek/deepseek-v4-flash shows as DeepSeek Flash, and requests use
  deepseek-flash.
- Add DeepSeek to the self-test whitelist check, update the DeepSeek tests,
  and regenerate docs/ai/providers.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:33:07 +02:00
Fraser
48b9e66904 Use oxlint (#82015) 2026-10-05 22:41:31 +13:00
Romeo Van Snick
ec5eac8463 Fail SDK unit tests on React act warnings (#83198) 2026-10-05 19:13:53 +11:00
YB
f57daa8097 Exclude core-provided Jackson and pin reactor-core in the SQL Server driver (#83629)
exclude core-provided jackson and pin reactor-core 3.8.7 in sqlserver driver
2026-10-05 15:40:36 +08:00
Romeo Van Snick
96824f4292 Give the side-effect scan hook a timeout sized for CI (#83627) 2026-10-05 11:25:15 +11:00
github-automation-metabase
cda908f994 [chore] Update E2E test timings with recent data (#83628)
update e2e timings 20261004-080830

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-04 08:42:51 +00:00
Oleksandr Yakushev
db6e70c620 [test] Optimize offset->line in kondo-ratchet test (#79931) 2026-10-03 20:26:47 +03:00
Romeo Van Snick
073e520171 Stop gating the homepage on the settings request (#82629)
Claude-Session: https://claude.ai/code/session_017G1dfFTeS2dqcoPMcVqafU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:27:29 +10:00
Romeo Van Snick
a4c8cbdf02 Scan for import-time side effects in parallel (#83493) 2026-10-03 15:22:02 +10:00
Romeo Van Snick
f0cb981e20 Skip the search debounce in the table picker and search results specs (#83503)
* Skip the search debounce in the table picker and search results specs

* Skip the search debounce in the search results dropdown spec
2026-10-03 15:21:48 +10:00
Chris Truter
0f61d2ccad Note why the kondo config files have no code owner (#83391)
* Note why the kondo config files have no code owner

ratchets.edn and config.edn change in most backend PRs, so a single
owner would add review friction without much benefit, and the ratchet
budgets may split per module. Keep the entries commented out with that
reasoning, so the gap reads as a decision.

* Cover the test ratchet file in the deferred kondo code-owner note

* Tighten the unowned kondo files comment

* Say the per-module ratchet split is a future possibility
2026-10-03 00:00:07 +00:00
Andrei Simionescu
d7c6ddb54b Support Bedrock inference profiles and unlisted Claude models (#83433) 2026-10-02 23:45:41 +00:00
Maz Ameli
3752472737 Fix doubled border under dashboard tabs (#83295)
On dashboards with tabs, the header container's bottom border and the tab
row's own underline stacked into a doubled line. The tab row's underline now
serves as the only divider: the header drops its border whenever the tab row
is shown, and the underline extends to the header's edges and sits fully
inside the header so the sticky filter bar can't paint over it.

Also fix the sticky filter bar showing its "stuck" border while not stuck,
e.g. after resizing the window. useIsParameterPanelSticky treated a sentinel
below the fold as stuck, and read the oldest of several queued
IntersectionObserver entries instead of the latest.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:54:21 -07:00
Chris Truter
ade2ab95fa Give :discouraged-var and :discouraged-namespace their own per-symbol ratchet budgets (#83352)
* Remove stale :discouraged-namespace ignores

These six ignores suppressed a namespace-level policy that no longer
exists in config.edn -- clojure.pprint, clojure.java.io, clj-yaml.core,
metabase.util.jvm, and metabase.config.core aren't configured as
discouraged namespaces anymore (clojure.pprint is only discouraged at
the var level now). Verified clean with a scoped kondo run requesting
:discouraged-namespace at :warning over just these files: zero findings.

* Give :discouraged-var and :discouraged-namespace their own per-symbol ratchet budgets

One shared budget per linter let a new ignore on a previously-clean var
land silently as long as the linter's aggregate count had room, and made
"introduce a policy for just this one var" impossible. Each configured
symbol now gets its own field: :discouraged-var-counts and
:discouraged-namespace-counts, siblings of :ignore-counts, keyed to
mirror the symbol's own namespace and name rather than the linter
that discourages it (metabase.entity-retrieval.core/search-unfiltered
-> :entity-retrieval.core/search-unfiltered; an enterprise namespace of
the same shape gets an `ee.` prefix so it can't collide with its OSS
counterpart).

Attribution resolves which symbol an inline ignore covers by running a
scoped `clojure -M:kondo` analysis pass over the files that carry one of
these two ignores (JSON output, since a generated var can have a name
that isn't valid EDN) and matching each ignore to the nearest covered
usage of a known symbol -- this is the one thing in the ratchet tooling
that needs a live kondo run, so `./bin/mage kondo-ratchets` is no longer
pure Babashka. `:comment-exempt` is untouched: occurrences still carry
the bare linter name in :linters, only the two new count fields are
keyed per symbol.

`--seed :discouraged-var`/`:discouraged-namespace` (no specific symbol)
seeds every symbol currently configured under it in one shot, unused
ones at zero, the same ergonomics as landing a new linter. A stale flat
:ignore-counts entry for either linter is self-healing: `fix!` drops it
every run and `check` fails if one reappears, since it would otherwise
double-count every occurrence the new field already covers.

Ported onto the prod/test ratchet split: both ratchet files carry the
per-symbol fields, and each file's occurrences are attributed separately.

* Attribute discouraged-var ignores from kondo findings, not nearest usage

Matching each ignore to the nearest later usage of a configured symbol
counted usages kondo never flagged (scoped :config-in-ns entries, :off
overrides) and let a stale ignore claim an unrelated usage. Attribution
now lints copies of the files with the discouraged ignores disabled,
joins each finding to its var or namespace usage at the same row and
column, and charges it to the last ignore before it naming the linter.
An ignore counts once per file and distinct symbol it covers. The copies
rename the ignore key to one of the same length rather than blanking the
ignore, so every form reads as before and every row and column stays put.

A failed kondo run now throws instead of zeroing every budget. Findings
that resolve to no configured symbol fail the check and stop the shrink
from writing; ignores that cover no finding only warn. Without an
:attribute, a discouraged ignore throws rather than reading as zero.
Each ratchet file's occurrences are attributed separately.

`--seed :discouraged-var/clojure.core/println` seeds one symbol, a bare
`--seed :discouraged-var` seeds every symbol at its actual count, and an
empty suffix is rejected. Bulk seeding at zero is gone: the next shrink
dropped those zeros anyway. The shrink workflow runs prepare-backend,
since the shrink now needs the Clojure CLI.

Budgets are re-seeded from the new attribution. Several were undercounted
or charged to the wrong symbol before.

This squashes the unpushed review-fix commits on top of the per-symbol
commit, since #83383 rewrote the same functions each of them changed.

* Remove a stale :discouraged-namespace ignore on clj-yaml.core

clj-yaml.core is no longer a discouraged namespace, so the ignore on its
require in lib-be.metadata.jvm suppresses nothing.

* Don't charge findings kondo reports with the ignores in place

Attribution charged every discouraged finding on the disabled-ignore
copies to the last ignore before it, including findings no ignore
suppresses. Unsuppressed usages in dev/ inflated the eval, println and prn
budgets, and a new usage of a symbol an earlier ignore already covers
went uncounted.

Lint the original files in the same kondo run and drop any finding they
also report, so only findings an ignore suppresses are attributed. An
ignore nested inside another for the same linter still takes the
findings under it, leaving the outer one reported as covering nothing.

* Parse kondo's JSON output with babashka.json

mage already depends on babashka.json, so attribution doesn't need
cheshire or the :discouraged-namespace ignore that came with it. That
returns the cheshire.core budget to 4.

* Attribute prod and test ignores in one kondo run

check and fix! attributed the prod and test occurrences in separate
calls, and each call started its own JVM and lint pass. :attribute now
takes a seq of occurrence groups and returns one attribution per group,
so attribute-occurrences! lints every file once and splits the result.
kondo-ratchets drops from about 39s to 33s.

* Drop budgets for symbols no longer configured instead of refusing

Removing a symbol from config.edn left its per-symbol budget naming an
unknown key, and both check and the shrink threw on it. The shrink, the
tool meant to repair that drift, couldn't. Two PRs could each pass alone
and break the shrink on master: one seeding a symbol, the other removing
it from the config. check now warns about such a budget, and the shrink
drops it at zero like any unused budget, since attribution never charges
an unconfigured symbol. --seed still rejects an unconfigured symbol.

A bare --seed :discouraged-var no longer warns once per configured symbol
with no ignores, and check's ok line counts the per-symbol budgets
alongside :ignore-counts.

* Share the ratchet setup and config walks between their callers

check and fix! repeated the same setup: validate the linters, read the
module and test ratchet files, scan, split prod from test, and attribute.
They now share read-policies and scan-and-attribute. fix! still resolves
and validates --seed before the scan, so a bad seed fails without waiting
on kondo.

The repository-linter and discouraged-symbol lookups each walked
config.edn with an atom to collect nested :linters maps. Both now read
them from one linters-maps. The budget-line formatter is shared, and the
empty and mergeable field sets derive from ratchet-field-order. Dropped
the unused one-argument discouraged-symbols.

* Correct the ratchet task's runtime claim and plain up its wording

The kondo-ratchets :doc said the check takes seconds; with the kondo
attribution pass it takes about half a minute. The CLAUDE.md paragraph
and the ratchet-file headers no longer open sentences with code.

The unattributed-ignore warning now names a nested ignore for the same
linter as a cause: the inner ignore takes the finding, so the outer one
reads as covering nothing even though removing it would surface a
warning.

* Test that a failed kondo run throws rather than reading as no findings

If a failed or garbled kondo run read as an empty result, attribution
would count zero ignores and the shrink would drop every per-symbol
budget. The exit and parse checks move into parse-kondo-output, which
tests now cover for a nonzero exit and for output that isn't a map.

The attribution tests build matching findings and var usages with one
var-hits helper, and the check test's attribution stub reads as what it
returns.

* Refuse colliding discouraged symbols before an unseeded shrink

Dropping the unknown-key validation also dropped the shrink's only call
to discouraged-count-keys, which throws when two configured symbols share
a ratchet key. An unseeded shrink could then merge both symbols' ignores
into one budget. read-policies now reads the configured keys up front, so
check and fix! both refuse a collision before scanning.

* Reject kondo output without a findings list

A run that exits cleanly but prints a map with no :findings, such as {},
parsed as a result with no findings. Attribution would then count zero
ignores and the shrink would drop every per-symbol budget.
parse-kondo-output now requires :findings to be a list. A missing
analysis bucket already fails loudly, since every finding comes back
unresolved.

* Note where nested ignores leave a budget short

Attribution charges a finding to the last ignore before it, so an outer
ignore around a nested one for the same linter counts toward no budget.
State that next to the rule, where a reader of the code will look for it.

* Flag the nested-ignore undercount as a known gap

Lead the comment with the gotcha and its consequence, a budget one short
that an unattributed warning hides behind, so a reader doesn't mistake
the last-ignore rule for a complete one.

* Key aliased discouraged vars by the namespace the file requires

Kondo resolves a re-exported var to the namespace it learned the var came
from, and what it has learned depends on its cache and on which files
share the run. lib.core re-exports ->legacy-MBQL from lib.convert, so
after an ordinary lint of lib/core.cljc the same ten ignores moved from
:lib.core/->legacy-MBQL to :lib.convert/->legacy-MBQL and the check
failed.

A var used through an alias now takes its namespace from the file's own
require for that alias, per language so a .cljc file's clj and cljs
requires stay apart, and the attribution lint runs with --cache false.

* Split the attribution test by behaviour

One deftest each for counting, which findings are charged, and how a
finding resolves to a symbol, so a failure names the behaviour that broke.

* Say a stale flat discouraged budget is never read, not double-counted

The check removes these linters from the occurrences before comparing
:ignore-counts, so a leftover flat entry is ignored rather than counted a
second time.

* Mark the nested-ignore gap as a way past the ratchet, with a TODO

A new ignore wrapped around an existing one for the same linter needs no
budget, so say that plainly and record the planned fix: lint copies with
one ignore disabled each and charge a finding to the copy that reveals it.
2026-10-02 22:42:32 +00:00
Andrei Simionescu
18b6929ad7 Add xAI (Grok) as a Metabot provider (#83431)
* Add xAI (Grok) as a Metabot provider

* Scroll the provider buttons into view in the setup e2e test

* Turn off reasoning on Grok 4.3 for structured output

* Report reasoning support for Grok 4.3

* Add xAI to the shared LLM test connections
2026-10-02 22:40:30 +00:00
Andrei Chernykh
dff4d17f72 Migrate the color picker and color selector off Emotion (#83480)
* Migrate the color picker and color selector off Emotion

* Move the color picker styles into its classes
2026-10-02 15:03:14 -07:00
Maz Ameli
cdd2788f2a Rename Data Studio library page heading to Semantic layer (#82224)
* Rename the Data Studio library page heading to Semantic layer.

The sidebar tab already uses that name; this keeps the page header consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* update strings and tests to Semantic layer

* update a bunch more straggler strings

* more tweaks in light of 82390

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 21:35:48 +00:00
Andrei Chernykh
2668add670 Migrate HelpCard and the icon button wrapper off Emotion (#83467)
* Migrate HelpCard and the icon button wrapper off Emotion

* Move the help card style props into its classes
2026-10-02 14:32:40 -07:00
Andrei Chernykh
6e674681c3 Migrate the segment form and revision icons off Emotion (#83448)
* Migrate the segment form and revision icons off Emotion

* Drop redundant wrappers in the segment form

* Move the segment form styles into its class
2026-10-02 14:32:16 -07:00
Andrei Chernykh
f349f44d32 Migrate the public action page off Emotion, drop LegaleseStep (#83488)
* Migrate the public action page off Emotion, drop LegaleseStep

* Use Title and semantic tokens on the public action page
2026-10-02 14:31:51 -07:00
github-automation-metabase
4e6f460e9d Auto-update documentation for master (#83603)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-10-02 21:27:49 +00:00
Andrei Simionescu
35e8d0675f Accept JSON replies from models that ignore the forced tool call (#82974) 2026-10-02 20:51:44 +00:00
Andrei Chernykh
c80438a092 Migrate the filter value lists off Emotion (#83384)
* Migrate the filter value lists off Emotion

* Update Loki Snapshots

* Address review feedback and truncate long filter values

* Revert the truncation of long filter values

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-02 13:48:33 -07:00
Anderson
1956024cf7 Show other users' personal collections immediately after inviting a user (#83501)
* Refresh personal collection visibility after user changes

* Use shared test utilities for waitFor
2026-10-02 17:44:20 -03:00
Aleksandr Lesnenko
ee09290185 fix(channel): stop wrapping email card bodies in a link (#83269)
* test(channel): add failing tests for linked tables in emails

Alert and dashboard subscription emails wrap the whole card body, table
included, in a link to the question, so readers can't select cells.

Refs #34165

* fix(channel): stop wrapping email card bodies in a link

Alert and dashboard subscription emails wrapped each card's title,
description, parameters and body in one link, so readers could not
select or copy table cells. The wrapper is now a plain div. The card
title keeps its own link to the question or dashcard.

Fixes #34165

* review
2026-10-02 16:35:20 -04:00
Andrei Chernykh
a8ba83ff21 Migrate the accordion list and aggregation header off Emotion (#83390)
* Migrate the accordion list and aggregation header off Emotion

* Drop the span around the action row chevron

* Make the aggregation back link a button, use CS utilities
2026-10-02 13:29:53 -07:00
Andrei Chernykh
ea62cc1c11 Migrate the setup step components off Emotion (#83283)
* Migrate the setup step components off Emotion

* Style the user step description with props

* Move step borders and radii to style props

* Fold redundant step wrappers into their children

* Use the CS scroll utility for the setup locale list
2026-10-02 13:24:32 -07:00
technomancy
e32e3bc18a Don't run mutating warehouses-rest api tests in parallel. (#83582)
This namespace is currently the largest source of driver test failures
in CI. The frequently-failing tests are all marked as `^:parallel`.
Many of these tests use `mt/with-temp` to insert rows. For obvious
reasons, this causes problems if you have one test making assertions
about the list of databases while another test changes what that list is.

To be safe I've removed parallelism from *every* test that uses `mt/with-temp`.
I realize this may be overly conservative; it's possible that it's
perfectly safe to insert into `:model/Card` for instance, but I've
erred on the side of caution for now. I could be convinced to restore
some of these if someone argues for it.
2026-10-02 13:22:27 -07:00
Andrei Chernykh
3c2efcc776 Migrate the Visualization container off Emotion (#83171)
* Migrate the Visualization container off Emotion

* Use Stack and CS utility classes in Visualization views
2026-10-02 13:11:06 -07:00
Andrei Simionescu
8e8712ac80 Accept nested tool arguments when replaying Metabot tool calls (#83511)
* Accept nested tool arguments when replaying Metabot tool calls

* Check replayed tool arguments as decoded JSON instead of anything

Tool-call arguments reach the adapters decoded from JSON: keyword keys from the stream parser, string
keys from replayed history. A metabot schema that allows either key at every depth fixes the nested
replay failure and still rejects values JSON cannot hold, which `tool-io` (`:any`) lets through.
`request.schema/json-value` describes JSON over HTTP, where keys are strings, and has no users left.

The replay test now checks the encoded arguments, not just the tool name, and a new test covers deeper
nesting with either key type and a value JSON cannot hold.

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2026-10-02 20:53:05 +01:00
Andrei Chernykh
3c0d9e930f Migrate the auth card and cloud panel off Emotion (#83267)
* Migrate the auth card and cloud panel off Emotion

* Use plain rem strings instead of rem() calls

* Build the migration card on Paper

* Use an h2 Title for the auth card title
2026-10-02 12:35:28 -07:00
Nemanja Glumac
e729c00e1d Consolidate visualizer e2e tests (#83556)
* Remove the visualizer dataset-replace test that no longer replaces

The replace and undo steps were dropped when every dataset click became
an add, so the test only adds two datasets. Its undo/redo and two-dataset
checks already run in the change-history and VIZ-905 tests; the
three-dataset test now asserts the well item names instead of counts.

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

* Fold visualizer basics subset tests into the tests that share their setup

Six tests repeated another test's fixture and entry path only to add a
few steps: the ellipsis-menu and description tests now run inside the
rename test, title drill-through inside the title propagation test,
table preview inside the dirtiness test, the empty search inside the
change-history test, and the public and embedded checks share one
dashboard.

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

* Fold pie, columns-mapping and trend-line visualizer tests into cartesian

The pie settings and trend-line tests built the same visualizer dashboard
as the cartesian settings test, so they now run in it; the pie change is
also checked on the dashcard after saving, which the old test never did.
The column-remap test opened the same card as the default-colors test,
and the remaining columns-mapping test moves over as is.

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

* Fold funnel and drill-through visualizer tests that share setup

The VIZ-678 scalar funnel test was a prefix of the scalar funnel build
test, which now checks the wells before closing the dataset list. The
single-question funnel title test moves into the funnels spec, and the
VIZ-979 brush test runs at the start of the drill-through test on the
same dashboard. The brush check now counts dataset requests after the
single-series brush has fired instead of right after the multi-series
one.

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

* Repair visualizer tests that could not fail

- The scalar funnel drill used .get("polygon"), which ignores its
  subject and clicked the regular funnel again; it now clicks the scalar
  funnel and expects its lowercase views column.
- The percent formatting repro (metabase#61521) only asserted the absence
  of unformatted ticks; it now checks the added series' tooltip value is
  a percentage first, and uses the shared dataset picker helper.
- The unsupported-viz test asserted missing hover actions before the
  actions panel had loaded; it now waits for Edit visualization first.

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

* Give visualizer cartesian negative checks a positive precondition

The pivot-grouping check now scopes to the pivot source after confirming
its Count column is listed, and the timeseries breakout test asserts the
second Count is in the well before checking it is removed.

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

* Drop unused visualizer e2e setup

Remove questions no visualizer test or fixture reads, request intercepts
nothing waits on, and an orphaned comment.

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

* Fix misleading visualizer e2e titles, logs and comments

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

* Move visualizer e2e specs to a top-level visualizer folder

The visualizer is its own frontend module, so its specs leave the
dashboard folder. The dashboard-height repro for cards with hidden
empty results never touched the visualizer, so it moves to the
dashboard reproductions spec instead.

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

* Fix visualizer consolidation follow-ups from the parity check

- The titled-card menu check expects Download results, since visualizer
  cards never offer Edit visualization in view mode.
- The column-remap test keeps its own sidebar entry path instead of
  riding on the default-colors test, which opens a plain dashcard.
- Drop tooltip absence checks that had nothing to wait for; the click
  actions popover visibility check already guards the drill.

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

* Drop the scalar funnel step from the visualizer drill-through test

The step only ever re-clicked the regular funnel. Clicking the scalar
funnel itself drills into the first metric's card instead of the clicked
step's card, so it can't assert correct behavior until that is fixed.

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

* Match only the percent value in the metabase#61521 tooltip check

The tooltip row also shows a signed period comparison that is always a
percentage, so the check now targets the unsigned value cell.

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

* Create single-test visualizer cards inside the tests that use them

The cartesian native funnel and scalar cards only feed the viz settings
test, and each funnels test needs its own few cards, so the shared
beforeEach no longer creates them for every test.

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

* Add the colliding visualizer dataset before the distinct one in VIZ-693

This restores the order the removed metabase#57897 test covered: adding
a dataset after a suffixed column is already in the well.

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

* Check the funnel tooltip hides once the drill popover is open

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

* Check the public visualizer dashboard before enabling embedding

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:05:58 +00:00
Nemanja Glumac
e617a44838 Consolidate actions e2e tests (#83533)
* Merge overlapping actions-on-dashboards e2e tests

Fold subset tests into the supersets that share their setup: modal
close checks into the custom query action test, the hidden field
create test into the implicit create test, public and static embed
checks into one test, date/time updates into the data types update
test, hidden column checks into the insert test, the two execute-modal
edit tests into one, and the constraint violation test into the
refetch test.

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

* Assert the edited query action's write in the database

Check the updated row instead of parsing the execute request body, and
type a date that differs from the row's existing value.

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

* Merge overlapping model actions e2e tests

Fold the constraint-violation check into the CRUD test, the basic query
action run into the query action execution test, and the separate
public query/implicit action sharing tests into the combined public
sharing test. Drop the template tag parameters test from this spec.

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

* Drop unused param and hardcoded db id in actions-on-dashboards e2e

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

* Assert the action editor's database selector state

The single-database check clicked the static database name and asserted
an absent popover entry, which passes regardless. Assert the static
database name is shown in place of the selector, and that the selector
is present when several databases are available.

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

* Fold object detail action tests into the permission-level test

Run the constraint-violation form error check and the dashboard object detail check for both admin and normal users inside the update/delete flow. The dashboard card now uses a model that has implicit actions, so the missing actions menu reflects the hidden controls rather than an absent action list.

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

* Strengthen weak model actions e2e assertions

Anchor negative assertions with positive checks on the same container,
assert the implicit action's form fields through the editor instead of
parsing the API response, and register the impersonation test for
postgres only.

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

* Drop unused setup from model actions e2e tests

Remove intercepts that are never waited on, the unused read-only option
of the action editor helper, and a stray comment; align the top-level
describe title with the spec location.

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

* Assert object detail action form against fixture values

Compare prefilled form values with the known scoreboard rows instead of the prefetch response, and wait for the model actions request before asserting the actions menu is absent.

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

* Fold action reproduction tests that share setup

Check the action button size in edit mode inside the info sidebar test, and run the variable-only template tag parameter checks inside the action creator viewport test.

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

* Compare action button bounds with its dashcard

The scrollHeight comparison passed even when the button overflowed, since both heights grow with the overflowing content. Assert the button's bounding box stays inside the dashcard container instead.

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

* Verify the stored timestamp after an implicit update action

Assert the success toast, then reopen the row and its update form to confirm the created-at value was persisted unchanged.

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

* Drop unused setup from action reproduction tests

Remove unused intercepts and implicit actions from the query action test, fix a parameter mapping comment, and tag the writable-database primary key test as external.

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

* Dismiss the action toast before the slow score checks

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

* Fold the hidden-field query action test into the dashboard query action test

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

* Merge model detail page action execution e2e tests

Run the implicit create action and the query action flows in one test,
sharing the per-dialect setup.

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

* Loop viewports inside one metabase#31587 e2e test

Each viewport loads a freshly created Orders dashboard after resizing,
so every iteration starts without an action button.

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

* Merge the data-types insert and update action tests

The insert runs first and adds a new row; the update then prefills and
changes row 1, whose prefilled values confirm the insert left it intact.

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

* Merge the implicit update and delete dashboard action tests

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

* Move the delete precondition next to the delete step

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

* Format model actions e2e spec

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

* Use an object detail question for the dashcard actions check

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

* Assert positives before negatives in action e2e menus and mapping form

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:02:36 +00:00
Nick Fitzpatrick
1b136fafbe DB form test connection (#83100)
* DB form test connection

* PR Feedback

* deisgn feedback

* fixing e2e test
2026-10-02 18:50:52 +00:00
Chris Truter
33663c4738 Show module cycles in the module explorer (#83566)
* Show module cycles in the module explorer

A Cycles view lists each cycle by name with its metrics and strong bridges, and explains each metric on hover
with that cycle's numbers. Tree rows say which cycle a module is in, the detail panel shows it first, and the
graph draws cycle edges in red and bridges dashed, with a legend. A 0-hop option draws a cycle on its own.

Under Babashka a :uses :any module listed no dependencies, so cycles through it went missing; mage now reads
those modules' ns requires. Along the way: "unfilled" becomes "escape hatches", the graph draws only edges
within the chosen hops, and picking teams hides unrelated modules in the tree and colours theirs in cycles.

* Map tracked files to modules once in the module explorer

any-uses and module-stats each mapped every tracked file to its module. explorer-data now builds that map once
with file-modules and passes it to both, which also lets the any-uses test hand it a few temporary files.

* Tidy the cycles view code in the module explorer

* Fade strong bridges outside the picked teams in the cycles view

With teams picked, a long bridge list buried the bridges that touch the
team's own modules. Bridges with neither end in a picked team now fade.

* Colour strong bridges by the picked team they touch

* Dim the bridge end no picked team owns

* Break the cycle metric tooltips into lines

* Lay out the graph toolbar's counts, legend, and hints separately

The toolbar ran the counts, mouse hints, and REPL tip together in one line of text, and the legend did not say
what a dashed edge means. Counts, a labelled legend, a ? badge for the mouse hints, and the REPL tip now sit
apart.

* Line up the formulas in the cycle metric tooltips

* Let Expand and Collapse fold the tree while teams are picked

Any tree filter forced its kept rows open, so the folding buttons did
nothing while teams were picked. Only search and the escape-hatch filter
force rows open now; picking teams just hides unrelated rows.

* Say that cutting a strong bridge splits or destroys the cycle

* Shorten the strong bridge explanation

* Mark the strong bridges a picked team can cut

A bridge whose source module the team owns is one it can cut itself. Those
keep the full team colour; bridges that only reach into the team's modules
get a fainter outline and fill.

* Boil the cycle explanations down to their essentials

* Redraw the detail panel when the picked teams change

Its strong bridges dim the ends no picked team owns, so a team click left
them stale until the module was reselected.

* Wrap a long ns form in the any-uses test
2026-10-02 20:48:04 +02:00
github-automation-metabase
c3433d60c9 Tighten ratchets (#83578)
Co-authored-by: github-actions <github-actions@github.com>
2026-10-02 18:34:09 +00:00
Chris Truter
ea6218bb31 Measure how tangled module cycles are (#83552)
* Move the cycle-names reader into dev.module-cycle-names

dev.module-cycles needs the JVM: it scans the source tree through dev.deps-graph. Reading and validating
cycle-clusters.edn needs only clojure.edn, so it moves to its own namespace that Babashka can load. That lets
the mage module explorer show cycle names while accepting exactly the files the module-cycles test accepts.

* Measure how tangled each module cycle is

Cycle size alone makes a ring of modules look as bad as a fully tangled knot, though one cut breaks the ring.
Density compares cycles of different sizes, cycle rank bounds the edges to cut, and strong bridges name the
single edges whose removal splits a cycle.

The SCC code moves to dev.module-graph, which is plain Clojure so the Babashka-run module explorer can share it.
module-boundary-stats gains :scc-densities and :scc-cycle-ranks; module-cycles lists each cycle with its bridges.

* Put one sentence per line in the module-boundary-stats docstring

* Sort cycle members and edges as symbols

component-edges and the cyclic-components tie-break sorted by string, while
:modules sorted symbols with compare, so enterprise/* modules landed in a
different place in each list. Everything now sorts with compare.

* Round cycle densities with u/round-to-decimals

Also drop the module-cycles assertion: it only repeated a delegation that
cycle-metrics-test already covers.
2026-10-02 20:31:44 +02:00
Alexander Polyankin
5ce0b19bc0 Actions optional model (#83435) 2026-10-02 14:29:29 -04:00
Anderson
8e203fd8cd Restore Tab navigation in the sidebar New collection dialog by removing the nested modal (#83502)
Remove nested modal that blocked Tab in the sidebar New collection dialog
2026-10-02 14:59:12 -03:00
Maksym Yakubych
8ef85c202f Fix managed AI setup confirmation closing before Done (#82297) 2026-10-02 17:42:25 +00:00
Braden Shepherdson
2389bc777b Metrics: Preserve the :default dimensions from before curation (#82596)
Fixes UXW-4944.

Prior to the Metric Dimensions project, you couldn't curate the dimensions on a metric. They were automatically generated.

The `:default` dimension was based on the first breakout on the metric's query which matches one of its dimension mappings. This PR preserves that logic for pre-curation metrics by explicitly setting `:default true` on the default dimension.

Also, the breakout's temporal bucket (if any) becomes the `:default-temporal-unit` of the dimension as well. This also matches the pre-curation logic.

Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-10-02 13:37:15 -04:00
Alexander Polyankin
a14a43b8f3 Make data apps regular git sync entities (#83455) 2026-10-02 17:36:01 +00:00
Denis Berezin
24a52962de UXW-3922 Add syntax highlight for AI system prompts (#79675)
* UXW-3922 Add syntax highlight for AI system prompts

* Review fixes

* Fix CI

* Update Loki Snapshots

* Update Loki Snapshots

* Fix e2e

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-02 22:18:58 +05:00
Nemanja Glumac
1feea97a07 Move visualizations e2e specs into a single visualizations (#83564)
* Move visualizations e2e specs into a single visualizations folder

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

* Move custom visualizations e2e spec into its own folder

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:02:49 +00:00
Alex Yarosh
7efe272763 docs: data studio redesign - draft (#83392) 2026-10-02 16:59:42 +00:00
Nemanja Glumac
5d36749f80 Repair and restructure dashboard filters matrix e2e tests (#83546)
* Make arity drive multi-select in the dashboard filters matrix

The matrix passed arity only into the parameter name, so every single
case ran a multi-select parameter and duplicated its multi twin. Set
isMultiSelect from arity and expect single-select-list-field where the
multi case renders list-field.

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

* Run the dashboard filters matrix on one dashboard per setup group

Setup only varied by the field's has_field_values and mapped column, yet
each of the 216 cases restored, built a dashboard and visited it. Put
every case of a group on one dashboard as its own parameter and check
each widget in turn: six tests in one spec replace eleven page specs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:56:29 +02:00
Voytek Pituła
d443b6bd42 [BOT-2148] Keep Qwen3.8 Max's OpenRouter handling under its dated id (#83522)
* Keep Qwen3.8 Max's OpenRouter handling under its dated id

OpenRouter now lists only `qwen/qwen3.8-max-0902`. Key the catalog entry on
the dated id and alias the undated one to it, so settings saved with
`qwen/qwen3.8-max` keep the tool_choice downgrade, mandatory reasoning,
forced-call token floor and context window.

* move to Paolos approach

* remaining comments
2026-10-02 16:50:00 +00:00
Andrei Simionescu
70025975a1 Only fetch restricted field values for fields that should have them (#83482) 2026-10-02 16:35:39 +00:00
Anderson
6a860db454 Highlight personal collections navigation and prevent horizontal grid scrolling (#83300)
Fix personal collections navigation and grid overflow
2026-10-02 12:35:10 -03:00
Riley Thompson
975a4e85c5 Split Athena driver tests across 2 runners x 2 JVMs (#81811)
* multiple jvms per runner for bigquery

* trigger CI

* add failing test

* lower timeout

* remove debug stuff

* clean up comments

* Shard Athena driver tests across 2 runners x 2 JVMs

Athena ran its whole driver-test suite in one JVM on one runner, with no
matrix. Move it onto the runner-index matrix used by the BigQuery job:
2 runners x 2 JVMs = 4 partitions, with `runner-count` read back from
`strategy.job-total`.

Athena test databases are pre-created and named deterministically from the
dataset definition, and creation is skipped when they already exist, so the
concurrent JVMs share them. The job gains a per-runner `artifact-suffix` so
the two legs do not collide on one artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AvA18UqmaooDVPVP2VbkV6

* clean up

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-02 11:34:15 -04:00
Andrei Simionescu
bd4edf4b7f Tighten FieldValues caching for restricted users (#83483) 2026-10-02 15:30:14 +00:00
Alexander Polyankin
fc36e4407d Retire the moved confirmed_at v59 changesets with a v59 cleanup changeset (#83549) 2026-10-02 11:19:48 -04:00
Eric Normand
322be17322 Fix two Cypress timing races (GHY-4726) (#83346)
* Stop the metrics browse truncation test from racing its tooltip

The test hovers the description and expects /should be truncated/ to match 2 elements: the description cell and its tooltip. UXW-4935 later gave the metric a long name that also contains that phrase, so the settled page has 3 matches. The test passed only when Cypress counted the name and cell before the tooltip opened, and it failed when the tooltip opened first.

Match only the description text. The count is now 1 until the tooltip opens and 2 after it, so the assertion waits for the tooltip.

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

* Retry the custom column help text position check until the popover settles

verifyHelptextPosition measured the help text popover once, inside .then(), so Cypress did not retry it. The popover moves to the cursor after the cursor moves. When Cypress measured first, it saw the popover still under the previous argument and failed.

Measure both positions in a .should() callback, which Cypress retries until the popover reaches the cursor.

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

* Wait for table metadata before using the custom column notebook

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

* Revert "Wait for table metadata before using the custom column notebook"

This reverts commit 298107518a.

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

* Remove unnecessary comment in verifyHelptextPosition

Removed comment about retrying

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:16:56 +00:00
John Swanson
e6dd0e1140 Filter collection-item tables with uncorrelated subqueries (#83443)
* Filter collection-item tables with the permitted_tables CTE

For non-admins the `:table` arm of the collection children query filtered
with `visible-table-filter-select` twice plus `published-table-visible-clause`.
That emits an EXISTS over `data_permissions` correlated on each row of
`metabase_table`, so `GET /api/collection/:id/items` and `/items/metadata`
took minutes on instances with tens of thousands of tables. Superusers never
saw it because the filter collapses to `1 = 1` for them.

The arm now uses `visible-table-filter-with-cte` with
`:include-published-via-collection?`, which computes the permitted table ids
once and folds the published-table grant into the same CTE. The arm's CTEs
are hoisted into the statement's top-level `:with` by the two call sites,
since a CTE is not valid inside a UNION ALL branch or an EXISTS probe.
`published-table-visible-clause` had no other callers and is removed.

Postgres, 5000 tables, two groups with table-level grants, non-admin, best
of n runs: before 8.8s per call (1.9s with analyzed statistics), after
70-100ms; admin 8-18ms either way.

* Filter collection-item tables with an inlined permitted-tables subquery

The previous commit hoisted the `permitted_tables` CTE into the top-level
`:with` of the collection-items statement. H2 returns no rows for that CTE
when it is referenced from inside the UNION ALL branch's derived table, so
non-admins saw no published tables on H2: the CI backend run and the
data-studio table-collection-permissions e2e spec failed on exactly that.

`visible-table-filter-subquery-clause` emits the same grants query as an
uncorrelated IN subquery, built from the parts the CTE form uses. The
`:table` arm uses it, and the CTE hoisting is gone. On the 5000-table
Postgres bench the items query is 65-80ms either way.

A unit test checks the subquery form against the CTE form, including from
inside a derived table, and a new EE test exercises the EXISTS probe behind
`/items/metadata` and `include-available-models` as a non-admin, which was
previously covered only as an admin, where the filter collapses to 1 = 1.

* Fix cljfmt indentation of permitted-tables-query-parts return schema

* Reword the H2 note on visible-table-filter-subquery-clause
2026-10-02 08:15:11 -07:00
Chris Truter
e902d2623c Name module cycles and fail when they merge (#82386)
The file .clj-kondo/config/modules/cycle-clusters.edn names each cyclic
cluster of the module require graph by an anchor module. The cluster
holding the anchor carries the name. Membership is computed, never
recorded, so a named cluster can grow or shrink without an edit.

The test metabase.core.module-cycles-test fails until every cluster
holds exactly one anchor. A merge of two named clusters fails and shows
the requires joining them. A split asks its author to name the new half,
and an anchor that leaves every cycle has to be re-anchored or retired.
The name-module-cycle skill suggests space-themed names.
2026-10-02 15:11:16 +00:00
Egor Grushin
8be2a0d41a "Remove dimension" button size mismatches "Available dimensions" button size (#83485)
Match remove dimension button size to Available dimensions button
2026-10-02 15:15:17 +01:00
Anderson
b915d34220 Fix dashboard click actions after changing a table visualization to a bar chart (#83164)
* Fix dashboard click behavior using the displayed visualization

* Remove redundant tests

* Rename function / Fix bug with bar->table viz change flow / Update tests

* Use column click behaviors in table E2E fixtures
2026-10-02 11:04:46 -03:00
Chris Vandoodewaard
1ed043bc04 Consolidate onboarding e2e tests (#83417)
* Consolidate onboarding e2e tests

* Condense signin error tests, split database details test, restore i18n setup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix

* Consolidate overlapping onboarding e2e flows

* fix failing test

* Fix consecutive profile saves in onboarding e2e test

* Skip failed sign-in label timeout with fake clock

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:17:03 -04:00
Nemanja Glumac
a01a8e3f2d Consolidate native e2e tests (#83438)
* Fold native filter e2e specs into the native folder

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

* Remove duplicative native e2e tests

- The template tag run on a text tag is covered by the test that saves
  template tags as parameters, which now asserts its row count
- The text filter widget run is covered by the 15981 text test
- Switching an SQL question to Mongo is covered by the Mongo indentation
  test, which now asserts the selected data source

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

* Fold native e2e subset tests into their supersets

- Unsaved question sources, plain static list values and the number search
  box chip run inside the values source tests that already share their setup
- The snippet sidebar add icon and short snippet scrollbars are checked in
  the snippet tests, from the same starting state
- Uncached data reference metadata is checked in the field filter table
  list test, and the native model editor freeze in the Columns tab test

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

* Merge native e2e tests that share their setup

Each merged test keeps every step and assertion of the tests it
replaces, in their original starting state, and saves a restore and
usually a page load. Absorbed regression tests keep their issue number
in the host title.

- Required text, number, date and field filter tag checks run as one
  test per tag type
- Time grouping, query preview, tab indentation, Mongo indentation,
  data reference metrics, suggestions and card tag normalization tests
  with the same setup run as one test each
- Persisted database source tests share the postgres, MySQL and Mongo
  restores, and the 55951 and multi-database 57644 tests share theirs
- Snippet search, nested folders, folder permissions and snippet tag
  tests share their setup
- Run selected text, hidden column recovery, single-character
  autocomplete, field filter type change, search box custom list and
  empty field filter run overlay checks join the tests they repeat

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

* Repair native e2e tests that could not fail

- Count matching completion labels instead of the single element that
  the completion helper yields, and look up the referenced question's
  column completion by name
- Type the card reference with its # so the 22991 test references a
  card, and reload the 38083 question with the default in the URL
- Select the number list value in a query that runs, sum ids so the
  boolean field filter tells true from false, and query by ID so the
  31606 text and number runs succeed
- Expand the read-only snippet before checking for its edit button, and
  check the add menu next to the search button
- Drag from the filter popover with mouse events next to the drag that
  reorders the tags, and clear the required tag inputs through React
- Check the setting persists once when picking another database, and
  check the inner snippet tag type changed

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

* Strengthen weak native e2e assertions

- Give every negative assertion a positive precondition on the same
  element in the same test: listed filter values before missing ones,
  default values before missing status icons, loaded sidebars, panels
  and editors before missing buttons
- Assert row counts, table data and scalar values that the unfiltered
  or pre-run result would not match, and wait for re-runs first
- Check values after Update filter, the widget type after a change, the
  editor after toggling it, and the URL after saving
- Make the required field filter differ from its default before Set to
  default and before reverting

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

* Drop unused setup from native e2e tests

- Delete the 17490 describe that has setup but no test, the unused field
  filter data objects and three unused field filter helpers
- Drop duplicate restores, sign-ins and intercepts, intercepts and
  aliases nothing waits on, unused public sharing and embedding setup, a
  duplicate assertion and dead code
- Tag the writable postgres 59356 test @external and run the Sample
  Database-only 21597 and 31606 tests outside the external job

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

* Fix titles and comments in native e2e tests

Name the 31926 and 66745 tests after their own issues and the behavior
they check, describe the remapping and number search box tests
correctly, drop the Date step and 44930 child from titles that never
test them, and correct stale comments and a describe typo.

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

* Keep merged native e2e tests on their original inputs

- Continue the Mongo suggestions query in place instead of clicking to
  the end of the auto-closed brackets, and check the query text
- Check the 38083 default both when applied on load and when passed in
  the URL

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

* Address review of the native e2e consolidation

- Cite metabase#53299 on the Mongo switch test
- Count completion labels through a NativeEditor helper, check the 22991
  card reference through the editor text, and guard the 15700 type
  select by length
- Check the 49577 search box is open before the values it hides
- Prove the number list label maps to its value, and require listed
  values to be visible before checking the missing ones
- Merge the number and date filter widget runs into their required tag
  tests, run the 15981 number case there, and pick the number list value
  after setting its default in one test

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

* Fix native e2e tests failing in CI

- Confirm the replaced number list value with Update filter, since the tag has a default
- Restore the original popover drag in the 40232 check and the original 59075 resize assertion

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

* Consolidate more native e2e tests

- Merge the four SQL suggestion tests into one editor session
- Edit the snippet the create flow just made instead of a fresh one
- Save the time-grouping question with a Year default and reset it after reopening
- Cover moving the cursor out of a template tag before typing after it

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

* Save card tag normalization questions to Our analytics

The save modal defaults to the most recent dashboard, which left the
first question's dashboard in edit mode and blocked the next new
native question.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:59:34 +02:00
bryan
df84accc3c Move auth_identity.confirmed_at migrations from v59 to v63 ids (#83425)
* Move auth_identity.confirmed_at migrations from v59 to v63 ids

#77449 added these changesets to 059_update_migrations.yaml with v59 ids, but
the feature shipped in 63. No v59-v62 jar knows those ids, so migrate down
below 63 could not roll them back and the cross-version downgrade tests fail.

Move them to resources/migrations/063/ with v63 ids, retire the stale v59
changelog rows first, and guard the addColumn so instances that already have
the column mark it ran.

* Keep migration tests grouped by major version

* trigger ci since failing test is now quarantined

---------

Co-authored-by: ChrisVandoo <chris.vandoodewaard@metabase.com>
2026-10-02 08:37:31 -04:00
Egor Grushin
1a20e28a3a Misaligned comments button in Document (#83486)
Fix comments button alignment, color and hover in documents
2026-10-02 12:00:22 +01:00
Chris Truter
06b78789e3 Keep top-level feature modules out of the module cycle (#83453)
* Keep revisions, transforms, slackbot and agent-api out of the bottom of the module graph

* Treat transforms.feature-gating as gating code and move the document revision tests to revisions

* Rename the channel and premium-features REST child modules to .rest

* Address review: openapi path filter, view SQL source path, last-edit docstring

* Match endpoint files in nested modules in the openapi path filter
2026-10-02 10:54:38 +00:00
YB
bbc2eefceb [GDGT-3170] keep subscriptions when a dashboard is trashed (#82999)
Trashing a dashboard hard-deleted its subscriptions since v0.50.0 (#42558), so restoring it lost them.

* Subscriptions are now deleted only on permanent delete, through Toucan so their Quartz triggers go too.
* Triggers keep firing while the dashboard is in Trash; the job skips them before opening a task run.
* Trashing or restoring a collection no longer touches its dashboard subscriptions, so restore can't revive one the user deleted.

Subscriptions deleted before this fix can't be recovered without a DB backup.
2026-10-02 17:18:37 +08:00
Romeo Van Snick
eda959cf7b Disable the move and create buttons until the shared collection is known (#83489) 2026-10-02 18:21:57 +10:00
Romeo Van Snick
4a91325213 Answer conditional requests for static assets (#81937)
* Let static resources answer a conditional request

Two things stopped an unhashed static resource under /app from ever producing a
304, so every revalidation resent the whole file.

`cache-prevention-headers` sets `Last-Modified` to the time of the response,
which is right for a dynamic response and wrong for a resource that has a real
modification time. Ring already reports that time, and the security middleware
merges over the top of it. It now keeps an existing one.

Nothing handled conditional requests either, so a correct `Last-Modified` would
have changed nothing on its own. The static handler is wrapped in
`wrap-not-modified`, which sees ring's own header because it runs inside the
security middleware rather than after it.

The locale catalogues are the case that prompted this: 166 kb of brotli resent
on every load of a non-English instance. It applies to every unhashed asset
under /app.

* Drop the Last-Modified change: it was already correct

Testing against a running instance showed the header moving between requests,
which was this commit's own doing. `add-security-headers*` merges as
`(merge security-headers response-headers cors)`, so a response that carries its
own `Last-Modified` already wins, and static resources have carried a real one
all along. The helper added here took its arguments in the wrong order and
stripped that real value, leaving the response time in its place.

So the only thing missing was the conditional-request handling. `security.clj`
goes back to master, `wrap-not-modified` stays, and a test now exercises a
static asset through the whole chain rather than the handler alone, which is
what would have caught this.

* Test revalidation against an asset that exists

`app/index.html` is a 404: the static root is `frontend_client/app` and the
index template does not live under it. Use `app/assets/img/browserconfig.xml`,
which is checked in, unhashed and served `no-cache, must-revalidate`.

Also drop the blank lines inside `testing` bodies that cljfmt rejects.

* Read the response body whatever type it comes back as

The test asked `str/blank?` of the response body directly. A static resource is
served as a `java.io.File` from a source checkout, and the mock test client
passes that through untouched, so the call threw a ClassCastException on every
backend job. It is an `InputStream` from a jar and absent on a 304, so read the
body first and ask about the text.

* Answer 304 only for the build the client holds

Every resource in a build carries that build's timestamp, and the comparison
HTTP defines is ordered: a 304 goes out whenever the client's copy is not older
than the file on disk. After a downgrade the file on disk is the older one, so
the client kept the newer build's resource and was never sent the one it should
have.

The 304 now needs the client's validator to match this build's exactly. An
unchanged instance still answers every revalidation with a 304, which is where
the whole win is, and a version change in either direction sends the file.

* Validate static resources by a hash of their bytes

A 304 goes out when the client's If-None-Match names the bytes we would send.
The validator is a strong SHA-256 of each variant, so it is compared for
equality and every encoding gets its own, as separate representations require.

If-Modified-Since is no longer consulted. It is compared as an ordered date,
which answers 304 for any copy the client holds that is not older than the file
on disk, so a downgraded instance could never replace the newer assets a
long-lived client was holding. Last-Modified stays on the response, because
dropping it makes the security middleware substitute the time of the response.

Each variant is hashed once and memoised. Only a path that resolves is hashed,
so a request for one that does not cannot grow the memo.

* Build the 304 with the shared ETag helper

A 304 carries no body, so the metadata describing one has no place on it. The
response now echoes only the fields that guide a cache, which is what
`lib.etag-cache` already worked out, instead of keeping the whole 200 minus its
Content-Length.

`with-etag` takes the validator to use, defaulting to the running version's
hash as before, so a caller that can identify a single representation passes
its own. Matching comes with it: a list of validators, a wildcard and the weak
form of a strong tag are all handled.

It also releases the body it drops. A resource served from the uberjar is an
open stream, and the 304 path discarded it without closing.

* Hash a static file on disk on every request
2026-10-02 18:12:04 +10:00
Romeo Van Snick
12aa794291 Scale the jest worker count to the machine it's running on (#83395)
Scale the jest worker count to the machine
2026-10-02 18:03:51 +10:00
Nemanja Glumac
93d8358ed0 Consolidate metrics e2e tests (#83411)
* Remove duplicate and vacuous metrics e2e tests

Drop tests whose every assertion is repeated by a retained e2e test with
the same shape (metric rename, cancel-editing, add-to-dashboard from the
metric page, palette search via a recents-titled test, categorical
breakout), and two explorer tests whose assertions target strings the
app no longer renders.

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

* Fold subset metrics e2e tests into their supersets

Move the few assertions unique to small tests into the tests that
already run the same setup and steps: explorer empty state, unified and
auto-split visualization checks, per-metric accordion, browse sidebar
link, browse listing, verified-filter absence, pinned metrics listing,
metric page tabs, the summarize step header, and first-stage action
buttons for metric-based questions.

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

* Repair metrics e2e assertions that cannot fail

Give negatives something to catch (create the model-based metric the
picker must exclude, look for the metric where it could appear, set the
verified-only setting to a non-default value), assert outcomes instead
of pre-existing state (duplicate navigates to the copy, tooltip content,
filtered chart values, literal formula terms), restore the 44171 trigger
by running the editor preview before saving, pin the temporal breakout
years to the sample data, and drop assertions that are always true.

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

* Strengthen weak metrics e2e assertions

Check that saved metric definitions keep their filters, joins and
expressions, anchor negatives on mounted UI, assert counts after adds
and removals, verify the picked per-metric dimension and brush filter
state, prove fixture dimensions exist before asserting they are not
shared, and make one-shot legend reads retry.

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

* Merge metrics e2e tests that share setup

Run behaviours that start from the same fixtures, user and page as one
test each: metric editor leave guard with save/cancel, dimension
search/reorder with curation, new-metric empty-state run with filtered
timeseries creation, compatible-metric aggregation listing/description/
search, browse empty state with cancel-discard and create permissions,
temporal unit with breakout replacement, browse navigation, dot-menu
flows per role, global and search-page metric search, collection
bookmark and trash flows, expression pill rename/revert, and curated
dimension listing with replacement.

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

* Drop unused setup from metrics e2e specs

Remove intercepts and aliases nothing waits on, intercepts re-registered
by nested hooks, a repeated sign-in, a no-op wait, a dead guard and a
redundant negative, and call existing helpers instead of inline copies.

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

* Align metrics e2e titles and logs with what tests check

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

* Tighten waits and anchors in merged metrics e2e tests

Wait on search-page requests only, move the pointer off the hovercard
before searching the aggregation picker, and require the history tab to
list the revisions the test itself creates.

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

* Restore metrics e2e checks lost in consolidation

Assert that two metrics on a line or bar chart render as one
visualization, keep the metric summarize step header checked at a small
viewport, and check that seeding leaves no default dimension before any
edits.

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

* Merge more metrics e2e tests that share setup

Fold the metric bookmark test's events into the collection and browse
bookmark flows and its metric-page bookmark into the scalar metric test,
run both metric drill-thrus from one question, add the alert setup and
trash steps to the metric definition test, add dashboard metrics via the
context menu and the sidebar in one test, and chain the numeric breakout
after the temporal one.

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

* Fix metrics e2e failures from consolidation

Restore the /api/card/*/query intercept the visualizer selectDataset
helper waits on, scope the dashboard text queries to the grid since
chained queries only search the first dashcard, and assert the numeric
metric math on the chart, as the pill suffixes repeated metric instances.

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

* Address metrics e2e consolidation review

Restore the normal-user rename path and the empty pinned-section check
after unpinning the last metric. Merge the verified-filter toggle and
persisted-setting tests (now persisted via the UI toggle and verified
by reload), fold the static column-label check into the curated
dimensions test, and combine the EE dependencies and usage analytics
tests.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:34:58 +02:00
Sébastien
cdbf081c75 Exclude reactor-netty from the Snowflake driver (#83506) 2026-10-02 09:27:42 +02:00
Romeo Van Snick
7ba547bdf7 Fetch the registered pages once the tab is idle (#82628)
* Fetch the registered pages once the tab is idle

* Fetch the dashboard modals in the background too

* Wait for the tab to be idle again between background pages

* Take no path for a page that only the background pass fetches

* Keep asking whether the background pass is wanted

* Leave full-app embedding out of the background pass

* Register enterprise pages only where the license mounts them

* Drain the microtask queue instead of counting then hops

* Name the idle turn cap in the background prefetch tests
2026-10-02 14:45:41 +10:00
Romeo Van Snick
0c2a3008cb Start the current-user request alongside the settings request (#82721)
* Start the current-user request alongside the settings request

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

* Correct the comment on the early current-user request

* Skip the early current-user request on an instance with no user

* Wait for the settings before rendering the authenticated app

* Set the verified metrics setting to false where the test expects false

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 14:07:03 +10:00
Andrei Chernykh
fbc4c685e7 Migrate TextArea off Emotion (#83278)
* Migrate TextArea off Emotion

* Move TextArea styles to style props
2026-10-01 21:06:52 -07:00
Sloan Sparger
87816dec15 [Metabot] Expose finish reason from useMetabot hook (#82464) 2026-10-01 20:04:23 -05:00
Maksym Yakubych
e795326c38 Allow @-mentioning databases in Metabot (#82379)
* Allow @-mentioning databases in Metabot via search

* Allow picking databases in the mention browse-all modal
2026-10-02 00:58:42 +00:00
Ethan McCue
d8fa856fdd Allow configuring readable/writable paths (#81794) 2026-10-01 19:20:11 -05:00
Sloan Sparger
1cc6b8954e [Metabot] Updated conversation styling (#82385)
* action icon styles, suggested prompt styles, text styles, container / input width changes, inline code style update

* /question/ask + /question/research bg treatment

* prompt input style updates

* gradient edges for messages container, small random fixes

* scroll corrections

* updated loader

* color corrections

* improved inline sql ui

* spacing fixes

* updated chart styling

* temp data sources used change

* ci fix

* reduce metabot border radii, fix suggestion icon shrink and inline sql cancel padding

* hide empty suggestions and empty message containers

* use theme colors for metabot response loader

* match original loader contrast in dark mode

* [Metabot] Update`@mentions` and data sources ui (#82448)

* updated at mention logic with better ui + hover cards for library entities

* updated sources used ui

* color fix

* ci fixes

* use default radius for source feedback modal

* address pr feedback on mentions, hover card, and source feedback modal

* feedback
2026-10-01 19:14:29 -05:00
metamben
3200343d92 Add MSAL4J dependency for SQL Server (#82511)
* Support managed identity and other AD auth methods for SQL Server

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-10-01 23:59:50 +00:00
Nathan Voxland
876d66c6fd Switch oidc-settings visibility (#83461) 2026-10-01 22:18:51 +00:00
Nathan Voxland
f5f8cb4e76 Druid validation (#83286)
Refuse Druid host and port values that add Avatica connection properties

The Druid driver writes :host and :port straight into the Avatica connect string
(url=HOST:PORT/...;transparent_reconnection=true), where ; starts another connection property and
quotes regroup what follows. A host such as "http://h;truststore=/path" therefore set connection
properties that no other check sees. Host and port values containing semicolons, quotes, or
whitespace are now refused where the connection spec is built, so every connection path is
covered.
2026-10-01 22:10:20 +00:00
Rodrigo López Dato
34ad0a30e6 Fix flaky transforms search tests (#83463) 2026-10-01 21:59:51 +00:00
Kevin Kelleher
3a79c09a11 Fix Visualizer series color mismatch (#83451)
use sortedKeys to align with sortedDefaultKeys
2026-10-01 17:59:27 -04:00
Nemanja Glumac
fe8908f397 Consolidate binning e2e tests (#83440)
* Fold binning result checks into the binning options spec

The options-render tests now apply a non-default bucket from the open
popover and assert the re-run query, replacing the separate
qb-regular-table tests that only checked results. The unselected-column
pick tests for number and time series, and the column popover tests,
move over as-is. The longitude unselected pick stays covered by the
longitude correctness spec.

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

* Fold time series footer check into the distribution test

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

* Format binning options spec

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

* Assert exact rows in binning correctness specs

Time series buckets and the unbinned longitude case now check exact
first rows (breakout value and count, derived from the sample database)
instead of substring matches across all cells, which Count values could
satisfy. Longitude axis labels are looked up inside the chart.

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

* Wait for the re-run query after picking a sidebar bucket

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

* Drop the time series unselected-column pick test

The time series correctness spec picks Quarter from the unselected
Created At button and asserts the resulting rows.

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

* Merge the 34688 and 34690 time series footer tests

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

* Drop the stale issue list from the time series correctness spec

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

* Loop through bucket sizes in one test per correctness spec

Each spec now restores once and switches buckets on the selected
column, so the first pick still comes from the unselected column and
every later pick covers changing an already-selected bucket. Longitude
picks 20 degrees first to keep the unselected-column path for it, and
the unbinned case runs as the last step. Each pick waits for its own
query so value checks can't match the previous result.

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

* Fix lint error

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:41:25 +02:00
Nathan Voxland
90d3d80574 Move the explorations EDN-to-JSON migration from 65 to 64 (#83429) 2026-10-01 16:30:11 -05:00
Braden Shepherdson
bbdf326dea Rehab :card_schema handling; Pre-existing Dimension display names include the join FK's name (#79620)
* Rehab the `:card_schema` read-time upgrade

Cards have a `:card_schema` field that is intended to indicate which
"vintage" the `:dataset_query`, `:results_metadata`, and other
query-related fields are from. On read, an outdated card passes
through the upgrade steps for each schema version up to the latest.

However, this never really worked, for two reasons.

`t2/update!` reads a baseline row and diffs it against the incoming changes.
The after-select `:card_schema` upgrades mutated that baseline, so the diff
compared the incoming values against an upgraded baseline, instead of what
is really stored! Now the schema upgrades are only applied to proper
selects, and not the baseline read inside `t2/update!`.

This had the additional side effect that `:card_schema` was essentially
frozen; it would never be changed because both sides of `t2/update!`'s
diff always had the latest version.

The second problem was that the schema upgrades require a certain set of
fields in order to run correctly. The `:result_metadata` can't be
updated without the `:dataset_query`, and similarly for the `:dimensions`,
which also need to know the `:type`. This set of mutually-required
fields is now properly enforced: selecting any of the set requires
selecting all of them, so that the `:card_schema` upgrades can run
correctly.

This PR pulls this set of fields into `metabase.queries.card-schema` and
into a couple of `metabase.queries.db` functions re-exported from
`metabase.queries.core`, so that the knowledge of this all-for-one
subset of fields is as contained as practical. There are some circular
deps issues that cause a few modules to refer to `card-schema` (a leaf)
directly rather than through `queries.core`.

Tangential changes:
- Dropped `:card_schema` from unrelated select field lists. The previous
  upgrade logic required it whenever `:id` was requested. The condition
  is more precisely target now, so `:card_schema` can be skipped.
- Correctly selecting `:type` now for stale cards; this fixes a latent
  bug that mislabeled a stale model or metric as a question, leading to
  e.g. the wrong icon in the FE when listing stale items. I had to tweak
  an e2e expectation to match this corrected behaviour.

* Add table prefixes to dimension `:display_name`s

Fixes UXW-4987.

Dimension names need to indicate which table they came from, or there
end up being 3 "ID"s. This PR prepends the table name to dimension names
exactly once.

The `:card_schema` is used to know whether it has already been done, rather
than guessing from the name — a prefix check would misfire on any dimension
whose name legitimately starts with its table's.

The schema 24 upgrade previously only fired on metrics with no `:dimensions`
at all, computing the full set from the query. It now also handles metrics
that already have curated `:dimensions`, running them through
`metrics/modernize-early-dimensions` so implicitly joined dimensions pick up
their group's display name. Cards that are not metrics, and metrics with no
real query, are left alone as before.

Tests cover a schema 23 metric with legacy unprefixed dimensions: that it is
modernized on read, that an unrelated update leaves the stored vintage
untouched, that an update to a schema-governed column migrates the whole
group, that explicit incoming `:dimensions` win over the migrated ones, and
that a card already at 24 is not prefixed a second time.

* Fix writing old dimensions to the new query

* Add kondo ignore

* Update ratchets.edn

* use new helper for selecting the right columns

* Rename `modernize-early-dimensions` and update comments

Moving from vague "early" and "modernize" to explicitly mentioning
"pre-curation" dimensions.

* Update db.clj

---------

Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Ethan McCue <ethan@metabase.com>
2026-10-01 17:24:13 -04:00
github-automation-metabase
7953e39c55 Auto-update documentation for master (#83462)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-10-01 17:22:15 -04:00
Jeff Bruemmer
8fa7cbfbf4 docs - remove workspaces (#83457)
remove workspaces
2026-10-01 13:49:45 -07:00
Chris Truter
1132fdb218 Share agent query execution between MCP and Metabot (#83412)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 22:18:25 +02:00
Chris Vandoodewaard
9b469ec6a8 Move instance stats e2e spec into admin folder (#83445)
The stats folder held a single spec. The tracking setting that controls the
stats ping lives in admin settings, and admin already has a similar telemetry
spec (internal-analytics), so the spec belongs there.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:53:19 -04:00
Chris Truter
ca08e86675 Decouple foundation modules from cyclic cluster (#83441)
* Stop foundation modules reaching up into the module cycle

* Account for the new child modules in the driver-test ratchet
2026-10-01 19:31:36 +00:00
github-automation-metabase
d638d6eddf Tighten ratchets (#83450)
Co-authored-by: github-actions <github-actions@github.com>
2026-10-01 19:31:31 +00:00
Chris Truter
e4471f76d3 Split kondo ratchet budgets into separate prod and test files (#83383)
* Split kondo ratchet budgets into separate prod and test files

Prod and test kondo suppressions shared one .clj-kondo/ratchets.edn budget per
linter, so a pile of test-code ignores could mask a prod-code regression (or
vice versa) inside the same number. Adds .clj-kondo/ratchets-test.edn, tracking
:ignore-counts for anything under a `test` directory, independent of the prod
file's budget. :config-counts (.clj-kondo/config.edn suppressions) stays
combined in the prod file -- config.edn's own scoping mixes test and prod
namespaces in several groups, so it can't be reliably split per suppression.

Updates every consumer: dev.kondo-ratchet's fix!/check, bin/merge-kondo-ratchets,
the backport and release-cut disable scripts, the shrink workflow, and
file-paths.yaml/.gitignore.

* Fix test-ratchets validation and exemption-suggestion bugs from roborev

fix!/check read the test ratchets file with *ratchets-file* rebound to
*test-ratchets-file*, but validated it after that binding ended, so an
unknown linter there was reported against the prod path instead. Validation
now runs inside the binding, and both now reject a nonempty :config-counts
on the test file instead of letting fix! silently drop it.

kondo-insert-ignores also only ever suggested adding a new linter's
:comment-exempt to the prod file, even when every unjustified occurrence
was test-only. It now checks prod and test occurrences against each file's
own exemptions independently and names whichever file(s) actually need it.

* Dedupe test-ratchets read+validate, simplify test-occurrence?

fix! and check each rebuilt the same read-validate-test-file sequence
inline; extracted into read-validated-test-ratchets. test-occurrence?
no longer builds a throwaway set just to check one membership.

* Note what splitting :config-counts later would actually cost

* Fix Eastwood reflection warning in new test helper

empty-test-ratchets-file! returned an untyped value, so .getPath on it
needed reflection at each call site. Hinting the return type as
java.io.File fixes it -- CI's changed-tests Eastwood run caught this.

* Move return type hint onto the arg vector

Clj-Kondo prefers the hint on the arg vector to the one on the fn name.

* Move shrink PR body file reading into dev.kondo-ratchet

The bb task only parses its arguments now; reading and combining the
ratchet files lives next to shrink-pr-body, with a test.

* Give the test ratchets file its own header and drop its :config-counts

The test file was rendered with the prod header, whose last lines point
readers at ratchets-test.edn and say :config-counts "stays here" -- both
wrong inside the test file. It also carried an empty :config-counts that
the check rejects whenever it's nonempty.

render-test writes the test file with its own header and no
:config-counts, and throws on a nonempty one rather than dropping it.
fix!, the normalization check, and the three-way merge (a new "test"
kind) all use it.
2026-10-01 19:26:37 +00:00
Kevin Kelleher
bdd6335f59 Fix pie charts crashing on the string 'null' (#83432)
* give the string 'null' a color

* add color fallback for safety
2026-10-01 18:19:53 +00:00
John Swanson
392df00e38 Guard modifications of all entities a sandbox can depend on (#81901)
* Add all-referenced-entity-ids-recursive to Lib

The sandbox guard needs the transitive closure of everything a policy
query reads, not just its source Cards: the query processor re-expands
snippets, segments, and measures from the database on every query, and
a snippet's stored template tags can pull in further snippets and
Cards. all-referenced-entity-ids-recursive walks those edges one layer
at a time with a bulk metadata fetch per kind, terminates on cycles,
and keeps unresolvable ids in the result without following them.

all-source-card-ids-recursive stays for its other callers.

* SEC-1094: guard snippets, segments, and measures a sandbox is built out of

The sandbox guard from SEC-684 enumerated Cards only, but the query
processor re-expands a policy's snippets, segments, and measures from
the database on every query, and none of their write paths knew
sandboxes existed. A non-admin with native access to an unrelated
database could rewrite a policy snippet's content to `1 = 1` and the
sandboxed group saw every row.

The guard now enumerates the full sandbox dependency set through
all-referenced-entity-ids-recursive and refuses non-admin writes to
any member, by kind:

- snippets: content, name, archived, delete. Name is guarded because
  snippet references re-resolve by exact name (a Card's tags when its
  template tags are re-extracted, a snippet's own tags on save), so a
  rename plus an impostor would re-point the policy on its next
  legitimate save. Snippet creation is deliberately left unguarded.
- segments and measures: definition, archived, delete. Their
  references are id-only, so name is not guarded.
- Cards reachable only through a policy snippet's {{#card}} tags are
  now covered too, closing a gap in SEC-684.

Server-side writes with no user bound (sync, serdes, remote sync) and
admins pass, as before. OSS declares the defenterprise stubs next to the
model hooks, mirroring the Card pair.

Tests cover every guarded write for each kind on all three paths
(refused for a non-admin, allowed for an admin, allowed with no user
bound), nested snippet/segment/measure edges, the snippet-to-card edge,
and the API-level repro from the report. Policy queries in the tests
are built through Lib.

* Test collection archive and delete against the sandbox dependency set

Archiving or deleting a Collection cascades to its Cards and snippets
through bulk updates and deletes, so the sandbox guard fires once per
row. Two sandboxes with mixed chains (snippets into a nested Card into
a Segment; a source Card into a Measure into a Segment) are spread over
six Collections. A non-admin is refused on the four that hold a policy
dependency, and the archive is rolled back; the two that hold nothing a
policy reads go through. An admin may archive and delete all six.

* Trim the snippet guard docstring to what it guards

The rename rationale read as more confusing than useful in review; the
guarded keys are self-explanatory and the reasoning lives in the commit
history and the review thread.

* Indent with-mixed-policies! call sites the way cljfmt expects

* Note why the sandbox dependency walk is per-Card and how it could narrow by Database
2026-10-01 09:47:40 -07:00
Nicolò Pretto
554ebdc5aa [EMB-2398] serve custom illustration images by a dedicated endpoint (#83211)
* [EMB-2398] Serve the custom login page illustration by URL

The custom login page illustration is stored as a base64 data URI and
was sent in every HTML bootstrap, every /api/session/properties
response and the MCP bootstrap, even on pages that never show it. A
large image made each of those responses megabytes bigger and slowed
down embedding.

The setting getter now returns a versioned URL
(api/session/login-page-illustration?v=<hash>) for uploaded images.
The new public GET /api/session/login-page-illustration endpoint
serves the decoded bytes with an ETag, long-lived private caching when
the hash matches, CORP same-origin, and a sandbox CSP for SVG. Values
that are not uploaded images, such as an http URL, are returned
unchanged. The raw data URI stays in the app DB, so serdes is not
affected.

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

* [EMB-2398] Serve all custom illustrations by URL

The landing, no-data and no-object illustrations get the same treatment
as the login illustration: their getters return
`api/session/illustration/<setting-key>?v=<hash>` for an uploaded image,
and `GET /api/session/illustration/:key` serves the bytes. The landing
illustration needs a logged in user.

The frontend prefixes these URLs with the basename in `getSettings`, so
they work on subpath deployments and in the React SDK, which renders on
the host app origin. The endpoint no longer sends
`Cross-Origin-Resource-Policy: same-origin`, because the SDK loads the
no-data and no-object images cross-origin.

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

* [EMB-2398] Resolve illustration URLs in the whitelabel selectors

Resolving the URLs inside `getSettings` made a copy of the settings and
cached it in a WeakMap keyed by the settings object. When
/api/session/properties returns HTML instead of JSON (metabase#62501),
the settings are a string, the WeakMap throws on the string key, and the
page does not render. Master renders with default settings in that case.

`getSettings` is back to its master version. The new
`getCustomIllustrationUrl` returns one illustration setting with the
basename in front of an uploaded image URL, and the whitelabel selectors
use it. It returns a string, so it needs no cache.

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

* [EMB-2398] Drop the casts on the custom illustration values

`getCustomIllustrationUrl` returns undefined when the option is "custom"
but no image is stored. The `as string` casts hid that case, and the
login, unsubscribe and landing layouts then rendered
`background-image: url("undefined")`, which requests `/undefined`. The
selectors now return null in that case, so nothing renders, the same as
what the user saw before.

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

* [EMB-2398] Clarify the comment on getCustomIllustrationUrl

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

* [EMB-2398] Pass named arguments to the whitelabel illustration helpers

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

* [EMB-2398] Drop the ETag from the illustration endpoint

The setting getter always returns the URL with the image hash, which is
cached for a year, so the frontend never sends a conditional request.

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

* [EMB-2398] Keep the illustration parse across settings cache reloads

A settings cache reload gives an unchanged image a new String instance.
Compare the content to reuse the parsed image, and store the new instance
so later reads only compare references.

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

* Update src/metabase/appearance/settings.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* [EMB-2398] Give the no-object illustration its own alt text

The no-data and no-object illustrations both used "No results", so the
e2e spec could not tell them apart.

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

* [EMB-2398] Rewrap the comment on parsed-illustration

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

* [EMB-2398] Explain the pass-through values in the illustration URL test

Use a data URI that the backend cannot parse, since a valid base64 image
now reaches the frontend as the URL.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-10-01 17:41:31 +02:00
Chris Vandoodewaard
635a6c15fa Consolidate joins e2e tests (#83279)
* Consolidate joins e2e tests

Merge issue 39448 into issue 17710, give the second 46675 test a unique
title, and replace weak or negative-only assertions with ones that can fail.

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

* Remove unrelated join test assertion changes

* Label combined 17710/39448 test sections with cy.log

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

* Fix joins assertions that could never fail

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Strengthen weak joins reproduction assertions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Remove unused setup from joins e2e tests

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:40:31 -04:00
metamben
36348665e6 Snowflake workload identity federation (#82931)
Add Workload Identity Federation support for Snowflake
2026-10-01 17:33:44 +03:00
Anna Tomka
33b7edca7d Fix vertical alignment of controls in the horizontal Schedule layout (#83408) 2026-10-01 13:52:54 +00:00
Timur Mashara
2883b4a6da fix stale help link (#83396) 2026-10-01 15:43:29 +02:00
github-automation-metabase
5d116801cc Auto-update documentation for master (#83377)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-10-01 13:17:46 +00:00
Chris Truter
b6907dafbb Share Metabot dashboard subscription code (#83409)
Share the dashboard subscription helpers between both Metabot tools

The Slackbot tool kept its own copy of create-dashboard-subscription and its helpers.
It now calls the one in metabase.metabot.tools.subscriptions.
The copies differed only in setting include_csv/include_xls false on each card, which card->ref already defaults.
2026-10-01 12:13:12 +00:00
github-automation-metabase
8951e5ba3b Tighten ratchets (#83414)
Co-authored-by: github-actions <github-actions@github.com>
2026-10-01 12:09:53 +00:00
John Swanson
d60c8f2e3b UXW-5198: Improved data analysts grace period (#81969)
* EMB-2341 Don't honor features from an invalid token status

The store's 403 for an expired token still includes the full feature
list in its body, and since the token-grace-period refactor (#64938)
we never checked :valid — we just keyed off the features present. So
an expired token kept all premium features active (e.g. embed
branding stayed hidden).

The design principle: store faithfully what the store returned, and
enforce :valid at the point of access. Provenance is structural, not
data — the fetch layer either returns an authoritative answer (any
response with a parseable body, including a definitive 4xx rejection)
or throws for anything transient, so whatever reaches the caches is
holdable by construction.

- *token-features* returns #{} unless the stored status is :valid
- canonically-has-feature? answers a definitive false for a
  canonical-but-invalid token
- a 4xx with no body now throws (like 5xx) instead of returning a
  synthetic non-canonical status: previously one bodyless 4xx from an
  intermediary would be cached fresh and drop features for up to the
  12h soft TTL, defeating the grace period; real 4xx answers (expired,
  does not exist) still replace the cache immediately, as they should

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

* EMB-2341 Restore legible timeout errors; specify transient-failure behavior

A hung token-check request surfaced as "Cannot throw exception because
the return value of Throwable.getCause() is null": Diehard's
TimeoutExceededException has no cause, so the FailsafeException unwrap
threw nil (an NPE). It still failed safe, but the admin-visible error
was gibberish. Catch the timeout explicitly and restore the pre-64938
"Token validation timed out." message; guard the generic unwrap against
other causeless FailsafeExceptions while we're at it.

Test coverage for previously unspecified behavior, all transient
(thrown, never cached, grace period preserved) except the last:

- hung request: times out legibly, not cached (replaces the dead
  :timeout stub in token-checker-test that was never exercised)
- unparseable body (a proxy's HTML error page): not cached
- 2xx with no body: not cached
- 2xx with a :valid false verdict: authoritative and cached, same as
  a definitive 4xx

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

* EMB-2341 Harden the authoritative/transient boundary on token checks

- A response body that is missing, unparseable (e.g. a proxy's HTML error
  page), or not a JSON object is never a verdict: throw a legible error
  instead of relying on schema validation (which is disabled in prod) or
  leaking a raw Jackson parse error.
- A bare 403/404 with no body IS a verdict: the store may reject a token
  without elaboration, so synthesize an authoritative invalid status and
  cache it rather than refetching forever.
- do-refresh! now refuses to cache anything not marked :canonical?,
  enforcing in code the invariant the comments described.
- Setting a token busts the cache first, so re-entering a just-renewed
  token validates against the store instead of a stale expired verdict.

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

* EMB-2341 Negative-cache transient failures; widen breaker window

Move the error boundary inside the local memoize: failure maps now
memoize for local-ttl like any value, so a store outage costs one
network attempt per 5s per instance instead of one per feature check,
and concurrent misses share a single attempt (core.memoize
single-flights values, but not thrown exceptions). Synthetic maps still
never reach the durable db-hash cache, which sits inside the boundary.

Widen the circuit breaker window 60s -> 180s: 10 worst-case failures
(10s timeout + 5s negative cache apiece) span ~135s, so a 60s window
could never accumulate 10 slow failures and the breaker would never
open against a slow-failing store.

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

* Gate Data Analysts membership additions on advanced-permissions

Adding someone to the Data Analysts magic group now requires the
`:advanced-permissions` premium feature.

The check lives at the model layer, on `add-users-to-groups!` -- that
function inserts via raw HoneySQL and so never fires Toucan's
before-insert hook, and direct `t2/insert!` on the model is refused
outright, which makes it the one place every production caller passes
through. That covers the membership API, the `is_data_analyst`
user-update path, invites, LDAP/SSO sync (whose per-add catch already
logs and continues, so logins never break over it) and SCIM (which
surfaces the 402 to the identity provider).

Only additions are gated this way - after downgrade, the group can only shrink.

* Make the SCIM group PUT membership update diff-based

PUT /Groups/:id cleared the group and re-added the whole desired member
list. On a downgraded instance with grandfathered Data Analysts that
made every push fail: a removal-only push, and even an idempotent
resync, re-added members who were already there, tripped the
addition gate, 402'd and rolled back -- so a member could never be
removed via SCIM.

Apply the difference instead: remove (current - desired), add (desired -
current). A removal-only push and a resync now touch no additions and
succeed without the feature, while a push introducing a genuinely new
member still 402s so the drift is visible at the IdP. It also stops
churning membership row ids for members who never changed.

Group PATCH has no membership path -- only /Users/:id -- so PUT and the
POST create path were the only callers.

* Data Analysts group is visible iff advanced-permissions or it has active members

The group's visibility was a straight feature check. This had bad
implications: a user who was still in the group could get permissions
from an "invisible" group that couldn't be seen anywhere in the
permissions graph. We needed to do permissions graph surgery (basically
removing the group in OSS or when downgraded) to prevent this bad state.

Now we don't hide the group unless it's empty. This eliminates the
"hidden permissions" entirely - an empty group doesn't provide any
permissions.

* Render the Data Analysts group removal-only without advanced-permissions

The model-layer gate refuses additions to the Data Analysts group when
advanced-permissions is absent, but the group stays visible while it has
members. Without a matching UI state, every add affordance stayed live and
failed with a 402 on click. Mirror the gate in the frontend instead.

getAddMembersDisabledReason is the single seam: it pairs the group with the
advanced_permissions token feature (read through useHasTokenFeature) and
returns either null or the tooltip text. Removal paths never consult it, so
a grandfathered member can always be taken out -- in MembershipSelect and
GroupsMultiSelect the gate is skipped entirely once the person is already in
the group, which keeps deselect and the pill remove button working. The
gated membership row is also marked aria-disabled, since cursor styling
alone is invisible to assistive tech.

The API-key group picker is included because an API key becomes a member of
its group and so hits the same server-side refusal. Keeping the key's
current group is not an insert, so it is exempt the same way, and
EditApiKeyModal can still save an unchanged key.

GroupDetail wraps its button in a Tooltip only when there is a reason to
disable it, so the with-feature DOM is unchanged. Pinning, sort order, and
the bolt icon key off magic_group_type alone and are untouched; with the
feature present nothing changes.

Frontend code I'm least confident in so this could use a thorough FE
review.

* Gate Data Analyst app access on advanced-permissions

What we want:

- data studio is available to superusers in OSS

- you can also give access to Data Analysts, but only with the necessary feature

We formerly did this by removing all members from the Data Analysts
group on downgrade. This is fragile. Now, we just... gate access on
feature availability as well as `is_data_analyst` (in other words, you
can be a superuser OR you can be a data analyst with the feature
enabled).

The rule for what pauses: a Data Analyst capability survives a downgrade
iff the permissions graph renders it. Curate on the Library collection,
manage-table-metadata on every table, and any assigned data, collection,
or application permissions are durable state and stay put. App access
that appears in no graph exists only because the role does, so it
follows the token.

`is-data-analyst?`: are you a member of the Data Analysts group (or a superuser)?

`entitled-data-analyst?`: the above, plus a feature check in the Data Analysts group case.

Moved to `entitled-data-analyst?`:

- the Data Studio API surfaces (data-model table endpoints, glossary, library,
  table publish, transforms listings),

- measure and segment write/create,

- Metabot's write-transforms capability,

- the transform, job, and tag models,

- `enabled-source-types-for-user`, `can_access_transforms` in the current-user
  permissions map,

- the user-list authorization,

- and the people-list `can_access_data_studio` filter, where the SQL disjunct
  on `is_data_analyst` is dropped when the feature is absent.

- `canAccessDataStudio` and the glossary page apply the same predicate on the
  frontend.

Left on role membership: `data_permissions` and its SQL, `collection.clj`,
`permissions/user.clj`, the search context, the dependencies module, and the
request plumbing for the flag.  `is_data_analyst` on the current user stays raw
too; the frontend ANDs it with the token.

api.common cannot require premium-features (settings sits between them
and requires api.common back), so the feature read is late-resolved, the
way `metabase.settings.models.setting` reads `has-feature?`.

Tests pin every moved reader in both feature states for a non-admin
analyst, with superusers shown unaffected. Analyst-driven tests that
previously needed only a transforms feature now grant
`:advanced-permissions` alongside it.

Under an OSS build every premium feature is false, so a non-admin
analyst is never entitled there. Tests that assert an analyst succeeds
are EE-only via `mt/when-ee-evailable`; the refusal cases still run in
OSS. The transforms API tests run once per requesting user through
`with-transforms-api-users!`: a superuser in every edition, and a data
analyst when EE is available.

* Delete the OSS startup demotion of the Data Analysts group

Every OSS startup ran a sync routine that mutated permission state: if the
Data Analysts magic group had members, it renamed the group in place with a
"(converted)" suffix, nulled its magic type, minted a fresh empty magic group
under the original name, and cleared `is_data_analyst` instance-wide. Because
a token check can be indeterminate as well as negative, the routine had to be
tri-state, retrying in a background future until it got a canonical answer.

This kind of state-change on startup due to a feature being missing
is... risky. If someone's token expires, it's a big annoyance if they
have to manually copy users and permissions from their old "Data
Analysts" group to the new one.

And the previous commits made it unnecessary:

- you can no longer add new members to the Data Analysts group unless
  the feature is enabled.

- almost all permissions of Data Analysts are still active after a
  downgrade. They still have curate access to the Library collection,
  `manage-table-metadata` permissions on every table, and any data,
  collection, or application permissions you assigned. Only Data Studio,
  and the app access that exists purely because of the role (writing
  measures, segments and transforms), pauses.

- when the last user is removed from the Data Analysts group, it
  disappears.

So we can remove it: startup no longer touches permission state, a much
happier place to be.

Deleted: `sync-data-analyst-group-for-oss!`, `do-sync-conversion!`,
`unique-converted-group-name`, `grant-library-permissions!`,
`seconds-to-sleep-per-attempt`, the `*allow-modifying-magic-groups*` escape
hatch that only the conversion used, the `metabase.core.core` startup call, the
`permissions.core` re-export, six now-orphaned `permissions.db` helpers, and
the routine's five tests. `canonically-has-feature?` is untouched; it has other
callers.

* Update docs/people-and-groups/managing.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

* Read data_layer through the schema overlay in the Data Studio access tests

Table edits land in the user-settings overlay since #82246, so a bare
`t2/select` on `:model/Table` reports sync's value. Read it the way the
OSS Data Studio table tests do.

* Reword the Data Analysts add-members tooltip around plans, not feature names

Review feedback: "Advanced Permissions" is not a term the docs use. Match the
docs' framing of a plan that includes the Data Analysts group.

* Pause every role-granted Data Analyst capability on downgrade, not just Data Studio

Review feedback: Data Studio entry paused when the plan lost
advanced-permissions, but Monitor diagnostics, the metric dependencies tab and
relationships section, and analyst-wide visibility in the dependencies API
still keyed off bare group membership.

The rule the PR set out is that capabilities rendered by a permissions graph
survive a downgrade, while capabilities that come with the role alone pause.
Dependency viewing is the latter, so:

- getUserIsEntitledAnalyst (current-user) is the feature-aware selector, and
  Data Studio, Monitor diagnostics, and both metric spots use it.
- The dependencies API builds its visibility opts from entitled-data-analyst?.

* Announce every non-clickable membership row as disabled

Review feedback: the click handler and cursor already key off isDisabled, so
aria-disabled should too, not only the feature-gated Data Analysts case.

* Make each membership row a real toggle button

Review feedback: the row was a clickable li, which keyboard and assistive
technology users couldn't operate or interpret. Each row is now an li holding
a full-width button (aria-pressed for membership) with the group-manager
toggle beside it, since a button cannot contain another button. aria-disabled
rather than disabled keeps a gated row focusable and hoverable so the tooltip
can still explain why it can't be toggled.

* Tighten the data-analyst visibility clause docstring

Review suggestion, taken verbatim.

* Name the token-status marker after the flag it sets

Review nit: the helper stamps :canonical?, so call it canonical.

* Leave SCIM group membership alone when no pushed member is a known user

Review feedback: the diff-based PUT emptied a group when every member value
was unknown, where the previous code was a no-op. Restore the no-op and warn,
since the IdP is naming users this instance never provisioned and a silent
wipe would hide that drift behind a 200.

* Make adding an existing group member a no-op

Review feedback asked for a test of PUT /api/user/:id re-asserting
is_data_analyst on someone already in the group after a downgrade. Writing it
showed two failures: a 402 from the Data Analysts addition gate, and, with the
feature on, a 500 from the membership unique index. Both because the add path
never checked whether the row already existed.

add-users-to-groups! now drops pairs that already exist before anything else
runs, so the tenant checks, the addition gate, the insert, the flag updates
and the events only ever see genuine additions. Every caller becomes
idempotent, not just the user PUT.

* Treat a bodyless 4xx token check as transient, not as a rejection

Review feedback: nothing shows the store rejecting a token without a body;
observed 403/404s carry JSON. A proxy or WAF can strip one, though, and caching
that as "token is not valid" while an HTML error page from the same proxy is
treated as transient was the wrong way round. A missing or undecodable body now
always takes the transient path: not cached, retried under the breaker, with
the previous canonical status standing meanwhile.

* Only cache a token-status body that satisfies MinimalTokenStatus

Review feedback: any JSON object, even {}, was stamped canonical and cached.
A body must now satisfy MinimalTokenStatus, a boolean :valid, a non-blank
:status and, when present, a features list, before it counts as the store's
answer. The schema's docstring records why it is used instead of TokenStatus:
one surprising optional field in a real answer would otherwise become a
transient failure refetched forever.

* fix icon alignment, small cleanup

* Enable advanced-permissions for the data-analyst MCP tool tests

A data analyst's segment, measure, and transform writes now key off the
advanced-permissions feature, which no OSS build can have. The three MCP
v2 tool tests that drive an analyst were still running with the feature
off, so the analyst was refused where the test expected success.

Follow the pattern used elsewhere on this branch: run the analyst-positive
paths only with EE available and the feature enabled, keep the denials
unconditional, and assert the analyst is refused once the feature is gone.

* Enable advanced-permissions for the metabot transform read tests

A data analyst's transform reads now key off the advanced-permissions
feature: transform-readable? goes through api/entitled-data-analyst?,
which is false for a Data Analysts member while the feature is off. The
metabot get_transform_details tool tests and the transform resource test
still drove rasta as an analyst with the feature off, so every read was
refused where the test expected the source query to render.

Follow the pattern used elsewhere on this branch and add the feature to
those tests' premium-feature sets. The source-card refusal test gets it
too, so its 403 is about the private card rather than the missing
entitlement.

* Make the analyst-driven metabot transform read tests EE-only

The previous fix turned advanced-permissions on for these tests, but
with-premium-features cannot flip a feature on an OSS build: the
entitlement check bottoms out in config/ee-available?, so rasta as a data
analyst was still refused before the test reached the source-export gate
it pins. The EE jobs passed and only the OSS app-db job failed.

Wrap every deftest that reads a transform as an analyst in
mt/when-ee-evailable, as the MCP tool tests on this branch already do.
That covers the four tests that failed and two more that only passed on
OSS because the refusal they assert happened for the wrong reason.

* Expect Monitor to pause with Data Studio for an unentitled analyst

The app-switcher spec was written when only Data Studio paused on a plan
without advanced-permissions, and asserted Monitor stayed. A later commit
on this branch made Monitor diagnostics key off the entitled-analyst
selector too, so the assertion went stale. Assert both are hidden without
the feature, and both shown with it.

* Find membership popover rows by button role in the group-managers e2e spec

The rows became real buttons without an aria-label, so findByLabelText no
longer matches them. Query by role and name instead, as the unit spec does.

---------

Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
2026-10-01 05:04:43 -07:00
Chris Truter
f4fd9491ac Remove redundant schedule-key conversion (#83353)
* Remove dead schedule-key conversion from create-dashboard-subscription-tool

schedule->schedule-map (called downstream via make-slack-channel) already
normalizes snake_case schedule keys to kebab-case through u/normalize-map,
so the manual day_of_week/day_of_month/frequency conversion here has been
redundant since that call was introduced.

* Add test coverage for schedule->schedule-map with snake_case/string input

schedule->schedule-map-test only ever fed schedule->schedule-map kebab-case
keyword input, so nothing asserted that a snake_case/string schedule (the
shape create-dashboard-subscription-tool now passes through unchanged)
normalizes to the same schedule_type/schedule_day/schedule_frame.

* fixup! Add test coverage for schedule->schedule-map with snake_case/string input

Use are to pair each kebab-case/snake_case schedule instead of repeating the
comparison per case.

* fixup! Add test coverage for schedule->schedule-map with snake_case/string input

* fixup! Remove dead schedule-key conversion from create-dashboard-subscription-tool
2026-10-01 11:14:35 +00:00
Stas Gavrylov
cbc6c3a858 Restyle Sidesheet to Figma and add the offset variant (GDGT-2480) (#82747)
* Restyle Sidesheet to Figma and add the offset variant (GDGT-2480)

Brings the common Sidesheet in line with the Figma spec and adds the
inset ("offset") variant the design system defines.

- offset?: 16 applies a 16px top/right/bottom inset and a 16px radius;
  typed as the literal 16 so unsupported values can't enter the API
- 464px (29rem) default width, applied only when `size` is omitted so
  the named sizes stay exactly as they were
- surface-primary background, xs_outline elevation, 24/24/16 header,
  h4 title, and a 32px close control with a 16px icon
- Storybook: added an Offset story, and reduced the MDX page to a
  summary. Each story embedded there rendered a real fixed-position
  Sidesheet, so the docs page stacked them all on top of each other
  and none could be read; the stories remain in the sidebar.

Mantine Modal internals (focus trap, escape handling, overlay, portals,
nested subpages) are unchanged; this is a restyle plus one additive prop.

* Use the design-approved sm width for Sidesheets

* Make Sidesheet offset a boolean option

* Move static Sidesheet styling to Mantine props

* Add Loki references for Sidesheet layouts

* Update Loki Snapshots

* Wait for Sidesheet before capturing Loki references

* GDGT-2480: Address review comments

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-01 14:00:15 +03:00
Nemanja Glumac
106eaa5260 Consolidate question e2e tests (#83329)
* Fold question reproduction e2e specs into the question folder

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

* Remove duplicative question e2e test

The normal user's most-recent-dashboard preselection never asserted the
preselection; the lost-access test runs the same steps and asserts it.

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

* Move native query reproduction e2e tests into the native folder

Native editor saves, native parameters, native revisions, TIME casting
and Mongo native previews now live with the other native tests.

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

* Merge question e2e tests that share their setup

Each merged test keeps every step and assertion of the tests it
replaces, in their original starting state, and saves a restore and
usually a page load. Absorbed regression tests keep their issue number
in the host title.

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

* Drop unused setup and fix titles in question e2e tests

Tests that never use the summarize sidebar get their own describe
instead of loading a question first, and the multi-schema test
restores once. Drop duplicate restores, sign-ins, intercepts, aliases
and dead code; fix issue numbers and a copied title, tag the
postgres-writable 54205 test @external, and correct a stale comment and
mislabeled logs.

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

* Close edited expression editors with Cancel in merged e2e tests

The expression editor ignores Escape while it holds changed text, so the
merged offset and aggregation picker tests cancel the editor before
closing its popover.

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

* Assert the new model is the most recent item in the merged e2e test

Recent items now also hold the earlier picks, so check that the model
tops the list rather than appearing anywhere in it.

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

* Explain the recent items ordering the merged e2e test relies on

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

* Merge more question and native e2e tests that share their setup

- Native drills on the same orders question create it once and revisit
  it per drill; quick-filter and the timeseries brush share one question
- Duplicate a saved question into a collection, a dashboard and a new
  collection in one test, revisiting the question between parts
- The summarize sidebar scrollbar and error overlay checks run in the
  summarize sidebar describe
- Mongo, postgres-12 and no-autorun native tests with the same setup
  run as one test each

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

* Move custom expression validation e2e test into the custom column folder

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

* Strengthen weak question e2e assertions and fix tests that could not fail

- Give every negative assertion a positive precondition in the same
  context, and wait for re-run queries before asserting on results
- Replace checks against selectors, strings and console warnings that no
  longer exist with UI assertions (table data, filter pills, URLs,
  draggable item order, disabled inputs)
- Record the Name field on every frame to catch the save-modal title
  flash instead of a 10ms negative lookup
- Fix broken join fixtures in the View-only tests and assert the
  unaffected case before the source card moves
- Make the covered Visualize button test fail when the click succeeds
- Check a multi-schema join picks the table from the chosen schema

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

* Move custom column and expression e2e tests into the custom column folder

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

* Address final review of the question e2e consolidation

- Move the mouse off the info icon before clicking Custom Expression so
  the hovercard can't cover it
- Name merged host describes after the behavior they test
- Keep query builder brush filters with the drills they now share a
  test with
- Run the 13469 Reviews test in its own describe instead of after a
  Products page load

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

* Require the Name sampler to record values in the 42773 e2e test

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

* Fix question e2e tests that failed on their first CI run

- Assert a closed popover or hovercard without the helpers that first
  require it to be visible
- Correct expected values for the composite key filter pill and the
  table URL after browsing, and scope join step checks to the element
  whose text they check
- Switch to data before checking breakout headers, since pivoted
  results open as a visualization
- Drag the 39795 column with the dnd-kit mouse helper so the reorder
  happens, and drop the unused drag helper
- Check offset is missing from the open suggestion list instead of the
  list being absent
- Wait for the New collection button to be enabled, keep the reload
  that makes the second chart request metadata, and use a precondition
  that exists for brand-new questions

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

* Address minor review notes on the question e2e consolidation

- Describe what the 39795 test checks instead of the old bug
- Check the ad-hoc part of the native autorun test made no dataset
  request before the saved-question part
- Check that opening the preview sidebar on small screens keeps the
  notebook URL
- Use the dashboard card helpers in the 13801 test

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:56:07 +00:00
Will Fry
e86c531b9c GDGT-3227: searchable column picker (#83345)
* GDGT-3233: Make the field picker rows a keyboard-navigable listbox (#82556)

* Make the field picker rows a keyboard-navigable listbox

The data-step and join-step "Pick columns" popovers rendered a plain
checkbox list with no keyboard affordance. Rows are now Combobox.Option
elements inside an always-visible Combobox.Options listbox, with the
"Select all" checkbox as the keyboard entry point: arrow keys move the
highlight and Enter toggles the highlighted column.

Options carry aria-label, aria-checked and aria-disabled; the checkbox
inside a row is decorative. Since a row is a div rather than an input,
the existing DataStep/JoinStep specs and one e2e assertion move from
toBeDisabled to the aria-disabled attribute.

Rows are keyed by column position rather than longDisplayName, which is
not guaranteed unique.

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

* remove aria-checked

* Fix fields picker e2e test clicking the assertion's undefined subject

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* GDGT-3266: Route field picker bulk column toggles through one callback (#83110)

* Route bulk column toggles through one callback

FieldPicker emitted onSelectAll/onSelectNone, which each caller turned
into a whole-query update. Replace both with onToggleColumns(columns,
isSelected), which receives the rows that would actually change, and
give the callers the set arithmetic in a shared helper.

Each caller still applies the result in a single lib call, mapping
"everything" back to [] / :all and "nothing" to :none so the queries it
produces are unchanged. The data step also keeps one column selected
when a deselect would empty the field list, since an empty :fields
means "every column".

No behaviour change: the DataStep and JoinStep specs are untouched.

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

* update comment

* rename arg targetedColumns -> toggledColumns

* add jsdoc to getNextSelectedColumns()

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* GDGT-3234: Add a column search box to the field picker (#83111)

* Add a column search box to the field picker

Wide tables made the "Pick columns" popover a scrolling exercise. When
a table has more than nine columns the picker now shows a search box
pinned to the top of the dropdown, filtering on the translated display
name so the match is against what is on screen.

"Select all" stays visible while searching, relabelled to "Select all
of these", and acts only on the matching rows: it deselects them when
they are all selected, selects the rest otherwise, and is disabled when
no match can be toggled. Its aria-describedby points at the live region
announcing the match count, so the accessible name still matches the
visible label.

The popover's focus trap decides where focus lands on open: the search
box is marked data-autofocus on non-touch devices only, so touch devices
don't get the on-screen keyboard popped over the list, with "Select all"
as the fallback. React's autoFocus isn't used because it fires before
Mantine's useFocusReturn records the element to restore on close.
ArrowDown moves from the search box into the list. Escape closes the
picker, as in every other Metabase popover, and the clear button clears
the query.

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

* fix issue where clearing search moved focus to doc body

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* GDGT-3237: Make "Select all" reachable with the arrow keys (#83112)

* Make "Select all" reachable with the arrow keys

"Select all" was a checkbox outside the listbox, so arrow keys from the
search box could never reach it. Render it as the first Combobox.Option
instead, exposing its partial state through aria-checked="mixed", since
Mantine rewrites aria-selected to mark the keyboard highlight.

The listbox itself is now focusable and acts as the events target,
driving navigation through aria-activedescendant. It takes focus on open
when the search box isn't autofocused (no search box, or a touch
device). Space toggles the highlighted option, as expected in a
multi-select listbox.

The initial highlight skips "Select all" and starts on the first column,
so Enter after typing a search toggles the top match rather than every
match.

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

* remove ignored prop, rename test helper

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 10:13:45 +00:00
Will Fry
121fa7febe GDGT-3240: SegmentedControl (#82657)
* GDGT-2463 update SegmentedControl to new Figma designs

* Update Loki Snapshots

* Update Loki Snapshots

* Update Loki Snapshots

* exclude `c` prop from SegmentedControl

* remove disabled attribute from segmented control item

* Show SegmentedControl overview story in light and dark themes

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

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:09:42 +00:00
Romeo Van Snick
ee4510a467 Compile jest imports lazily (#82998)
* Load embed.js per test with isolateModules

* Compile jest imports lazily

* Settle requests instead of sleeping in negative tests

* Let async waits outlast a cold worker's first render

* Hold the path loading state open longer than a waitFor poll

* Run pending timers instead of all timers in the initializeQB spec

* Build the Accordion chevron element per render

Mantine merges a nested provider's theme into its parent's with a deep
merge, and a React element keeps a reference to the fiber that rendered
it. The chevron element ran at import time before, outside any render, so
it had no owner and the merge stopped at its props. Now that jest
compiles imports lazily the config runs during a render, so the merge
walks the fiber tree until the stack overflows. A function keeps the
element out of the theme.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 11:55:10 +10:00
Chris Truter
35d0f1356e Give mage's CLI test runner a 40-minute timeout (#83389) 2026-10-01 09:14:07 +08:00
Romeo Van Snick
759bc574e3 Fail unit tests on React key warnings (#83062)
* Add missing React keys in list children

* Give conversation date filter groups unique names

* Give the root collection change group a stable key

* Give duplicated test fixtures unique ids

* Fail unit tests on React key warnings
2026-10-01 11:11:05 +10:00
Eric Normand
e868c207e3 GHY-4722: Remote sync: model actions aren't exported, and dashboard-button actions only appear on full exports (#83347)
* Export a model's actions with the model in serdes and remote sync (GHY-4722)

An action points at its model through model_id, but the model does not reference its actions. Serdes exports that walk serdes/descendants from a target never reached the actions. That covers a collection-targeted serdes export and every remote-sync push.

serdes/descendants "Card" now lists a model's actions, and leaves out archived actions when :skip-archived is set. Remote sync walks serdes/descendants, so full remote-sync exports now write the actions.

Incremental remote-sync pushes also dropped actions, because untracked-content-deps kept only models with a remote-sync spec. Actions have no spec and never get a RemoteSyncObject row, so it now keeps them too. This also fixes dashboard-button actions, which a full export wrote but an incremental push did not.

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

* Track actions in remote sync with an Action spec (GHY-4722)

Adding, editing, or deleting an action on an already-pushed model did not reach the repo, and a pull did not delete actions the repo no longer had. Actions had no remote-sync spec and no events, so nothing tracked them.

The action API now publishes :event/action-create, :event/action-update, and :event/action-delete. A new Action spec tracks them: an action is eligible when its model card is (new :parent eligibility type), and its ledger row takes the model's collection through a join. Action is a child of Card, so archiving a model marks its actions for deletion, and moving it out of synced collections marks them removed. The cascade now finds the rows of non-Table children through their FK column.

A pull deletes absent actions only when their model is in a synced collection (new :scope-table removal option), guarded by the existing deletion-conflict check. On read-write instances, startup and collection re-sync give existing untracked actions a create row, so the next push writes them instead of the first pull reporting them as unsynced.

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

* Publish action events when a model change retires its actions (GHY-4722)

Three model changes remove actions without going through the Action API, so remote sync never saw them and their files stayed in the repo until a full export: turning a model into a question (explicit actions archived, implicit ones deleted), changing a model query so it no longer supports implicit actions (implicit ones deleted), and deleting a model (actions deleted through the foreign key).

Each path now publishes :event/action-update for an action it archives and :event/action-delete for one it deletes. They fire before the card changes, while the model is still in its synced collection, so remote sync marks each action for deletion and the next push removes its file. The action event schema allows a nil user, since a card hook can run with no current user.

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

* Publish model action events from the API, not the Card hooks (GHY-4722)

The Card hooks also run during a pull, when serdes loads a model or remote sync deletes one. Because the hooks published action events, a pull that deleted a model or turned it into a question wrote rows to the remote sync ledger. The instance was then dirty right after the pull, and a read-only instance failed at startup with unpublished changes.

The hooks now only change the database, as they did before. update-card! reads a model's actions before the update and publishes action-delete for each action that is gone and action-update for each action that became archived. The card DELETE endpoint reads the model's actions before the delete and publishes action-delete for each one after. The ledger again gets rows only from API-layer events.

New tests: two remote sync API tests pull another instance's delete and model-to-question change of a model with a local, untracked action, and check that the instance stays clean. A card model test checks that t2 updates and deletes of a model publish no action events. A card API test pins the DELETE endpoint events.

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

* Mark children that record their parent on the ledger with a spec flag (GHY-4722)

When a parent becomes ineligible, the cascade marks its children's ledger rows as removed. It found those rows by model_table_id only when the parent was a Table, and it hard-coded that check. A child spec can now name the ledger column that holds its parent's id with :parent-rso-key. The Field spec sets it to :model_table_id. The cascade uses that column when the child spec has the key, and the child's own foreign key otherwise.

This is a refactor with no change in behavior, so it adds no new test. The spec and events tests still pass.

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

* Add coverage tests for action changes in remote sync (GHY-4722)

Each test goes through the API and checks the repo files after a push. The tests cover: archiving and unarchiving an action; moving an action to a model outside the synced collections and back; turning sync on again for a collection that got an action while sync was off; archiving the synced collection that holds a model; and the upgrade backfill, after which a pull is refused until a push writes the action, and a later pull keeps it.

The untracked-action test helper now inserts an implicit action with its implicit_action row, so that the push can serialize it.

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

* Refresh remote sync changes when a model action changes (GHY-4722)

Remote sync now tracks model actions. But the frontend did not fetch the dirty state again after an action was created, updated, or deleted. Thus the push button stayed off until some other change, such as a rename of the model.

Add actions to the mutations that invalidate the remote sync tags. Add "action" to RemoteSyncEntityModel, because the dirty endpoint now returns it.

A dirty action entry has no parent model id, so the changes list cannot make an action URL. It linked to /404. Now the changes list shows an action as plain text with its icon, and with no link.

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

* Pass string ids to search delete on an incremental pull (GHY-4722)

An incremental pull that deletes an entity removes it from the search index with search/delete!. That function takes string ids, because the app-DB search index stores model_id as text. The pull passed the integer model ids from the remote sync ledger. H2 converts the types, but Postgres rejects the statement with "operator does not exist: text = integer", so the pull failed.

The pull now passes each id as a string, as the database module already does. The existing search-delete test pinned the integer id; it now requires the string id, and it fails on H2 without the fix.

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

* Run the Action event tests as a read-write instance (GHY-4722)

Master now skips remote sync tracking for an item that is not editable (GHY-4650). remote-sync-type defaults to read-only, where synced items are not editable. So in CI, which tests this branch merged with master, the Action event tests published their events, the handler returned without a row, and the tests failed on every app database.

The three Action event tests now set remote-sync-type to read-write, as the other event tests on master do. They pass locally both with and without the read-only guard loaded.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:57:39 -05:00
technomancy
b19ae81046 Continue legacy truncation for partitioned tables until fully switched (#82790)
* Continue to use legacy query_execution truncation until fully on partitions.

Partitions for query_execution replaces legacy truncation, but the
problem is that currently when switching over to using partitions, the
entire legacy query_execution table (with data potentially going back
as long as the retention window) is kept until its newest row drops
out of the retention window. This means it will be keeping rows around
for much longer than the retention policy intends.

The fix is to continue to use the legacy truncation for as long as the
initial legacy partition is around. We detect this by looking for the
oldest row in the oldest partition. If the intended partition for that
row is the same as the partition it's actually in, then we don't have
a need for the legacy truncation any more, because either the legacy
partition is gone, or the legacy partition doesn't contain more than a
month of rows.

* Support infinite retention days.

Oddly when retention-days is set to zero, it's implicitly converted to
infinity in the audit-app.settings namespace.
2026-09-30 15:24:51 -07:00
github-automation-metabase
d8aa372fbf Tighten ratchets (#83385)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-30 22:17:07 +00:00
Chris Truter
f35a8a0077 Untangle util and a few other cross-module cycle knots (#77954)
* Untangle util and the shallow cross-module cycle knots

Cut util's upward edges into settings and system, and break four shallow
cross-module cycle knots, so the module graph's remaining cycles are the
deep ones worth arguing about.

Raise :cross-subtree-cycle-pairs to 38 for pairs master formed while
this branch sat below it: `permissions <-> users` and
`sso.auth-identity <-> users` appeared and `metrics <-> queries` went
away, for a net +1. The updater only blesses decreases.

* Make time and CORS configuration explicit

* Address time and CORS review findings

* Preserve explicit week semantics

* Use conventional module paths

* Default frontend time config while loading

* Make date formatting config explicit

* Normalize exported time config

* Use time config for picker bounds

* Close the time-config and handler option schemas

Master now checks at load time that every mu/defn argument has a schema and that every map schema is closed.
Share one closed time-config schema from lib.schema.common, close the private copy in util.date-2, and type
the comparison-range and server handler arities that had none.

* Fix closed config boundary regressions

* Separate locale from time config

* Address remaining util review findings

* Cast the invalid start-of-week through unknown in the picker spec

The test passes a deliberately invalid value to reach the runtime check, and TypeScript rejects a direct
cast between types that don't overlap.

* Nest the Quartz secure delegates in their own task module

app-db.quartz was the only reason app-db required task. The delegates now live in task.secure-delegate,
which task exports, so app-db depends on that leaf instead.

The generated classes move to the metabase.task.secure_delegate package. Running from source compiles
them into a temp directory shared by every checkout, and a stale class under the old name would still
point at the old namespace.

* Exempt task.secure-delegate from driver tests, and cap only top-level triggering modules

* Count a nested driver-triggering module unless its parent triggers too

* One sentence per line in the driver-cap docstring

* Test which driver-triggering modules the cap counts

* Name the session middleware as the one that loads mcp.core

* Drop the test-handler hooks the explorations helper needed

Master re-enabled explorations and deleted metabase.explorations.test-util, the only caller of the dynamic
handler binding.

* Keep the test handler a private no-arg function

Master reduced it to that shape, and the arity taking routes had no caller once the explorations helper went.

* Drop the u.date arities that default to the current time

Old two-argument calls like (u.date/truncate t :day) landed on the new
time-config arity and quietly used the current time in prod, where
mu/defn does not validate. Record the signature changes in the driver
changelog.

* Pass time config straight through the date formatters

Nothing outside the tests calls these formatters, so they need neither
the JS config normalization nor the week-of-year branch, which threw in
locales like ru and uk where dayjs's ordinal returns a number.

* Late-bind the API routes in the test handler again

A static require made loading metabase.test load every API namespace,
about 400 more namespaces on a cold start.

* Name app-db, not task, in the Quartz hook comments

* Keep one copy of require-time-config in impl-common

* Move the segment endpoints to metabase.segments-rest.api

Matches the other REST modules, and the openapi path filter now picks
the file up.

* Leave the driver-test cap counting as it was

With the task.secure-delegate override the old count stays at 47, and
the new counting would stop noticing a child like queries.rest that
starts triggering driver tests on its own. Changing it belongs in its
own PR.

* Look up the week start directly and test a Monday week

A Record keyed by DayOfWeekId cannot miss, so the throw and its test go.
One picker test now uses a Monday week, which the default fallback
could not satisfy.

* Drop the date-2 tests for the removed current-time arities

* Give drivers time functions that fill in the instance time config

driver-api/date-extract, date-truncate, date-bucket, date-range,
date-comparison-range, desugar-filter-clause and negate-boolean-expression
take the old arguments, so a driver migrates by changing the namespace
rather than threading a config map through each call. lib-be/time-config
replaces the map literal at the QP call sites.

* Tighten comments, docstrings and alignment in the review fixes

* Pass the current time explicitly where tests used the removed arities

Formatting today's date as yyyy-MM-dd needs no truncation, so those
bucketing tests format the current time directly.

* Explain in the driver changelog why to prefer the driver-api time functions

* Inject MCP auth and routes into the server so it no longer depends on mcp

The session middleware took MCP UI credentials from mcp.core and OAuth
bearer tokens from oauth-server, and the routes mounted the OAuth
endpoints directly, so passing CORS in alone left server depending on
mcp. The application now supplies all of these: mcp.http-handler/options
carries the CORS, bearer and UI-credential fns, and make-routes takes
the api, auth, oauth and well-known handlers as a map. A handler built
without the credential fns does not authenticate those credentials.

* Keep the segment endpoints in metabase.segments.rest.api

The segments.rest module's namespaces now match its name, so it needs no
:ns-prefix. The openapi path filter also matches src/metabase/*/rest/api.clj,
so a change to these endpoints still runs the OpenAPI checks.

* Rebuild the dev handler when its options var is redefined

make-handler also takes the options as a var. The dev handler watches it
like the middleware vars, so re-evaluating mcp.http-handler/options
reaches a running server; the prod handler derefs it once when built.

* Tighten the MCP handler docstrings and the middleware list wrappers

* Tell drivers how to replace the removed current-time arities

Also realign the comparison-range parameters left uneven by the arity
removal.

* Fail at startup when the handler is built without credential fns

The handler options now require the CORS, OAuth bearer and MCP UI
credential entries, and make-handler validates them explicitly because
mu/defn does not check schemas in prod. A server wired without them
would otherwise start and quietly refuse those credentials.

* Keep week-of-year labels on the locale's week rules

Numbering them by the configured start of week changes what users see
(week 53 and late-December dates), so it waits for frontend review.
Extraction and formatting go back to dayjs's week and Java's w pattern.

* Address remaining review findings

Drops a to-range comment the new :week branch made stale, switches the
handler test to mt/with-dynamic-fn-redefs, tightens the oauth bearer
scope schema to NonBlankString, and notes the TimeConfig duplication
and the inert start-of-week placeholder in format-diff. Leaves TODOs
on optimize-temporal-filters' per-clause time-config calls pointing at
threading it through once, like lib.filter.desugar does.

* Pass time-config explicitly in align-literal-to-unit

#81947 landed a caller of the two-argument u.date/truncate while this
branch was open. Master's version wasn't wrong -- that arity read
start-of-week through the same implicit global setting this branch
removes -- but the explicit time-config argument is now required, so
the call needs it passed in directly.
2026-09-30 22:11:55 +00:00
Nemanja Glumac
e40d36c2b5 Consolidate collections e2e tests (#83325)
* Remove duplicated collections e2e tests

Drop tests and loop variants whose every step another test in the folder
repeats for a user of the same collection-permission class:

- collection-picker-tenants: shared-to-normal switch in the create modal,
  fully repeated by the official-collections test
- permissions: nodata variants of the collection-only and create-dashboard
  tests (same collection group as normal), and the admin pinning variant
  (pinned-overview pins as admin)
- personal-collections: run the personal sub-collection test once per
  collection-permission class instead of for every user

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

* Merge collections e2e tests that share setup

Fold tests whose setup and starting state match another test's into it,
keeping every assertion of the folded test. Moved steps use working
selectors and assert a positive before each negative.

- collections: drag to root and invalid-parameter listing into the
  items-table drag test; new collection in the current collection into the
  tree navigation test; markdown description into the sidesheet test; the
  short and long last-edited-by values into one test; bulk trash into the
  keyboard shortcuts test; sort pagination reset into pagination; both
  entity-id redirects into one test
- trash: nested restore rules into permanent delete; bulk restore and bulk
  move into one test; no-move-into-trash into drag out of trash; single
  context menu into aborted operations
- cleanup: empty and error states into the stale items flow
- pinned overview: pivot and native pins into the single-section test;
  move and duplicate into unpin
- revision history: both question history entry points into one revert
- permissions: question and dashboard moves; question, dashboard and model
  archive plus the trash page; cancel, trash, undo and visiting an
  archived sub-collection; read-only bulk actions, pins and duplicate
- personal collections: other users' sub-collections into the personal
  collection permissions test
- instance analytics: model and dashboard edit restrictions and the SQL
  and permissions leak checks into the save-to-custom-reports flow, which
  saves the copy those leaks need; API checks into the admin describe;
  dashboard analytics link into the question one
- uploads: append and replace, with and without a schema mismatch, into
  one test per dialect, successes first
- tenants: sub-collection official checks into the shared collection
  test; deactivated tenants into the tenant list test

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

* Repair collections e2e assertions that could not fail

- Replace selectors that never match: the missing edit icon, the pin
  icon that only renders while dragging, text lookups on revert buttons
  that only have an aria-label, a misspelled chevron icon, a
  case-sensitive regex against capitalised collection names, and bulk
  action checks on label spans that can never be disabled
- Assert on menus in their portal instead of inside the header
- Give negative assertions a positive precondition on the same
  container, and wait for the request that renders it
- Check the outcome after actions that used to end the test: confirm the
  trash modal in the upload error regression, actually attempt the
  rejected subcollection drag, open recents and search in the move picker,
  render the model x-ray, and verify restored and deleted items
- Seed the state a check needs: a pinned trashed model, a question in
  Custom reports, a renamed question for view-only revert checks, an
  activated token for the read-only cleanup check, and a table name for
  the invalid upload query
- Assert saved descriptions, replaced upload data, per-tenant links and
  both collections in the propagated permissions regression

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

* Drop unused setup from collections e2e specs

- Remove intercepts nothing waits on, and intercepts the upload helper
  already registers
- Remove nested restores and sign-ins that repeat the outer beforeEach,
  a tracking toggle with no snowplow assertion, and a click that only
  reopened a dropdown
- Remove the generated-nothing "no" permission group from the loops
- Remove steps the restore-from-page helper already runs
- Set view-only collection access through the API, and drop the question
  and dashboard the view-only trash test never opens
- Scope the card query intercept to the one test that checks it

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

* Fix collections e2e titles, logs and stale comments

Correct logs that contradict the assertion after them, name the checks
tests actually make, fix typos and an unclosed issue reference, and
remove commented-out code and comments about it.

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

* Move the sidebar collection expand helper to the collection helpers

The collections specs' local displaySidebarChildOf and the permissions
spec's identical exposeChildrenFor become one shared
H.displaySidebarChildOf, scoped to the navigation sidebar.

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

* Restore coverage narrowed in the collections e2e consolidation

- Check the table row count in the writable database after the rejected
  append and replace in the merged upload test
- Accept either upload status title: an earlier append in the same table
  keeps "Data added" in the grouped status for a few seconds
- Wait for the Custom reports URL before reading its collection id

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

* Check the select-all checkbox and register upload intercepts in place

- Bulk selection: assert the "Select all items" checkbox is checked while
  items are selected and unchecked after deselecting all
- uploadToExisting registers the append and replace intercepts it waits
  on instead of relying on an earlier upload in the same test

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

* Merge more collections e2e tests that share setup

- collections: the moving-collections picker states and the recents and
  search checks (metabase#45248) run in one test, recents first
- permissions: move, duplicate, archive and pin run as one test per
  curate user, archive after duplicate so the copy includes the card
- pinned overview: question, dashboard and model pins in one test, the
  zero card query check before any click-through
- trash: drag out of and into the trash in one test
- reproductions: propagation unchecked then checked (metabase#56567),
  resetting collection A in between
- uploads: the dog_breeds upload checks run at the start of the append
  and replace test
- personal collections: the edit and trash test runs for an admin and
  for the user without collection access; group permissions don't apply
  inside a personal collection

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

* Fix collections e2e failures from the consolidation

- Revision history: the header button opens the info sidesheet on Overview, so select the History tab.
- Read-only save picker: typing Enter in the search confirmed the selection and closed the picker, which made the search assertions vacuous.
- Pagination: revisiting a cached page sends no request, so don't wait for one.

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

* Assert the hover-revealed revert button exists rather than is visible

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:33:25 +02:00
Nemanja Glumac
d75a4bdfcd Consolidate models e2e tests (#83339)
* Remove duplicative models e2e tests

Each removed test's assertions are exercised by another test in the
models folder:

- model-indexes: searching an indexed value and opening its record,
  repeated step for step by the same-model record navigation test
- reproductions-1: navigating back from the query tab of a new model
  (34514), covered by the browser history navigation test
- reproductions-2: typing in the native editor of a new model (53649),
  covered by the native model creation tests in create
- models: the SQL placeholder check for no-data users on a native model,
  now asserted in the 57557 test that visits a native model as nodata

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

* Fold subset models e2e tests into their supersets

- Native model creation now goes through the browse page on its second
  entry, and checks the native editor does not overflow after many new
  lines (69722)
- Empty descriptions for columns without one (25884, 34349): asserted in
  the 29943 test, whose model now also has the text custom column
- Model metadata kept when re-running the query (45924): steps of the
  33844 update test, same model and user
- No query hash on rerun (20045): steps of the GUI model query editing
  test, same model and entry
- One card request per model load (31905): first step of the 32963
  test, same model shape

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

* Make models e2e tests that could not fail meaningful

- Saving a question based on a model: assert the new question's URL
  instead of a URL check that already held before saving
- Nested queries disabled: assert models are missing from the data
  picker's collections, next to a collection that is listed
- Variables in native models: blur the editor so the query update lands
  before asserting the tag editor stays closed
- Model info modal shown once: wait for the undo and the second
  conversion, then assert the model renders without the modal
- Remapped custom values (23449): wait for the model query the
  conversion re-runs and check it succeeds
- Reordering by the header edge (41419): scroll the table right first
  and assert the drag keeps the scroll position
- Newly created model in the data picker (26091): assert the listed item
  is a model, not only its name
- Previously acknowledged model modal (56913): set the acknowledgement
  through the API and wait for the app to read it before converting

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

* Strengthen weak models e2e assertions

- Duplicating a model: assert the copy opens in the chosen collection,
  and check a listed collection before the missing dashboard
- List view: wait for the editor to close before asserting the saved
  view; check an unused column is listed before the used one is not;
  assert the mini bar is gone inside the list view that rendered
- Query editor: assert the Count result before the missing scalar, and
  the saved native row count after the editor closes
- Model indexes: check the index through search and the toggle state,
  instead of parsing API bodies; wait for the editor to close before
  searching for an unindexed value; wait for the second record's heading
- Sorting a model with joins (30712): run the sorted query and assert
  the result table
- Moved model in the data picker (19737): assert the model shows in the
  level the picker opened before asserting the old collection is gone
- Click behavior to a custom destination (29517): wait for the target
  dashboard's query, not the source's
- Columns mapped to database fields: assert the description got a value
- Custom column metadata (35711): assert the query tab rendered before
  checking for errors
- Details-only columns (22521), model FK targets (31663), no-data model
  (20042): positive checks before the negative ones, scoped queries
- Cancelling a new model lands on the home page

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

* Merge models e2e tests that share their setup

Each merged test keeps every assertion of the tests it replaces and
saves a restore and page load per merged test.

- models: turning a question into a model changes its display, undoes,
  and skips the info modal the second time; opening a question with a
  /model URL, the /question redirect for a model, turning it back and
  duplicating it; questions from a model through filters and click
  actions, and editing its info
- models-list-view: default view saved, preserved on duplication, and
  dropped when the duplicate turns back into a question
- models-metadata: cancelling, editing and clearing GUI model metadata
- models-query-editor: the GUI query edit, cancel and table lock; the
  native query edit and cancel
- create: the no-native-permissions browse check runs before the
  no-collection-access model creation
- reproductions-2: query and metadata unsaved changes (32037); read-only
  and no-data native model access (56698, 57557); back and forward
  navigation in the model editor (56775, 55486)

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

* Drop unused setup from models e2e specs

- Remove intercepts no test waits on: search (model-indexes), card query
  (list view, 23024), metadata update (23024), schema (models data
  picker), FKs (34574), database fetch (34514), question save (26091),
  card update (29951)
- models: create the Products question only in the two tests that use
  it, named for the one that needs a model name
- Save question helper: drop the unused model id and wait for the card
  it creates
- models-metadata: move the details-only visibility test (22521) out of
  the describe whose native model and metadata it never used

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

* Fix misleading models e2e test titles and comments

- 39993 is covered by the 29943 test, which saves the column order; the
  35711 test never saves, so drop the reference and name its describe
  after the issue
- Titles of merged tests name every behavior they now cover
- Add missing issue references (19180, 38747) and fix the 37300 and
  29517 reference formats
- 29378 now lands on the model actions page; 29517 and 53556 map native
  model columns to database fields, they do not remap values
- The 22521 test checks the Columns tab; models.cy "listing" block only
  adds a model to a dashboard
- Fix a comment that called the undo toast a modal and a typo

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

* Restore models e2e coverage narrowed by consolidation

- Nested queries disabled: check through an everywhere search that the
  picker lists tables and no models; the collection precondition could
  not hold because the picker hides collections without tables
- Model metadata on query rerun (45924): run on a fresh model, as before
  the merge, instead of one with saved metadata edits
- List view to table when turned back into a question: turn the
  original model back, not its duplicate, and assert the table renders
  first
- Reordering by the header edge (41419): assert the column order before
  and after the drag, so the scroll check cannot pass on a no-op drag

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

* Delete skipped models e2e tests

- model-indexes: record outside the first 2000 rows
- models-metadata: FK drills on a native model; the dashboard FK drill
  test on the same model keeps running

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

* Check a deleted model index stops matching search in e2e

The create, delete and re-create index test now searches after the
delete and expects no results, and cites the model index cache
invalidation fix it guards.

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

* Merge more models e2e tests that share their setup

- 19737: drop the root-to-personal move test; the custom collection
  test ends by moving the model to the personal collection and finding
  it in the picker without refreshing
- 23421: check the empty and hidden columns models in one test
- 33844: create and update a model in one test, with fresh aliases
  before the update
- models-metadata: the native model is kept in sync with a cancelled
  query change, edited as the first metadata revision, reverted to it,
  and inherited by a question
- models-list-view: filter and drag columns inside the customize test,
  restoring the default columns before customizing

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

* Fix models e2e tests failing after consolidation

- Model indexes: drop the post-delete search check; deleting a model
  index leaves its values in the search index, so the value still
  matches
- Moved model in the data picker (19737): open the personal collection
  to find the model, and check First collection is gone from Our
  analytics; the picker opens on Databases
- Remapped custom values (23449): the conversion re-runs the query
  through /api/dataset, not the card query endpoint
- Native model metadata: close the info sidesheet after reverting so it
  does not cover the New button

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:28:25 +02:00
Nemanja Glumac
9b9fe485b6 Consolidate filters reproductions e2e tests (#83317)
* Move dashboard filter reproduction specs to dashboard-filters-2

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

* Move filters reproduction spec into filters

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

* Leave e2e timings to the scheduled update

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

* Fold filter reproduction tests that share setup

Merge tests with identical setup into one run each, keeping every original
interaction sequence: add-values and second-filter edits on Category, value
search and Contains popover width in the notebook, back-navigation to custom
expressions and non-boolean expression rejection, field value search on People,
and the datetime picker for custom expressions and coerced columns.

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

* Repair filter reproduction assertions that could not fail

Use a product that is created on a Monday and assert row counts for the
excluded weekday filter, wait for the re-run before checking time filter
results, assert the no-match message for a long column name fits its dropdown,
and give negative checks a positive precondition.

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

* Move the dashboard filter search reproduction to dashboard-filters-2

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

* Fold the exclude granularity reproduction into the Orders header filter test

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

* Drop the layout-dependent dropdown check and restore the People row limit

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

* Move native query filter widget reproductions to native-filters

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

* Close the numeric filter popover with Escape from inside it

The value input is blurred earlier, so a page-level Escape never reaches the
popover's key handler.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:25:17 +02:00
github-automation-metabase
62606a9353 Tighten ratchets (#83376)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-30 21:15:10 +00:00
John Swanson
3951ceeda1 Don't reject edits to a dashboard question that sits on other dashboards (#83277)
UXW-5284 / ENG-10948 (#82237, #82617): the card-update guard threw
"Can't move question into dashboard" whenever the PUT body contained a
`dashboard_id`, not only when it changed. The frontend re-sends the whole
card on every save, so a Dashboard Question that also appears on other
dashboards (a state older code paths allowed) could not be edited at all.

Only check the rule when `dashboard_id` will actually change to a
dashboard. A genuine move into a dashboard while the card is on other
dashboards is still rejected, and `delete_old_dashcards` still clears
those placements first. The `:in_dashboards` hydration now only runs on
a real move, so ordinary saves do one query fewer.

Also make `:dashboard_count` include series placements, so the move
modal prompts for a card used only as an extra series instead of letting
the save fail with the same error.

Out of scope: moving a dashboard question from one dashboard to another
while it is also on a third dashboard still throws even with
`delete_old_dashcards`, which only clears the old home dashboard.
2026-09-30 14:13:38 -07:00
Chris Truter
b87e6058b0 Exempt test namespaces from the dynamic-var ratchet (#83348) 2026-09-30 23:09:46 +02:00
Denis Berezin
20056fab05 Keep thread spacing consistent in the exploration sidebar below the Summary (#83257)
* Keep thread spacing consistent in the exploration sidebar below the Summary

* Review fixes
2026-10-01 02:08:53 +05:00
Chris Truter
4aa3c15d49 Bump sqlglot 28.6.0 -> 30.20.0 (#83343)
* Bump sqlglot 28.6.0 -> 30.20.0

Picks up ~2 years of parser/optimizer/dialect fixes across the drivers we
route through sqlglot, plus a real quoted-function-name parser crash on
ClickHouse that landed between 28.6.0 and 30.20.0.

* Fix source-swap test for sqlglot's new comment escaping

sqlglot >=30.17 escapes a comment-terminator-like sequence inside comment
content instead of the 28.6.0 behavior, which duplicated the comment and
could produce SQL that corrupts on a second round-trip. Strengthen the
assertion to check the bracket marker stays inside a comment rather than
matching the old literal byte sequence.
2026-09-30 21:07:44 +00:00
Jacob Joseph
14550d36bd [docs] Modify Docker build command in developers guide (#83151)
* Modify Docker build command in developers guide

Updated the Docker build command to include a VERSION argument and clarified the versioning instructions. VERSION is a required build-arg.

* Correct version format in Docker build instructions

Updated version format in build instructions to remove patch
2026-09-30 20:50:02 +00:00
Egor Grushin
68caadb85f Use font-size tokens for Button label (#83334) 2026-09-30 21:47:13 +01:00
Eric Normand
0af6bce7a7 GHY-4586: Bind values in user_key_value/db.clj (9 non-id) (#83159)
* Bind the user key-value module's request values (GHY-4586)

namespace and k reach every read, update and delete in user_key_value/db.clj from request path parameters. They are now written as [:auto/param ...], so they bind as parameters. user-id is an id that Malli already constrains to an integer, so it is coerced with (long user-id) instead of marked. The insert and the update changes map are left bare, because a written value is not a where-clause value.

The namespace joins :config-in-ns for :metabase/unsafe-app-db-query, so the lint holds it at zero. Before this change it reported 14 findings (9 non-id).

The new API and db tests pass both before and after this change: HoneySQL already binds a plain string, so a SQL-looking key never matched another row. The tests pin that behavior; the lint is what the change moves.

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

* Test that user-key-value namespace and key are bound with the schema off

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:30:03 +00:00
Timofey Kachalov
bbf64b92ca Fix data apps schema query and skill issues (#82978) 2026-10-01 00:23:39 +04:00
Eric Normand
76934b3c84 GHY-3856: Surface the underlying sync failure cause in the UI, not just "Sync failed" (#83042)
* Show why an initial sync aborted, not only that it failed

When the initial sync of a database aborts, for example because Athena credentials lack glue:GetDatabases, the status panel said only Sync failed. The cause was in the server log only.

A new column, metabase_database.initial_sync_error, now holds the message of the error that aborted the initial sync. A later sync that completes clears it. The API returns the column only to users who can edit the database, because the message can name connection details. Serialization skips it.

The sync status panel shows the message after Sync failed.

Fixes GHY-3856.

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

* Show the initial sync failure cause on the database admin page

The Connection and sync section of the database admin page now shows an error alert, titled Sync failed, that holds initial_sync_error when the initial sync of the database aborted.

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

* Record the real field-sync error, not a Malli validation error

In dev and test, a failed field sync recorded a schema-validation message as initial_sync_error in place of the SQL error. Two schemas rejected valid failure values.

sync-fields! returns the exception that with-error-handling catches, but its output schema allowed only the result map. The schema now also accepts a Throwable.

filtered-stacktrace added a nil frame when a stack trace had no Metabase frame. For example, an empty trace became [nil]. The failure task_details schema requires strings, so recording the failed sync-fields task threw. The function now leaves the marked frame out when there is no Metabase frame.

Production only logs a warning for these schema violations, but it stored the nil frame in task_history.

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

* Expect initial_sync_error in the table API response for admins

PUT /api/table/:id returns the table with its database. Crowberto can write the database, so that database now includes initial_sync_error. update-table-test lists the keys that only admins get, and it did not list the new key, so it failed. It now lists the key.

The sync-failure API test checked that the cause appeared somewhere in GET /api/database/:id. It now checks initial_sync_error, the field that the admin page reads. When the old check failed, it printed the whole response walked as a tree.

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

* Render aborted status cards in the DatabaseStatusLarge tests

StatusLarge shows a card only while the panel is active or the item is in progress. A card that mounts already aborted is hidden, so the two new aborted-sync tests found no description. The tests now pass isActive, as a panel that is open does.

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

* Pass a query map in the model-chart case of document-tools-errors-test

Two master changes merged cleanly but conflict. The model-chart case of document-tools-errors-test passes :query as an empty string. document_construct_model_chart now takes an MBQL query map for :query (construct-tools/LLMQueryArgument). So input validation rejected the string before the stubbed construct-notebook-query-tool could throw boom.

The case now passes the same empty query map as document-construct-model-chart-new-chart-types-test.

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

* Keep the create-database auto-sync out of create-db-default-schedule-test

POST /api/database publishes a create event, and that event starts a sync of the new database on the quick-task pool. The test reads the row back at once and expects dbms_version to still have its default nil. When the pool is idle, the sync reaches its dbms-version step first and writes {:version "1.0"} from the test driver. So the test fails. The Clickhouse driver job hit this on the full run and on its single-test rerun.

The test checks default column values and the Quartz sync triggers, and neither needs the sync. It now stubs quick-task/submit-task! around the create request. With the sync forced to run at once, the old test fails and the new one passes.

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

* Nest the sync error message check inside the isError branch

* Fix formatting of creator_id field in schema

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:00:13 -05:00
Nathan Voxland
21a74d4d87 Improve PDF/PNG link handling (#82755) 2026-09-30 14:52:49 -05:00
Eric Normand
f765a1d341 GHY-4587: Bind values in public_sharing/db.clj (8 non-id) (#83142)
* Bind the uuid prefix in public-sharing lookups

Each of the eight prefix lookups in metabase.public-sharing.db now writes the prefix as [:auto/param prefix], so it is bound as a SQL parameter. The prefix is derived from a public uuid that arrives in a request path.

The namespace joins :config-in-ns for :metabase/unsafe-app-db-query, so an unmarked value added here later fails the lint. Before this change the lint reported 4 findings; it did not see the 4 calls that select [:model/X :id :public_uuid], but those are marked too.

The column has no deftransforms, so marking it in kv-arg position does not bypass a transform. The compiled SQL is unchanged: PUBLIC_UUID_PREFIX = ?.

Tests: an API-level test resolves a shared card, dashboard, action and document through GET /api/public/<model>/:uuid and public-uuid->model. A contract test for the db fns checks that a matching prefix finds the row, a SQL-shaped prefix matches nothing, and a non-string prefix is rejected.

Part of GHY-4587.

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

* Test that the prefix marker holds without the schema

The existing tests pass with or without the marker, because every caller passes a string and a string already binds as a parameter. This test turns the schema off and passes an operator-shaped prefix to each of the eight lookups. Without the marker, [:not= "zzzzzzzz"] compiles into the WHERE and matches every shared row. With the marker, the compile step rejects it.

* Wait for the expression editor to take focus before pressing Escape

The custom expression editor is a lazily loaded CodeMirror chunk that focuses itself with autoFocus once it mounts. The popover closes on Escape only when focus is inside it. The test pressed Escape right after opening the editor, so on a slow runner the key reached the page before the editor held focus, and the popover stayed open.

The test now waits for .cm-editor to have the cm-focused class, the same wait the CodeMirror type helper uses. The test failed the same way on master at d8488ef, so this branch did not cause it.

* Move the expression editor Escape fix to its own PR

This reverts c6b10bbc68. The e2e test fix is unrelated to GHY-4587 and now lives in #83165.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:42:56 +00:00
Eric Normand
7e45872ff0 GHY-4650: Remote Sync: creating a public link doesn't mark the dashboard as changed (#83018)
* Mark cards and dashboards dirty for remote sync when a public link changes

Remote sync serializes public_uuid, but creating or revoking a public link did not mark the card or dashboard as changed. The next push reported no changes, so read-only instances never got the link, and a pull overwrote a link made locally.

Remote sync now handles the card and dashboard public-link-created and public-link-deleted events. These events carry only :object-id, so the spec handler loads the instance when the payload has no :object.

The create endpoints published the event before they wrote public_uuid. The handler then hashed the old content, matched the pushed hash, and resolved the status to synced. The endpoints now publish after the write, as the delete endpoints already do.

Fixes GHY-4650.

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

* Attach the card or dashboard to its public-link events

The four public-link events now carry the entity as :object, the same as the create, update, and delete events. The endpoints already hold the entity: the create endpoints get it from read-check, and the delete endpoints now fetch it in place of the check-exists? query. So the remote sync handler does not read the entity again.

The event schemas now require :object. They keep :object-id, which the audit log reads.

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

* Revert "Attach the card or dashboard to its public-link events"

This reverts commit 53c1e6119b. The endpoints built :object by merging their changes into an entity they read before the write. That copy can miss concurrent writes, and it repeats the update logic. The remote sync handler reads the entity by :object-id again when the event has no :object.

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

* Stop the metrics browse truncation test from racing its tooltip

The test hovers the description and expects /should be truncated/ to match 2 elements: the description cell and its tooltip. UXW-4935 later gave the metric a long name that also contains that phrase, so the settled page has 3 matches. The test passed only when Cypress counted the name and cell before the tooltip opened, and it failed when the tooltip opened first.

Match only the description text. The count is now 1 until the tooltip opens and 2 after it, so the assertion waits for the tooltip.

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

* Stop the data studio error-handling test from racing the table switch

The test switched from Many Data Types to Orders inside the page and then clicked the Fields tab. Until the Orders metadata loads, the table section keeps rendering Many Data Types, because RTK Query keeps the previous data while it refetches. The Fields tab builds its URL from that stale table, so the click navigated back to Many Data Types and Quantity never appeared.

Use a fresh visit to the Sample Database before picking Orders, as the Undos test in the same file already does for the same race.

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

* Retry the custom column help text position check until the popover settles

verifyHelptextPosition measured the help text popover once, inside .then(), so Cypress did not retry it. The popover moves to the cursor after the cursor moves. When Cypress measured first, it saw the popover still under the previous argument and failed.

Measure both positions in a .should() callback, which Cypress retries until the popover reaches the cursor.

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

* Allow the public-link event types in the remote sync spec test

all-specs-have-valid-events-test lists the event types that a spec may declare. The Card and Dashboard specs now also declare :public-link-created and :public-link-deleted, so that remote sync marks them dirty when a public link changes. Add both types to the allowed set.

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

* Wait for the config-file sync to finish in the cruft tables test

test-cruft-tables! waited 5 seconds for the sync future, ignored a timeout, and then counted the synced tables. On a slow app database the sync was still running, so the test counted 5 of the 8 tables and failed.

Wait up to 60 seconds, and assert that the sync finished, so that a slow sync fails with a clear message and not with a wrong count.

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

* Move the two Cypress race fixes to their own PR

The metrics browse and custom column help text fixes are not part of GHY-4650. They move to #83346 (GHY-4726), so that they do not ride along with this fix or its backports. The other two CI fixes already left this branch when master was merged in, because master had fixed or removed those tests.

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

* Ignore public link changes on read-only remote sync instances

Admins on a read-only instance can still create and revoke public links on synced cards and dashboards. Marking those items dirty left a change that can never be pushed: the next pull fails without force, auto-import falls back to a full reload, and startup throws on unpublished changes.

The handler now skips public-link events when model-editable? is false for the item, which is the case for synced items on a read-only instance. The link stays local, as before this change, and the next pull resets it.

The read-write public-link tests now set remote-sync-type to :read-write, because the setting defaults to :read-only.

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

* Do not revoke the public link of an archived card

The card DELETE public_link endpoint did not filter on archived, unlike the dashboard endpoint. Revoking the link of a trashed card published a public-link event for an archived card, which remote sync records as a delete. When the card was already pushed, that inserted a delete row with no file path and forced a full export on the next push.

The endpoint now returns 404 for an archived card, as the dashboard endpoint does. The admin list of public cards already leaves out archived cards.

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

* Save public link changes in one transaction with their events

The public link endpoints write public_uuid and then publish the public-link event, and publish-event! rethrows handler errors. When the remote sync handler threw, the client got a 500 and the tracking row rolled back, but the link change stayed saved. A retry then found the link already created, or already gone, and fired no event, so the item was never marked dirty.

The create and delete endpoints for cards and dashboards now write the link and publish the event in one transaction. A handler error rolls back the link change too, and a retry does the whole change again.

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

* List the public-link topics explicitly

The read-only guard matched public-link topics by a substring of the topic name. It now checks an explicit set of the four topics, so a search for any of them finds the guard.

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

* Carry the card or dashboard in its public-link events

The public-link events carried only :object-id, so the remote sync handler read the entity itself. They now carry :object, like the other events it handles. Each endpoint reads the row again after its write, inside the transaction, so the object is fresh and the endpoint repeats none of the update logic. The handler no longer needs its :object-id fallback.

The event schemas now require :object. They keep :object-id, which the audit log reads.

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

* Track no remote sync changes to synced items on read-only instances

The read-only guard covered only public-link events. It now covers every event that the spec handler receives: when model-editable? is false for the item, which means a synced item on a read-only instance, the handler writes no tracking row. A read-only instance never pushes, so a tracked change there could only block the next pull and startup. Imports load through serdes without these events, so pulls are not affected. On a read-write instance, model-editable? is always true and tracking is unchanged.

remote-sync-type defaults to :read-only, so the tracking tests in the events, content hash, glossary, snippets, and transforms namespaces now set :read-write in each test. A fixture is not allowed for settings.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:05:54 +00:00
Nathan Voxland
d6cefe3594 Replace storing EDN with JSON in explorations (#81745) 2026-09-30 13:51:19 -05:00
Timofey Kachalov
b26dbd5538 Fix serialization import for metrics with dimensions (#83017) 2026-09-30 22:44:59 +04:00
Timofey Kachalov
c15980048e Added a metabase-data-app-migrate skill that helps upgrading data apps between versions (#82895) 2026-09-30 22:39:12 +04:00
Braden Shepherdson
b4a88f04b4 Rehab the :card_schema read-time upgrade (#82186)
Cards have a `:card_schema` field that is intended to indicate which "vintage" the `:dataset_query`, `:results_metadata`, and other query-related fields are from. On read, an outdated card passes through the upgrade steps for each schema version up to the latest.

However, this never really worked, for two reasons.

`t2/update!` reads a baseline row and diffs it against the incoming changes. The after-select `:card_schema` upgrades mutated that baseline, so the diff compared the incoming values against an upgraded baseline, instead of what is really stored! Now the schema upgrades are only applied to proper selects, and not the baseline read inside `t2/update!`.

This had the additional side effect that `:card_schema` was essentially frozen; it would never be changed because both sides of `t2/update!`'s diff always had the latest version.

The second problem was that the schema upgrades require a certain set of fields in order to run correctly. The `:result_metadata` can't be updated without the `:dataset_query`, and similarly for the `:dimensions`, which also need to know the `:type`. This set of mutually-required fields is now properly enforced: selecting any of the set requires selecting all of them, so that the `:card_schema` upgrades can run correctly.

Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-09-30 18:19:42 +00:00
appleby
ecc0eb1612 Default new document cards to the document's collection on update (#83154) 2026-09-30 10:35:09 -07:00
Eric Normand
e20e936c96 GHY-4588: Bind values in usage_metadata/db.clj (7 non-id) (#83140)
* Bind values in usage_metadata/db.clj (GHY-4588)

Mark the non-id values in metabase.usage-metadata.db with [:auto/param ...] so they bind as SQL parameters: started-at, started-before, bucket-date, bucket-start and bucket-end. Coerce the ids source-id and table-id with long.

The private grouped-rollup-where helper builds the same clauses as grouped-profile-rows. The lint does not see it, because it is not a query call, but it gets the same treatment so the two stay alike.

Add the namespace to :config-in-ns so :metabase/unsafe-app-db-query holds it at zero. Before this change the lint reported 16 findings for the namespace; now it reports none.

The new db-test pins that the marked dates and coerced ids still select the right rows for the deletes, the grouped rollup queries, and unarchived-segments.

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

* Compare the summed count by value in usage-metadata db-test

On MySQL and MariaDB, SUM returns a DECIMAL, so :total_count comes back as 2M. The =? assertion compared it with 2 and failed, although the query selected the correct rows. The assertion now uses == so that it compares the number and not the type.

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

* Bind the collection values in usage_metadata/db.clj

Master (#83163) now lints a model written with columns, such as [:model/Table :id :db_id]. That finds five more unmarked values in this namespace: query-hashes, field-ids, table-ids (twice), and card-ids.

The id sets are coerced with (mapv long ids). An empty set still becomes [:in []], which Toucan rewrites to false.

query-hashes holds byte arrays, not ids, so it is marked as [:in [:auto/param query-hashes]]. It binds as IN (?, ?). A marker hides an empty collection from the Toucan rewrite, so the empty case would compile to IN (), which is invalid. The schema now requires at least one hash. The only caller passes chunks from partition-all, which are never empty.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:31:19 +00:00
Eric Normand
aee940ec0d QUE2-862: [Flaky Test]: health-check-database-test (#83284)
QUE2-862: Make the health-check timeout test deterministic

The "failures for timeout" case in health-check-database-test set db-connection-timeout-ms to -1 and expected the check to time out. deref with a timeout of zero or less checks the future one time and returns the result if the task is already complete. On H2 the connection check is fast, so when the caller thread was descheduled between future-call and deref, the check completed first and the metrics recorded healthy = 1.

The test now sets a positive 1 ms timeout and redefines driver/can-connect? to sleep 500 ms. The timeout no longer depends on thread scheduling. This is a test-only change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:19:33 +00:00
Andrei Simionescu
912b2ba3b9 Support Model Garden endpoints on the Google provider (#82276)
A Google connection can now name a Model Garden endpoint, which then shows up in the model picker and is called over Chat Completions. A Chat Completions stream that fails partway now ends in an error instead of looking complete.
2026-09-30 18:18:45 +01:00
Eric Normand
d90b5c6b13 GHY-4590: Bind values in auth_identity/db.clj (7 non-id) (#83141)
* Bind provider in auth_identity/db.clj queries

The auth-identity lookups passed provider to Toucan as a bare kv-arg. Malli checks it is a string, but only in dev and test. In prod a vector such as [:like "%"] reached Toucan, which read it as an operator form, so the query became provider LIKE '%' and matched every provider. delete-auth-identities! then deleted every identity the user had.

provider is now [:auto/param provider] in the five lookups and the delete. The value guard refuses an operator form inside a marker. The user ids in those queries, and in delete-sessions-for-user! and user-active?, are coerced with long.

insert-session! keeps session-key and expires-at unmarked. They are written values, and a column :in transform would store a marker as data.

The namespace is added to the unsafe-app-db-query lint under :config-in-ns. It reports zero findings.

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

* Bind the expiry and SAML values that insert-session! writes

In prod, where Malli does not validate, an operator form passed as a SAML value or an expiry compiled into the INSERT. [:upper "abc"] became UPPER(?) and stored ABC. expires_at and the three saml columns are now marked, so the value guard refuses an operator form. The user id and the two nullable identity ids are coerced with long.

The session id and session key stay unmarked. The before-insert hook of :model/Session refuses a UUID id and hashes the key, and both checks need the plain string. The key never reaches SQL, only its hash.

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

* Coerce the user id in user-login-columns

Master commit e713f3471e taught the unmarked-value lint to check a model written with columns, such as [:model/User :id :is_active]. The lint then reported the bare user-id in user-login-columns, and the Clj-Kondo job failed on the merge with master. The id is now coerced with long, like the other ids in this namespace.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:43:03 -05:00
Eric Normand
90e5ff9328 GHY-4589: Bind values in settings/db.clj (7 non-id) (#83161)
* Bind the setting key as a value in settings/db.clj (GHY-4589)

The four queries in metabase.settings.db that filter on the setting key now mark it with [:auto/param ...], so it is bound as a SQL parameter. The marker holds the key's name. The schema accepts a keyword, and a bare keyword compiles to a column identifier, while a bound keyword cannot be converted by the JDBC driver.

Written values (the setting value and value-with-aad on insert and update) stay unmarked, as in the sso conversion. The before-insert and before-update hooks read and encrypt them.

metabase.settings.db is added to :config-in-ns so :metabase/unsafe-app-db-query holds it at zero findings.

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

* Add a contract test for binding the setting key in settings/db.clj (GHY-4589)

The test covers setting-value, update-setting-value!, update-raw-setting-row! and delete-setting!. A keyword key finds its row, and a key that looks like SQL matches nothing.

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

* Store a keyword setting key by its name in the settings/db.clj inserts (GHY-4589)

insert-setting! and insert-raw-setting-row! accept a keyword key by their schema, but wrote it as-is. HoneySQL formats a keyword in VALUES as an identifier, so H2 failed with Column "TEST-SETTING-1" not found. This is the same failure the branch fixed for the filter functions.

Both inserts now write (name setting-key). The key is a written value, not a filter, so it needs no marker: a string is always bound. The contract test now calls both inserts with a keyword key.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:42:22 -05:00
Jakub Chodorowicz
fb70ee539f GDGT-3201 fix flakiness in dev server test (#83308) 2026-09-30 17:49:53 +02:00
Jeff Bruemmer
2e51e9ded1 docs - security best practices (#83260) 2026-09-30 11:00:23 -04:00
Paolo Rascunà
e851975806 Stop Metabot emitting filters legacy MBQL can't normalize (#81947)
The frontend opens a Metabot question from a `/question#` link holding legacy
MBQL, which can't decode a string temporal unit on `absolute-datetime`, `during`
or `temporal-extract`. Repair was producing exactly that shape from plain
`between` date bounds, so the question page returned a 400 in dev, or in prod
silently dropped the filter.

- Stop rewriting plain date bounds into `absolute-datetime`.
- Move a bucket off a temporal literal onto the compared column where that keeps
  the meaning (filters and expressions only). Truncate the literal to the unit,
  and skip `year` on custom columns and sub-day units on date literals.
- Rewrite `temporal-extract` to its `get-*` equivalent.
- Return a retryable agent error, with rewrite advice, for temporal clauses the
  link can't carry.
- Check bare temporal literals against the column they're compared to, and reject
  a bucket on a non-temporal field or custom column.
- Make the JSON round-trip check catch a clause that was silently deleted.
2026-09-30 15:25:01 +02:00
github-automation-metabase
40f2f01f17 Auto-update documentation for master (#83150)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-30 09:14:41 -04:00
Andrei Simionescu
c7f496ed85 Reindex search after the site locale change commits (#83303) 2026-09-30 12:37:38 +00:00
Andrei Simionescu
2b50816eef Fix Metabot failing to read metrics that have a breakout (#82951) 2026-09-30 12:57:08 +01:00
Andrei Simionescu
120f637fe8 Keep the schedule day in Metabot's Slack alerts and subscriptions (#83043) 2026-09-30 12:55:31 +01:00
Will Fry
8bfda47885 GDGT-3271 Assert toast isn't left paused after clicking a link inside it (#83256)
Assert toast isn't left paused after clicking a link inside it

Closes GDGT-3271

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:15:18 +01:00
Sébastien
4a48ea5b86 Increase e2e restore endpoint timeout (#82564) 2026-09-30 08:18:24 +02:00
Nemanja Glumac
7fd3b8d589 Consolidate custom column e2e tests (#83253)
* Remove duplicative custom column e2e tests

Each removed test's assertions are exercised by another test in the
custom-column folder:

- custom-column-1: basic CC (13241), "existing column name", CC after
  named aggregation (13857), Tab indent, snippet arguments
- custom-column-2: CASE date type (13122), lower( help header, help text
  click (17548), unknown column, substring validation, exit on empty
  editor via data step
- custom-column-3: resolve aggregation + 1, same-name aggregations
- cc-typing-suggestion: collapse help text
- cc-shortcuts-combine: default separator

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

* Fold subset custom column e2e tests into their supersets

- Tab-accepting a suggestion keeps focus in the editor: asserted in the
  49882-4 repro, which already Tab-accepts a completion
- Accepting a function suggestion shows its help text: asserted in the
  15891 help text test right after accepting `round`
- Right-clicking the help text keeps it open (41305): asserted in the
  52711 help text click test, same editor and table

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

* Make custom column e2e tests that could not fail meaningful

- Date functions: assert the query succeeds and every function column
  has a value. Drop the 2-arg convertTimezone column; Created At has no
  timezone on QA Postgres12, so that variant always fails the query.
- X-ray on a custom column breakout (16680): wait for the automagic
  dashboard and assert it renders, instead of stopping at the click.
- Closing the summarize popover: assert against the real popover
  selector, with a visible-popover precondition.
- Arithmetic operator suggestions: assert against completion labels
  instead of a test id that no longer exists.
- Autocomplete before a bracket identifier (50925): run the
  "followed by bracket identifier" case instead of repeating the
  whitespace case.
- Snippet arguments after validation (55164): wait past the validation
  debounce while the snippet is active, as the regression requires.

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

* Strengthen weak custom column e2e assertions

- Custom column after removing a filter (14193): wait for the re-run and
  assert the column is in its result, instead of matching the header
  rendered before the change.
- Custom column over aggregations (12762): assert MegaTotal equals the
  sum of both aggregations in every row.
- Not-equal filter on a custom column (14843): assert no returned row
  has the filtered value.
- Segments in case (24922): assert each row's value matches the segment
  condition.
- Arity error (53682): edit the name field, the crash path of the bug.
- Compatible case types (57674): prove the type error can render, then
  assert it clears and Done is enabled.
- Follow-up filters on a single-column custom column (25189): wait for
  each filtered query and assert the pills.
- Unfocused suggestions (31643): assert suggestions show before blur.
- Aggregation references: assert Derived equals Custom Sum.

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

* Merge custom column e2e tests that share their setup

Each merged test keeps every assertion of the tests it replaces and
saves a restore and page load per merged test. Where a merged test
pairs a negative assertion with a positive one in the same context,
the positive now runs first.

- custom-column-1: breakout bucketing for numeric, date and coordinate
  custom columns; format button hidden when empty and blur not
  formatting (into the format button test); invalid expression
  formatting, saving and validation
- custom-column-2: date type through a field and COALESCE; cursor moves
  and resize keeping the formula (15734, 16127); help text after a field
  reference and outside a function; Escape on a non-empty then empty
  editor; keep editing then discard
- custom-column-3: splitPart index error before a valid split; function
  browser filtering, empty results and unsupported functions; inserting
  a no-argument function
- reproductions-2: long suggestion names with and without spaces
  (55984); both number-with-string concats (12938), now with value
  checks
- cc-typing-suggestion: templates with and without arguments

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

* Assert custom column e2e results through the UI

Replace manual parsing of /api/dataset responses with table assertions,
and drop value checks already covered elsewhere:

- Aggregation references (Derived) and filter removal after
  aggregation (14193): assertTableData with the expected values
- Number-with-string concat (12938): narrow the table to Title and the
  two custom columns and assertTableData the first rows
- Segments in case (24922): assert both case branches render
- Not-equal filter (14843): assert the first person renders before
  asserting Rye does not
- Fields from aggregated data (12762) and date functions: keep only the
  query-error check; later-stage values are asserted by the nested
  aggregation tests in custom-column-3

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

* Drop unused setup from custom column e2e specs

- custom-column-2: only the date functions test needs QA Postgres12;
  the other data type tests run on the sample database without the
  postgres restore or the @external tag
- custom-column-3: drop the Orders notebook visit every aggregation test
  replaced with its own question; move the same-name aggregations test
  out of the describe whose saved question it never used
- cc-fields: drop the Orders notebook visit neither test uses
- cc-boolean-functions: drop the second sign-in in the dashboards block

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

* Fix misleading custom column e2e test titles and logs

- 31964 repro carried the 54722 title; name it after what it tests
- Rename the follow-up stage "breakout" test, which adds an
  aggregation, not a breakout
- Fix metabse/metaabase issue reference typos
- Fix logs that named the wrong function or contradicted the assertion
  that follows

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

* Deselect ID in the number-with-string concat e2e test

"Select all" on a fully selected column list keeps the first column,
so ID stayed in the table next to Title and the two custom columns.

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

* Restore custom column e2e coverage lost in consolidation

- Split the date type test back into a same-table Birth Date column on
  People and COALESCE over an implicit join on Orders; the merged
  version only checked a joined field
- Check the editor accepts convertTimezone without the source timezone
  argument, alongside the date functions
- Assert unknown column and substring argument errors in the editor as
  steps of the invalid expression test

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

* Restore custom column e2e paths narrowed by merges

- Format button test: pressing Tab and Shift+Tab after leaving the
  editor does not format the expression, and typing afterwards appends
  at the end
- Combine shortcut: check the empty default separator on a plain query,
  not only on an aggregated one
- Aggregation reference: keep typing + 1 after picking the completion
  and check the formatted value and Done, then reference the
  aggregation on its own
- Exiting the editor: press Escape and click the data step on an
  untouched editor before the existing flows

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

* Cover named aggregations in the custom expression after aggregation e2e

The saved legacy question with a follow-up custom column now also
aggregates a count named through aggregation-options and references it
from a field-literal expression, asserting values both on load and
after the filter change.

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

* Check operator suggestions in the list the precondition uses

The operator check read CodeMirror's native completion labels, which
the editor hides, so it could never find anything. It now looks for
operator options among the suggestion options that showed the field.

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

* Click Done without force when adding custom columns in e2e

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

* Tighten custom column e2e assertions added in consolidation

- Scope the X-ray intercept to the comparison dashboard request
- Anchor the unsupported-function check on the empty-results message
- Assert visibility instead of existence for moved and new checks

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

* Wait for the custom expression editor focus before pressing Escape

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

* Reopen the custom expression editor from the expression step

Closing an empty editor leaves the custom column step in the notebook,
which replaces the data step's Custom column button.

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

* Assert long custom expression suggestions exist rather than are visible

The suggestion list scrolls vertically, so the second long suggestion
can be rendered below the visible area.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:04:07 +02:00
Nemanja Glumac
94243e0217 Consolidate filters e2e tests (#83282)
* Remove duplicated filter e2e tests

Drop tests whose every step is repeated by another test with the same setup:
boolean apply (covered by change/remove boolean), the duplicate-titled negative
filter conversion (covered by its sibling, which also asserts the formula), and
Tab focus retention (covered by the Tab suggestion test).

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

* Fold subset filter e2e tests into their supersets

Merge tests that share setup and whose steps are a prefix or a subset of
another test, keeping each merged step on the same starting state and inputs.
Saves page loads across the filters specs.

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

* Repair filter e2e assertions that could not fail

Scope assertions to the results, wait for re-runs before checking, give
negative checks a positive precondition, use exact matches for operator
lists, and assert bigint values in the exported CSV via a new
assertResponseBody option on downloadAndAssert.

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

* Compute the reference time per relative-datetime test

A spec-level timestamp drifts from the real clock as earlier tests run, so
minute-granularity windows could drop rows on slow runs or retries.

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

* Fix filter e2e titles, tags and stale setup

Use the @external tag like the sibling bigint tests, correct a misleading
Tab comment, name both filtered columns in the view test titles, and drop
an unused filter popover.

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

* Assert join filter results and restore picker chevron check

Check the order count left by the 'is not Gizmo' join filter, and keep the
no-back-chevron check on the column header date picker with a positive
precondition.

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

* Restore joined-table and search-placeholder filter assertions

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:00:59 +02:00
Romeo Van Snick
161f56350b Drop the empty script emitted for the styles entry (#82548)
* Drop the empty script emitted for the styles entry

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

* Move the styles-entry plugin beside the other build plugins

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

* Narrow the src attribute before matching it

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 15:44:41 +10:00
Romeo Van Snick
3786e73df0 Resolve the mocked floating-ui position synchronously (#83064) 2026-09-30 14:22:24 +10:00
Romeo Van Snick
b0de2cb678 Dissolve the app-tier cycle clusters (#81289)
* Split the SDK environment spec per artifact

* Move the wizard embed settings types next to the runtime options they extend

* Move the app shell visibility selectors into app nav
2026-09-30 12:13:44 +10:00
Andrei Chernykh
b72688a01d Migrate CollapseSection off Emotion (#83160)
* Migrate CollapseSection off Emotion

* Space the toggle icon with a gap instead of margins
2026-09-29 16:45:21 -07:00
Andrei Chernykh
51ec37c506 Migrate Pinned items off Emotion (#83131)
* Migrate the pin drop zone off Emotion

* Draw the empty pin zone as a dashed drop box

* Keep the pin drop zone geometry in its CSS module

* Drop a comment the reviewer found redundant
2026-09-29 16:44:42 -07:00
Ryan Laurie
c0fdb3dd07 Consolidate admin e2e tests (#82882)
* consolidate admin e2e tests

* fix

* combine admin folders

* Fold duplicate database admin e2e tests into siblings

Writable-connection health, side-panel help links, routing-on
conflict messages, connection-string value retention and failure
tracking now run inside tests with the same setup.

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

* Fold duplicate datamodel and segments e2e tests into siblings

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

* Fold duplicate content-translation, help, oauth and api-key e2e tests into siblings

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

* Fold duplicate people and tenants e2e tests into siblings

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

* Fold duplicate remote-sync e2e tests into siblings

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

* Fold duplicate SSO and MFA e2e tests into siblings

Each provider's reset test now runs after its pause/resume test.
The LDAP resubmit clicks match "Save and enable" or "Failed"
instead of passing an object where cy.button expects a numeric
timeout.

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

* Fold duplicate settings and whitelabel e2e tests into siblings

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

* Fix two merged admin e2e tests and strengthen two weak assertions

- jwt: run deactivate before key regeneration. After saving a
  regenerated key the form stays dirty, so navigating back to the
  overview opened a "Discard your changes?" modal.
- people: assert the row menu closed with H.menu() instead of
  cy.get(POPOVER_ELEMENT), which fails once the menu unmounts.
- upload-and-download: after replacing the dictionary, check the
  German rows are actually gone.
- whitelabel: set a custom help link before deleting the token, so
  the default link can only come from the whitelabel feature gating.

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

* Restore Postgres connection-string e2e test and address coverage review

- Restore "should successfully connect to PostgreSQL using connection
  string". databases.cy.spec.js fills the Postgres form by hand, so
  nothing else saved a form filled only by the Postgres mapper.
- Run the Save-enabled check on a fresh MySQL form again. Engine changes
  keep earlier values, so in the parse loop Save could already be
  enabled before the paste.
- JWT and SAML group mappings: delete the groups of a mapping that
  includes Administrators again, and check Administrators survives.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:32:09 -06:00
Romeo Van Snick
c916fa723a Stop caching 404s for hashed assets (#82796)
* Never send a far-future cache header on a failed response

* Require a real content hash before caching an asset forever
2026-09-30 09:10:42 +10:00
Andrei Simionescu
e6dcb849be Drop the nonexistent schema from Metabot's Sample Database examples (#82958)
* Drop the nonexistent schema from Metabot's Sample Database examples

* Let Metabot's query tool schema accept tables without a schema

* Use the Sample Database's null schema in the Agent API examples

* Use a real Sample Database column in Metabot's filter example

* Keep Metabot's basic filter example on one table
2026-09-29 22:20:48 +00:00
Tyler Plude
78e91452ec [GIT-71792] Fix literal handling in legacy MBQL conversion (#83162)
* add a :legacy-int-field-ids? opt-out to legacy MBQL normalization

* keep raw integer literals when converting MBQL 5 to legacy

* keep integer literals in legacy MBQL queries imported through serdes

* honor the legacy-int-field-ids opt-out inside aggregation clauses

* prefer lib/normalize
2026-09-29 15:03:39 -07:00
Uladzimir Havenchyk
1df1ca5f36 fix: align button baseline and hover state in dark mode in toast (#83092)
* fix: align button baseline and hover state in dark mode in toast

* drop testing options

* address review

* address review
2026-09-30 00:52:06 +03:00
Andrei Simionescu
859f67d39e Report Metabot search failures as tool errors (#82941)
* Report Metabot search failures as tool errors

* Run the search failure test in parallel

* Name the ticket in the search failure test
2026-09-29 21:47:29 +00:00
github-automation-metabase
2d79e75917 Auto-update documentation for master (#83293)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-29 21:24:43 +00:00
Robert Kosara
f40e8e13da Clarify reference questions scope (#83264)
* Small spacing fix

* Add short paragraph about scope that's being searched

* Move new paragraph below the image

* Jeff's rewording

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-09-29 20:50:19 +00:00
Maksym Yakubych
670c7c3555 Remove unused /api/llm/generate-sql endpoint (#82380)
* Remove unused /api/llm/generate-sql endpoint

* Drop stale SQL generation warning from provider removal dialog
2026-09-29 17:04:29 -03:00
Eric Normand
6d78fff1a1 GHY-4491: Keep legacy MCP OAuth tokens alive on upgrade; answer invalid_grant for bad grants (#82950)
* GHY-4491: Make RevokeLegacyMcpOAuthTokens a no-op

The 64 upgrade revoked every OAuth token that carried only the pre-v2
MCP scopes, so that each client would have to re-authenticate. Since
GHY-4543, tools/list lists every tool, and a call that the token cannot
make gets a 403 insufficient_scope challenge that names the v2 scopes.
A client that holds only legacy scopes re-authorizes through that
step-up. We verified this in Claude Code: one Re-authenticate and one
consent, then search and query both work.

Revocation forces every client through the refresh-failure path
instead. We verified that path in only one client. Revocation also
gains little: the legacy tokens keep only the reach that they already
had.

The changeset stays, with a no-op body, and keeps its id and class. If
we deleted it, instances that already ran it would keep an orphan v64
row in DATABASECHANGELOG. Then migrate down would not roll that row
back, and the 63 jar would refuse to start with "Downgrade detected".
Liquibase computes a customChange checksum from the class name and
params only, so instances that already ran the old body still validate.

The widen migration is unchanged.

* GHY-4491: Answer invalid_grant for bad grants at /oauth/token

POST /oauth/token used the error code from the oidc-provider exception,
and used invalid_request when the exception had no code. oidc-provider
sets a code only for PKCE failures, an expired refresh token, and
resource indicators. So a revoked or unknown refresh token got
invalid_request.

RFC 6749 section 5.2 requires invalid_grant when the grant or refresh
token is invalid, expired, revoked, bound to another redirect URI, or
issued to another client. Clients act on the difference. With
invalid_request, Claude Code keeps the dead tokens and shows the server
as failed but still authenticated. With invalid_grant, it drops the
tokens and asks the user to sign in again.

oidc-provider gives no error code for these failures, so we map them by
the ex-data they carry, as authorization-error-code already does:

- :code: unknown, used, or expired authorization code, or a missing
  redirect_uri. The code is consumed at that point, so a retry cannot
  succeed.
- :refresh-token: unknown or revoked refresh token.
- :expected and :actual: client mismatch for a code or refresh token,
  or redirect_uri mismatch.

Malformed requests carry none of these keys and stay invalid_request.
An explicit error code from the library still wins.
2026-09-29 15:03:08 -05:00
Eric Normand
4273b378a7 GHY-3289: sync-fields fetches the same cursor from the app db with no reason (#83027)
* Test that sync_schema does not read the Database row once per column

GHY-3289 reported that field sync ran SELECT * FROM metabase_database WHERE id = ? once for every column. The cause was describe-table-fields-xf, which called table/database inside its per-column function.

Commit 37b50677ed (Faster sync-fields, #38828, first in v0.50.0) moved that call out of the per-column function, so the Database row is now read once per table. This commit adds only a test. It calls POST /api/database/:id/sync_schema on a Postgres table with 60 columns and counts the app DB reads of the Database row. With the old per-column read the count is 63. Now it is 2.

* Run the GHY-3289 sync read-count test on H2 too, and prove its matcher

The test ran only on Postgres, so the default H2 CI jobs skipped it. The per-column Database read that GHY-3289 reports happens in the shared sql-jdbc describe-table-fields-xf path, so H2 shows it too: with that read put back, H2 counts 61 reads, the same as Postgres.

The SQL matcher expected double quotes. A MySQL app DB quotes with backticks, so there the counter would stay at 0 and the test would pass without counting anything. The matcher now accepts both quote styles. Before the sync, the test reads the Database once and asserts the counter saw that read, so a matcher that stops matching fails loudly.

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

* Make the GHY-3289 sync read-count test fail when the sync never runs

The test synced the dataset Database, which mt/dataset had already synced when it loaded. If the sync_schema request did nothing, the fields were still there and the read count was 0, so both assertions passed.

The test now syncs a new Database row that points at the same warehouse, with auto-sync off. Its Fields exist only if the sync_schema request ran. It reads them through GET /api/database/:id/metadata, because GET /api/database/:id/fields returns a 500 for a database with no tables.

A real sync of a new Database reads the row 8 times, so the limit is now 30, half the column count. With the per-column read restored the count is 67 on Postgres and on H2. With the sync dropped, the field count is 0 on both drivers.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:01:38 +00:00
Andrei Chernykh
9258817f4e Migrate the setup layout components off Emotion (#83157)
* Migrate the setup layout components off Emotion

* Keep the setup card layout in its CSS module

* Fold the setup arm into the form body's default layout
2026-09-29 12:27:10 -07:00
technomancy
2de3039266 Bump snowflake JDBC driver to pull in a fix for a connection pool bug. (#83263)
https://github.com/snowflakedb/snowflake-jdbc/issues/2642

Fixes https://github.com/metabase/metabase/issues/83093
2026-09-29 18:27:54 +00:00
Andrei Simionescu
b87df9ee9d Fix the anonymous stats ping failing on dev instances (#82952) 2026-09-29 17:49:54 +00:00
Eric Normand
4f89083137 GHY-3584: Exclude Python transforms from check entity task (#82979)
Resolve the database of Python transforms in dependency analysis (GHY-3584)

The entity-check job looks up the database of each entity before it analyzes it. For a transform, instance-db-id read the database only from the source query. A Python transform has no source query, so the lookup returned nil.

An earlier change (#76119) records a terminal error when no database resolves, so the job no longer loops. But every Python transform then got the error "Could not resolve a database for this entity." and showed up as broken in the dependency graph.

instance-db-id now falls back to the source_database_id column of the transform. The transform check already returns no errors for non-query transforms, so a Python transform now gets a clean finding.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:49:05 -05:00
Andrei Chernykh
afa728d185 Migrate FormMessage off Emotion (#83137) 2026-09-29 10:23:55 -07:00
Michael Eby
3ec11a26ab docs: auth settings UI (#82971)
* docs: auth settings UI

* Optimized 6 images, saved 212.5 KB (49.8% smaller)

* Apply batched suggestions from code review

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>
2026-09-29 17:22:01 +00:00
Andrei Simionescu
3073c1f7ea Pass a query map in the document tool error test (#83261) 2026-09-29 17:06:00 +00:00
Eric Normand
4a463e69eb BEGUILD-37: Ratchet dynamic vars with a kondo linter (#82995)
* BEGUILD-37: Ratchet dynamic vars with a kondo linter

Add :metabase/discourage-dynamic-vars to the def hook. It flags a def,
defonce, defn, or defn- whose name has ^:dynamic or ^{:dynamic true}
metadata. The finding sits on the whole form, so an ignore above a
multi-line def suppresses it.

The 382 existing dynamic vars get inline ignores, and ratchets.edn
seeds a budget of 382. The linter is comment-exempt because the
inserted ignores have no explanatory comments. The shrink workflow
lowers the budget as dynamic vars are removed.

* BEGUILD-37: Flag dynamic mu/defn too

Kondo lints mu/defn as schema.core/defn, so the def hook never saw it.
Add a lint-dynamic hook for mu/defn and mu/defn- that runs only the
dynamic-var check. Ignore the 13 existing sites and raise the budget
to 395.

Also remove three ignores in dev and mage that sat inside the form,
where they did nothing.

* Whitespace

* BEGUILD-37: Close review gaps in the dynamic var linter

Flag a defn whose attr-map has {:dynamic true}. Clojure merges the
attr-map into the var metadata, so that form makes a dynamic var too.

Register lint-dynamic for cljs.core def, defn, defn-, and defonce.
In cljs code, def resolves to these, so no hook ran before.

Test the routing through the real config with kondo/run!, so removing
a registration from config.edn fails a test.

Correct two comments. An ignore above a form covers every line of the
form. The problem was that kondo-insert-ignores puts the ignore on the
line above the finding.
2026-09-29 16:06:43 +00:00
Mahatthana (Kelvin) Nomsawadi
08c63a6b0f feat: show an error instead on the SDK on an unsupported React (#82435)
* feat: add a minimum supported React version check to the SDK

The minimum is 18 for now, so nothing changes today; the React 19
upgrade bumps it to 19. An unknown host version counts as supported so
a detection gap renders instead of blocking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtrkKRneS6NAuJmdYXEHHi

* feat: show an error instead of the SDK on an unsupported React

Once the SDK needs React 19, a host app still on React 18 would hit an
internal React error inside the first provider to mount, and that error
bubbles up and takes the host app down. Check the host React before any
SDK provider mounts and render a plain-DOM message in its place, with a
single console.error per page so the reason is visible even if the box
is hidden.

The check lives in the bundle because the bundle is served by the
instance while the installed package may be older. ComponentProvider
wraps every SDK component the package renders, so it carries the
guard. The fallback uses no Mantine, emotion or redux since those are
what may need the newer React.

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

* feat: skip direct SDK entries on an unsupported React

MetabaseProvider calls useInitData and useLogVersionInfo and renders
MetabotSubscriber outside ComponentProvider's check. On an unsupported
host React the bundle exports stand-ins for them that only log the
error once. The choice is made when the bundle loads, since the host
React cannot change after that, so the real hooks keep their hook
order untouched.

The version-info spec keeps the real host-react-version module in its mock,
since importing the bundle now evaluates the check as the exports are built.

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

* feat: show the React error in the data-app preview

The package renders SdkThemeProviderWithStore around the whole data-app
dev preview, so on an unsupported React it shows the error in place of
the app instead of mounting its providers.

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

* test: cover the SDK on an unsupported host React

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

* feat: take the minimum SDK React from package.json

The minimum host React major was a constant, so the React 19 upgrade
would have had to bump it by hand. The bundle build now reads it from
the React version Metabase itself depends on, and the SDK Storybook,
Jest and the e2e spec use the same value, so the upgrade needs no
change here.

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

* fix: set the minimum SDK React in the main app build

The iframe embed, MCP apps and data apps render SDK bundle code from
the main app build, which did not define the minimum React version.
The check read NaN there and reported every host React as
unsupported. The main build now defines it too, so a build that
misses it shows the error everywhere and fails fast.

The check reads the minimum directly instead of taking it as a
parameter only tests passed, and the build helper gets a test that
its value is a whole React major of at least 18.

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

* test: cover hooks-only hosts on an unsupported React

A host that only uses SDK hooks gets no error box, so the new case
checks that the SDK never starts initializing and that the error is
still logged once. The component case now renders two components to
show each gets the error box while the console gets one message.

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

* docs: say whose React the SDK globals carry

The SDK bundle takes React from METABASE_REACT, which reads as Metabase's
own React. It is the host app's, published by the package, and the check
this stack adds depends on that.

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

* fix: honor --spec when running component tests locally

The component branch of the local Cypress runner dropped the user's CLI
overrides, so a --spec pointing at one component spec ran the whole
component suite instead.

* test: cover the data app dev server and the version-log swap

The data app dev server runs the app author's own React against the
bundle from the instance, so it can hit the check; the version log has no
host-visible effect, so its swap is pinned in a unit test instead.

* refactor: hardcode the minimum SDK React version

The minimum host React major came from package.json through a
build-time variable. A build that missed the variable compared against
NaN and blocked every host React, and a future React upgrade would
raise the minimum even when the SDK does not need it.

It is a constant in host-react-version.ts again, and the rspack,
Storybook and Jest plumbing is gone. The specs fake a React 17 host
against it instead of reloading the module with a chosen minimum.

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

* refactor: swap SDK entries where they are defined

useInitData, useLogVersionInfo and MetabotSubscriber now pick their
unsupported-React stand-in in their own files instead of in the bundle
exports, which go back to what master has. The shared log-only hook
moves to host-react-version.ts, and the useLogVersionInfo swap spec
moves next to the hook.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 16:47:28 +01:00
Andrei Simionescu
be02050901 Test agent streaming with a draft query without a data source (#82749)
Pin agent streaming response on empty question

Relates to [BOT-1644](https://linear.app/metabase/issue/BOT-1644)

Problem:
On versions prior to 1.61.19 / 1.62.17, empty question context lacked a
database ID, causing /agent-streaming to reject requests with a 400.
While resolved on master by loosened schema validations, no regression
test existed to pin this behavior.

Solution:
Add pinning test verifying that an empty adhoc draft query with nil
database passes through agent streaming without throwing 400.

How to verify:
- ./bin/mage run-tests test/metabase/metabot/api_test.clj passes
2026-09-29 16:39:44 +01:00
Andrei Simionescu
8c5c1e3e85 Report query, chart analysis and library search failures as errors (#82970)
* Report query, chart analysis and library search failures as errors

* Fix the construct test helper docstring
2026-09-29 16:39:21 +01:00
Andrei Simionescu
f01ab65388 Let Metabot see dashboard filters (#82959)
* Let Metabot see dashboard filters

* Cap the dashboard filters shown to Metabot

* Don't split emoji when Metabot shortens a long name
2026-09-29 16:39:02 +01:00
Andrei Simionescu
f09e8fb24a Tell Metabot when it only sees some of a column's values (#82961)
* Tell Metabot when it only sees some of a column's values

* Default to exact filters when a column's value list is partial

* Apply the partial-list advice only to requests for a specific value
2026-09-29 16:38:55 +01:00
Andrei Simionescu
ffa7eecd6c Let Metabot add model-based charts to documents (#82990)
* Let Metabot add model-based charts to documents

* Give the document agent the notebook query skills

* Wrap a long line in the document tool test
2026-09-29 16:38:26 +01:00
Andrei Simionescu
af594ebafe Save viewing context item IDs only once stored (#82748)
Fixes [BOT-2186](https://linear.app/metabase/issue/BOT-2186)

Problem:
add-client-ids rewrote the union of all earlier turns' IDs and this
turn's IDs into :turn-state on every turn. Ad hoc items minted a new UUID
each time, causing the client-ids set to grow indefinitely with every turn,
even when a turn had no viewing context or stored nothing.

Solution:
Keep this turn's candidate IDs outside :state in agent memory. Only persist
an ID to :state and :turn-state when a query or chart is actually stored under
it, matching the pattern used by set-link-registry.

How to verify:
- Tests pass in test/metabase/metabot/agent/memory_test.clj and core_test.clj
- Linters clean (clj-kondo, cljfmt)
- New tests confirm empty turns do not persist IDs and stored items
  persist correctly with earlier turns' IDs
2026-09-29 16:38:15 +01:00
Eric Normand
2fef5f61f4 Wait for the expression editor to take focus before pressing Escape (#83165)
The custom expression editor is a lazily loaded CodeMirror chunk that focuses itself with autoFocus once it mounts. The popover closes on Escape only when focus is inside it. The test pressed Escape right after opening the editor, so on a slow runner the key reached the page before the editor held focus, and the popover stayed open.

The test now waits for .cm-editor to have the cm-focused class, the same wait the CodeMirror type helper uses. It failed this way on master at d8488ef, in both attempts of e2e-group-45-ee.
2026-09-29 10:25:16 -05:00
Denis Berezin
6d67d817df Close the timeline details modal to the collection page (#83122)
* Close the timeline details modal to the collection page (#83006)

* Open a new timeline in its own collection after creating it
2026-09-29 20:16:10 +05:00
Denis Berezin
b04e88210a Show the archived-entity banner on trashed explorations (#83144)
* Show the archived-entity banner on trashed explorations

* Review fixes
2026-09-29 20:15:25 +05:00
Jeff Bruemmer
cf2d4d8b86 docs - domain page (#82683) 2026-09-29 14:51:00 +00:00
Nemanja Glumac
9d5e3356c9 Remove "auto resolve merge conflicts" workflow (#83246) 2026-09-29 14:50:47 +02:00
Timofey Kachalov
0a6efc66ff Move data apps back under data-apps token feature (#82855) 2026-09-29 15:37:11 +04:00
Egor Grushin
ec1b55a09e Bookmark icon misplaced in sidebar items with long labels (#83148)
Fix sidebar item name overflow pushing right element to edge
2026-09-29 12:32:46 +01:00
Anna Tomka
97283f9c02 GDGT-3231: 1. Combobox: add Select, MultiSelect and Combobox stories with Loki snapshots (#82756)
* test(ui): add Select, MultiSelect and Combobox Overview stories with Loki

* test(ui): address review, drop Combobox story and searchable sections

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-29 10:47:45 +00:00
Stas Gavrylov
5b9d69ca66 Improve Cypress flaky-test guidance (#82752) 2026-09-29 13:44:33 +03:00
Stas Gavrylov
6871bb4809 GDGT-3122: Stabilize sync cruft tables test (#82739)
* GDGT-3174: Stabilize dashboard question move warning test

* GDGT-3174: Require successful UI data loads

* GDGT-3174: Limit the scope of applied solution

* GDGT-3174: Extend e2e skills

* GDGT-3175: Stabilize query action execution test

* GDGT-3107: Stabilize impersonated permission test

* GDGT-3175: Stabilize query action execution test (#82658)

* GDGT-3122: Stabilize sync cruft tables test

* GDGT-3107: Stabilize repeated model action setup

* Revert "GDGT-3174: Extend e2e skills"

This reverts commit 034ca4c725.

* GDGT-3122: Use deref-with-timeout for all sync waits
2026-09-29 13:13:38 +03:00
Stas Gavrylov
18ecd5433a GDGT-3174: Stabilize dashboard question move warning test (#82646)
* GDGT-3174: Stabilize dashboard question move warning test

* GDGT-3174: Require successful UI data loads

* GDGT-3174: Limit the scope of applied solution

* GDGT-3174: Extend e2e skills

* GDGT-3175: Stabilize query action execution test (#82658)

* Revert "GDGT-3174: Extend e2e skills"

This reverts commit 034ca4c725.

* GDGT-3107: Stabilize impersonated permission test (#82666)

* GDGT-3175: Stabilize query action execution test

* GDGT-3107: Stabilize impersonated permission test

* GDGT-3107: Stabilize repeated model action setup
2026-09-29 12:10:31 +03:00
Oleh K
a522f86267 Update supported browsers doc and pin browserslist (#80477)
* GDGT-3128: Pin browserslist to the versions we actually support

The documented floor (Chrome 70, Firefox 68, Edge 17, Safari 11) had drifted
far from reality. Auditing the shipped bundles turned up features that break
well above it:

  - subgrid (Chrome 117), unguarded in two CSS modules
  - :has() (Firefox 121), 248 occurrences; the postcss polyfill is disabled
  - color-mix() (Chrome 111), including the ui/colors base palette
  - toSorted/findLast (Safari 16.4) and URL.canParse (Chrome 120, Safari 17)

There are no polyfills to fall back on: core-js is not a dependency, so
preset-env injects nothing.

browserslist said "defaults", which resolves to Chrome 109 among others --
below our own floor -- so the build was targeting browsers the code breaks
on. Pin it to the audited versions instead.

The rspack swc-loaders hardcoded env.targets: ["defaults"], bypassing
package.json entirely. Dropping targets lets swc fall back to the
browserslist config, matching babel-loader and postcss.

Verified against the real postcss pipeline over all 826 CSS files. Prefix
output only improves: -webkit-backdrop-filter is now emitted (Safari needs it
through 17.6, and it was previously missing), and -webkit-background-clip is
dropped, which is safe because Chrome 120 is exactly where the unprefixed
form lands. The -moz-column-*/-o-* prefixes that go away were for Firefox
versions far below 121.

* GDGT-3128: Use the :global() function form in CSS modules

Pinning browserslist dropped `op_mini all` from the targets, and Opera Mini
was the only thing keeping postcss-preset-env on its expanded nesting
desugaring. With it gone, preset-env emits the spec-correct `:is()` form:

    .editor :global .cm-panels     ->  :is(.editor :global) .cm-panels

That pulls `:global` inside `:is()`, where its positional "everything after
this is global" semantics no longer apply. css-loader then scoped the classes
it was meant to leave alone:

    :local(.editor) .cm-panels     ->  :is(:local(.editor)) :local(.cm-panels)

CodeMirror renders real `.cm-editor`/`.cm-panels`/`.cm-content` elements, so
every rule in those blocks silently stopped applying -- 20 scoped selectors and
0 global in CodeMirror.module.css alone. That took out three e2e specs:
cc-typing-suggestion, native-reproductions and transforms.

The `:global()` function form binds global-ness to a selector node instead of a
position, so no amount of selector rearrangement can separate it from the class.
Verified to hold three levels deep, including under `:is()` wrapping.

Selector output is otherwise unchanged: compiling each file under the old and
new configs and comparing the post-CSS-modules selector sets gives an exact
match, except for a `::-moz-placeholder` that the browserslist pin drops on its
own (Firefox 121 has it unprefixed).

The stylelint rule catches the bare form. It also flags a top-level `:global {}`
block, which happens to work today only because a lone `:global` is a single
compound and escapes the `:is()` wrap -- one more nesting level and it breaks
the same way.

* GDGT-3128: Raise the Safari floor to 18.2 for auxclick

`auxclick` lands in Safari and iOS Safari 18.2 -- every version through 18.1
reports `n` in caniuse. There are 12 unguarded `onAuxClick` handlers in app
source (19 counting tests), so 17 was never a floor we actually met.

Chrome (55), Firefox (53) and Edge (79) are all far below their current floors,
so only Safari moves.

Side effect: `-webkit-backdrop-filter` is no longer emitted. Safari needed it
only through 17.6, so past 18.2 autoprefixer correctly drops it. The same bump
also covers scrollbar-gutter/width/color, which all wanted exactly Safari 18.2.
2026-09-29 10:59:14 +02:00
Oleh K
e22d727172 Use Ocean stops for the default brand ramp (#82853)
* Use Ocean stops for the default brand ramp (GDGT-3177)

The brand ramp in `base-colors.ts` is built from `color-mix()` expressions over
`--mb-color-core-brand`, which exist so a whitelabelled brand can be approximated
at paint time. An instance that has not customized its brand has nothing to
approximate, so `deriveFullMetabaseTheme` swaps those values for Ocean's
hand-tuned stops instead.

The theme files bake the ramp in at module load, so the swap happens on
`baseTheme.colors` before the merge. That is the only source that can carry ramp
values -- chart colors are plain hex (they must be, since `deriveChartTintColor`
parses them in JS), and whitelabel and embedding overrides contribute literal
colors.

Resolving to literals also fixes these colors in static viz, which renders
server-side and cannot parse `color-mix()`.

The "has the brand been customized" check reads `application-colors` by its
`brand` key, which stays addressed that way, and the embedding override by both
`brand` and `core-brand` so it keeps working across the GDGT-2536 rename.

* Keep the brand ramp dynamic on the SDK path

Loki caught the DatePicker "Dates range SDK" story losing its custom brand: the
in-range dates fell back to Ocean blue while the endpoints stayed purple.

A v1 SDK theme never reaches `deriveFullMetabaseTheme` -- its brand arrives as
`--mb-color-core-brand`, emitted by `getSdkDesignSystemCssVariables` after
`createColorVars` in the same `:root` block, and the ramp's `color-mix()`
expressions resolve against it at paint time. Resolving them to Ocean literals
severed that link.

`getSdkDesignSystemCssVariables` always re-declares `--mb-color-core-brand`
(`themeColor` falls back to the primary color, so a color always "exists", and the
base Mantine theme always carries `core-brand`), so literals are never correct on
that path. `getMetabaseSdkCssVariables` now asks for the dynamic ramp outright via
`keepBrandRampDynamic` rather than trying to detect a custom brand.

An SDK embed with no custom brand therefore keeps the approximated ramp instead of
gaining Ocean, same as before this branch.

* Cleanup

* Address some review comments

* Stop building a whole theme to read one value

Five modules called `getThemeOverrides()` at import time and kept a single
field off the result. Four wanted `fontFamilyMonospace`, which is a hardcoded
string in `theme.ts`; each call derived the entire palette
(`getMantineThemeColors` -> `deriveFullMetabaseTheme`) and discarded the rest.

They also passed no color scheme and no whitelabel colors, so any color read
off those objects would silently have been the light, un-whitelabelled one.

The four font readers now use `var(--mb-default-monospace-font-family)`, which
both `getMetabaseCssVariables` and `getMetabaseSdkCssVariables` emit. That also
drops three unjustified `as string` casts. `ColumnInput` wanted `spacing` and
`fontSizes` rather than the font, so it reads them from `useMantineTheme()`
inside the component.

`getThemeOverrides` is left with one production caller, `ThemeProvider`.

* Apply the custom brand to the SDK palette, not just the CSS variables

The previous fix set `forceDynamicBrandRamp` only where the CSS variables are
emitted, but `getSdkDesignSystemCssVariables` re-declares 41 of them from the
Mantine palette, which `ThemeProvider` builds separately and without the flag.
Two of those are brand ramp colors -- `text-hover` and `text-brand-hover` --
so they kept overriding the dynamic values with Ocean literals and a custom
brand was lost.

`forceDynamicBrandRamp` now reaches the palette as well, through
`ThemeProvider` -> `getThemeOverrides` -> `getMantineThemeColors`, so both
layers agree. This also covers colors read off the theme rather than through
CSS variables, such as Mantine `c=`/`bg=` props and `theme.fn.themeColor`.

`SdkThemeProvider` sets it only when a brand actually exists, from the SDK
theme or from whitelabel, so an embed without one still gets Ocean. Whitelabel
had the same bug: `SdkThemeProvider` never passed `whitelabelColors` to
`ThemeProvider`, so the base palette never saw an instance brand either.

`SdkThemeProvider.unit.spec.tsx` covers the four combinations by reading the
`<style>` tags the provider actually renders, so the flag it picks is part of
what is asserted. It checks both a re-declared color (`text-hover`) and one
that is not (`text-brand`) -- either alone misses a divergence between the
two layers.

Also renames `resolveBrandRampToOcean` to `replaceBrandRampWithOcean`: Ocean's
stops are not what the `color-mix()` expressions resolve to, they replace them.

* Build theme definitions from a brand ramp parameter

The theme files baked `baseColors.brand[N]` into their color maps at module
load, which destroyed the stop index. Swapping in Ocean then meant recovering
it by matching the generated `color-mix()` strings against a lookup table --
the remapping Kamil flagged in review.

`getLightTheme`/`getDarkTheme` now take the ramp as an argument, so
`deriveFullMetabaseTheme` picks `baseColors.brand` or `baseColors.ocean` up
front and spreads the result unchanged. `baseColors.ocean` drops in because
`typeof baseColors.brand` requires the same stops, so a mismatch is a compile
error rather than a value that silently fails to match.

`constants/brand-ramp.ts` is deleted with its lookup table and second pass over
the palette.

`METABASE_LIGHT_THEME`/`METABASE_DARK_THEME` are gone too, so there is one way
to get a theme rather than a constant beside a builder. The six consumers call
`getLightTheme()`/`getDarkTheme()` instead.

The ramp test no longer has a lookup to assert against, so it compares the
derived palette with the Ocean-parameterised theme directly, which covers every
ramp color instead of those that happen to match a string.

* Cover the remaining brand ramp cases for SDK embeds

The suite checked V1 and V2 with a brand and V1 without one, leaving three
holes:

- a V2 theme with no brand, whose override carries a full palette and so merges
  over the base differently than V1 does
- dark mode, where the two layers pick the color scheme independently and the
  ramp stops differ (`brand[30]`/`[40]` rather than `[60]`/`[50]`)
- the whitelabel case asserted only `text-hover`, the token the SDK mapping
  re-emits, and not `text-brand`, which it does not -- that pairing is what
  catches the two layers disagreeing

Also adds a case for an SDK brand and a whitelabel brand together, which PR
#79391 calls out as a separate path. It verifies precedence rather than the
ramp: a whitelabel brand already keeps the ramp dynamic inside
`deriveFullMetabaseTheme`, so that case does not discriminate on the flag.

Verified by mutation: forcing the flag off fails 5 of these, forcing it on
fails 4.

* Cleanup

* Address review comments

- `getThemeOverrides` takes an options object, so its last positional boolean
  is gone. A `= {}` default keeps the no-argument callers unchanged.
- The SDK theme spec uses `MetabaseEmbeddingTheme` rather than reaching through
  `React.ComponentProps`, and its `setup` returns `{ getCssVariable }`.
- `<Text ff>` takes a plain string instead of a braced literal.
- Typo in a comment.
2026-09-29 10:30:45 +02:00
YB
d0d21c105a Bump zookeeper to 3.8.7 (#83196)
bump zookeeper to 3.8.7
2026-09-29 16:26:02 +08:00
Jakub Chodorowicz
bc828de810 Gdgt 3239 update api changelog for v64 (#82753)
update api changelog (gadget v64)
2026-09-29 09:32:14 +02:00
Fraser
df5e090463 Use affected-test plans to narrow E2E tests (#82052)
* Use affected-test plans to narrow E2E tests

* Restore existing E2E workflow comments

* Read the E2E spec selection from the test plan artifact

* Prepare the E2E spec selection through the composite action

* Type-check Cypress detection with or without Cypress globals

* Tighten E2E selection tests and workflow setup

* Simplify E2E selection setup and tests
2026-09-29 19:43:16 +13:00
YB
53e53addbc Bump jackson-core and jackson-databind to 2.21.7 (#82997)
bump jackson-core and jackson-databind to 2.21.7
2026-09-29 11:14:37 +08:00
github-automation-metabase
0e59c12a16 Auto-update documentation for master (#82989)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-28 20:54:54 -04:00
Eric Normand
e713f3471e Lint a model written with columns in the unmarked-value check (#83163)
The kv-arg and conditions-map walkers found the model by looking for a :model/... keyword. A model written with columns, such as [:model/Card :id :public_uuid], is a vector, so the walkers found no model and checked no pair. An unmarked value after it passed the lint.

Both walkers now use model-node?, which accepts a :model/... keyword or a vector whose first child is one.

Found in GHY-4587: the lint reported 4 of the 8 unmarked values in metabase.public-sharing.db.

Fixes GHY-4668.
2026-09-28 18:52:28 -05:00
John Swanson
32c6bbac66 Fix metric explorer for metrics with implicit joins (#72558)
* Fix metric explorer for metrics with implicit joins

PR #71741 fixed metric explorer for metrics with explicit joins but
metrics using implicit (FK) joins still failed because
mbql-aggregation->node* dropped the field-ref opts map, losing
:source-field. The compiled MBQL then referenced the target column
with no FK hint, so the QP could not materialize the implicit LEFT
JOIN and produced "Column PRODUCTS.PRICE not found".

Preserve :source-field on the column node in both :count and
:column-agg aggregation builders, mirroring the existing pattern in
dimension-mapping-node. The QP's resolve-implicit-joins middleware
handles the rest — no two-stage compilation needed.

* Use a single =? pattern in compile-fk-aggregation-column-test

Address review feedback: match the whole compiled structure with one =?
instead of picking it apart with separate assertions.
2026-09-28 16:23:43 -07:00
Kevin Kelleher
64868188dd Hide new AI exploration button when all AI features are disabled (#83145)
don't show new AI exploration button when all AI features are disabled
2026-09-28 22:09:15 +00:00
Nathan Voxland
674c8b8dc6 Additional dashboard read check (#82787) 2026-09-28 16:51:13 -05:00
Egor Grushin
3b120289c2 Fix onboarding button shrinking and search user picker pills (#83125)
* fix onboarding button shrinking

* replace hand-rolled SearchUserPicker with PillsInput. Removed one more "as cast" and "styled" component

* skip pills for unknown users, drop redundant h=100%
2026-09-28 22:17:54 +01:00
Aleksandr Lesnenko
d8488ef969 feat(visualizations): give each y-axis its own label (#82881) 2026-09-28 16:53:28 -04:00
Andrei Simionescu
5375ffed93 Report unexpected Metabot tool failures as tool errors (#82969)
* Report unexpected Metabot tool failures as tool errors

* Wrap test lines over the 120-column limit

* Log unexpected Metabot tool failures at error level

* Reject invalid schedules in the dashboard subscription tool

* Return dashboard subscription errors to the model as output

* Don't count Slack tool calls from outside Slack as failures
2026-09-28 20:41:31 +01:00
bryan
ba78bac461 Fix is_personal hydration on instances with no Personal Collections (#83130)
`collections.db/personal-collection-ids` returns nil when no Personal
Collection exists yet, and batched `:is_personal` hydration called that
value as a set, throwing an NPE. `GET /api/collection` hits this on any
fresh instance where nobody has logged in, e.g. one driven only by an
API key.
2026-09-28 18:36:40 +00:00
Andrei Chernykh
e28f66a5ac Migrate the visualization skeletons off Emotion (#82992)
* Migrate the visualization skeletons off Emotion

* Skeletons: shared svg wrapper, inlined caption, Loki stories
2026-09-28 11:06:51 -07:00
Will Fry
44f2da8e70 Pause toast auto-dismiss while hovered or focused (#82957)
* GDGT-3253: Pause toast auto-dismiss while hovered or focused

UndoListing only paused the dismiss timer on hover for toasts with
showProgress, so every other toast disappeared from under the cursor.

- Pause any auto-dismissing toast while it is hovered or has keyboard
  focus, and resume with the remaining time once both end
- Store the resolved default timeout on the undo so resuming a toast
  created without an explicit timeout doesn't compute NaN and dismiss
  immediately
- Ignore a second pause on an already-paused undo
- Drive the progress bar's paused state from the store instead of
  CSS :hover so it also freezes on focus

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

* fix bugs when pausing and resuming

* cleanup and deduplicate tests

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:06:47 +00:00
Will Fry
fe6571e69a Prevent horizontal table overscroll triggering browser navigation (#82922)
Prevent horizontal overscroll on tables triggering browser navigation

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:01:14 +01:00
Andrei Simionescu
7b327c132c Mark failed tool calls in Metabot's thinking panel (#83044) 2026-09-28 17:43:29 +00:00
Nathan Voxland
76e91225be Additional jdbc validation (#82785) 2026-09-28 12:20:31 -05:00
Andrei Simionescu
31a183a847 Tell Metabot when it calls a tool it doesn't have (#82960)
* Tell Metabot when it calls a tool it doesn't have

* Keep made-up tool names out of Metabot analytics

* Keep made-up tool names out of Metabot logs
2026-09-28 17:58:05 +01:00
Andrei Chernykh
d2b1d250a6 [UXW-3618] Emotion css -> mantine: chart settings (#82976)
* Migrate chart settings components off Emotion

* Stop the visualizer header from truncating the Save button
2026-09-28 09:47:58 -07:00
Chris Truter
856c3bae26 Assign Search ownership to Metabot (#82809)
* Move search ownership to Metabot, and reconcile the Metabot roster

The search module's :team was still UX West while its children search.embeddings and
search.entity-retrieval already said Metabot. CODEOWNERS had no entry for search at all,
so nothing requested a review from its owners.

team.json listed appleby under Metabot, though GitHub has them in ux-west and
ux-west-backend, and was missing svozniuk.

* Give Metabot the nested search modules and the search tests in CODEOWNERS

* Assign the enterprise search module to Metabot too

* Give Metabot the frontend's search API contract files
2026-09-28 12:15:15 -04:00
Nemanja Glumac
ebfaff69d0 Update the checkout action to v7 (#82914) 2026-09-28 17:31:18 +02:00
github-automation-metabase
6bf44b00de [chore] Update E2E test timings with recent data (#83065)
update e2e timings 20260927-062459

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
2026-09-28 15:24:55 +00:00
Nemanja Glumac
735aef3539 Move data model error-handling coverage from e2e to unit tests (#82953)
* Move data model error-handling coverage from e2e to unit tests

The e2e test stubbed every mutation endpoint with a 500, so the backend
never participated in what it asserted, yet it paid for a writable
Postgres container, a table reset and a full resync, twice over.

Cover the toasts where the code that emits them lives:

- TableSection: table name, description, field order picker
- TableFieldList: inline field name and description
- FieldSection: field name, description, casting, semantic type,
  visibility, filtering, display values, JSON unfolding, formatting
- useTableUpdateHandlers: custom field order, whose dnd-kit PointerSensor
  jsdom cannot drive
- PreviewSection: failed table and object detail previews

The sync, rescan and discard toasts already had unit coverage in both
areas. The remaining toasts come from components shared by admin and
data studio, so they are covered once rather than per area.

setupFieldEndpoints and setupTableEndpoints gain the PUT routes these
tests drive.

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

* Cover TableActionsMenu sync, rescan and discard failures

The Data Studio table actions menu has its own handlers for these
actions, separate from the standalone buttons, so its error toasts
need their own coverage.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 15:12:37 +00:00
Eric Normand
e8f85e28e0 GHY-3274: Initial sync should not attempt to create field values (#82963)
* GHY-3274: Skip the FieldValues scan on the initial sync of a Database

FieldValues are only created on demand, by the /values endpoints. A new Database has none. But the initial sync (sync-database!, which the database-create event runs) still ran the field-values phase. That phase did per-Field app-DB work for every Field: a count and a delete of expired advanced FieldValues, and an exists check before clearing each non-list Field. On a new Database none of that work can find anything.

sync-database! now reads the initial_sync_status of the Database from the app DB when it starts. When the initial sync is not complete, a :full sync runs the metadata and analyze phases and skips the field-values phase. The status comes from the app DB and not from the map that the caller passes, because the metadata phase marks the Database complete, and callers can hold a map from before the first sync. Later syncs, and the scheduled field-values task, run the scan as before.

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

* GHY-3274: Read initial_sync_status from the database map, not the app DB

sync-database! fetched the Database from the app DB to read its initial_sync_status. Production callers do not need that: the database-create event and advanced-config pass the Database they just created, the sample-data code creates or selects the Database first, and the notify API loads it per request. Only tests passed a map from before the first sync to a later sync. So sync-database! now reads the status from its argument, and saves one app-DB query per sync.

Three places in the tests re-synced with a stale with-temp map and expected the later sync to refresh FieldValues: one-off-dbs/insert-rows-and-sync!, scanning-test, and sync-and-find-values! in the warehouse-schema FieldValues tests. They now select the Database from the app DB before each later sync.

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

* GHY-3274: Stub the unique task list in the task filtering e2e test

The "filtering should work" test in tools.cy.spec.ts opens the task list with task=field values scanning, and checks that the "Filter by task" picker shows that value. The picker is a Select whose options come from GET /api/task/unique-tasks, and a Select shows an empty input when its value is not an option. The test stubbed GET /api/task but not unique-tasks, so it depended on real task history.

That real task history had a "field values scanning" row only because the initial sync of the Sample Database ran the FieldValues scan. This branch skips that scan on an initial sync, so the name is no longer in the list, and the picker was empty. The test now stubs unique-tasks with the task it filters on, in the same way that it already stubs the task rows.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:02:54 +00:00
Denis Berezin
63cbcc92e3 Render the move-questions-into-dashboards modal without a second overlay (#83023) 2026-09-28 19:46:21 +05:00
Denis Berezin
16ad7b75f2 Keep query column order for new table columns (#82476) (#82972)
* Keep query column order for new table columns (#82476)

* Fix CI
2026-09-28 19:45:55 +05:00
Nemanja Glumac
eedda27c89 Cover Mongo convert-to-native on a unit test level (#83011)
* Move Mongo convert-to-native coverage out of e2e

The Mongo e2e test asserted on a specific product row, but the converted
pipeline has no $sort, so row order depends on the Mongo container's
natural order and the row often fell outside the rendered grid.

Replace it with backend tests (no default $limit, nested query over a
converted native card runs, re-converts with its collection and without
Bson wrappers, and the re-converted query runs) and Jest tests for the
Mongo-specific labels and the collection handoff on convert.

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

* Keep the positive collection case next to the negative one

The "no collection" case only proves something if the same fixture can
produce a collection; without the database feature flag, native extras
are silently dropped and the negative assertion passes vacuously.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:44:17 +02:00
Nemanja Glumac
1a0ff4340b Replace flaky metric-aggregation e2e test with a unit test (#82964)
* Replace flaky metric-aggregation e2e test with a unit test

The CodeMirror value() e2e helper read the editor text once, so a read
that landed before the initial format settled failed without retrying.
It is now a Cypress query that re-reads on every assertion retry.

The notebook e2e test is replaced by an AggregateStep unit test. Writing
it exposed a blur during the initial format overwriting the formatted
source with the stale empty one; blur is now ignored while formatting.

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

* Read CodeMirror text through invoke queries instead of a custom command

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:32:38 +02:00
Nemanja Glumac
0bdad48e91 Command palette e2e spec simplification (#82905)
* Move command palette shortcut coverage down to RTL

Split the shortcuts monolith into a navigation test and a focus-suppression
test, and drop the e2e assertions a unit test can make deterministically:
shortcut modal toggling, c f / c d, the [ navbar toggle, admin tab digits,
the mobile search button, palette action presence, and the snowplow counts.

New unit specs drive the real shortcuts through kbar and assert the router,
the redux modal state and the tracking call. Admin settings links per role
were already covered by the PaletteResults suite, so those two e2e tests go
away as duplicates.

AdminNavbar's spec binds digits against a fixture tab list; the real list's
order, which those digits index into, is pinned next to getAdminPaths.

Dropped Meta+[ outright: toggle-navbar binds "[", and the modifier trigger
is $mod+. handled outside kbar, so the press matched nothing and the
assertion after it passed on the sidebar's pre-existing state.

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

* Fold the documents-in-recents palette test into the main one

The test was kept separate because it needed the bleeding-edge token, which
enabled enough extra features to disturb the main palette test. Documents
moved to OSS: the token activation and the /api/ee/document endpoint went
with it, so the separation costs a cold app boot and buys nothing.

The document is created before the dashboard so the dashboard stays the most
recent entry and the existing selection assertion holds.

Also pin the personal-collection name on the read path in Clojure, which is
the one thing the e2e assertion covers that no backend test did.

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

* Fold the API-order palette test into the main one

Both tests are "real search results render correctly in the palette" and
each paid its own cold app boot. The order check moves into the query block
of the main test, right after the other search assertions.

The searchData alias is registered immediately before its query rather than
at the top of the test: cy.get yields the last matching request, and the
earlier searches in the merged test would shadow it.

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

* Anchor the shortcut navigation test on rendered pages

cy.location resolves when a route transition starts, not when it finishes, so
chaining chords on URL assertions alone lets the next chord land mid-mount
and be dropped. Each hop now waits for content on the destination page.

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

* Sync the dashboard bookmark shortcut on its API writes

The o shortcut decides between add and remove from the refetched bookmark
list, so pressing it twice in a row can toggle against stale state and leave
the dashboard bookmarked. Each press now waits for its write, and the
positive assertion between them proves the list refetched before the second.

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

* Set up the palette permissions test through the API

The test needs a non-admin with settings, monitoring, table-metadata and
database-management access; how those grants get made is not what it covers.
Driving the two permission graphs directly replaces a cold boot into the
admin permissions screen, ten UI interactions and two save round-trips.

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

* Wait for the bookmark toggle before the dashboard shortcut

visitDashboard resolves on the dashcard query responses, not on render, so
the o keystroke could land before DashboardBookmark mounted and registered
the shortcut — no request, no toggle. Anchor on the button that component
renders, which is the same thing that owns the shortcut.

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

* Wait for the app bar before opening the palette after sign-in

The palette registers its toggle only once its effect sees the logged-in
user, and the home greeting renders independently of the app bar, so the
ctrl+k could land while the palette was still disabled and be swallowed.
Anchor on the search button, which the same shell renders.

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

* Tighten restricted-shortcut and admin-path filtering tests

Assert the pathname stays put right after a restricted shortcut, and cover
the admin paths reducer dropping paths for a non-admin.

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

* Mount the admin app reducer at its typed store path in tests

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 15:31:35 +02:00
Nemanja Glumac
9b193b0937 Replace flaky help text docs link e2e test with a unit test (#82954)
* Replace flaky help text docs link e2e test with a unit test

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

* Assert the help text docs link click is not cancelled

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:14:41 +02:00
Nemanja Glumac
1139be54dd Replace upload-permission e2e tests with unit coverage (#82973)
* Replace upload-permission e2e tests with unit coverage

The "permissions" e2e block restored the postgres-12 snapshot only to
assert upload button visibility, which is covered by backend
can_upload tests and CollectionHeader unit tests. Add the missing
unit case: button hidden when uploads are enabled but canUpload is
false.

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

* Add a missing backend coverage

* Make upload-permission unit tests assert the right cause

The sandboxed can_upload test enabled uploads on the original database
while with-gtaps-for-user! queries a temporary copy, so can_upload was
false because uploads were disabled. Enable uploads inside the gtaps
block and sanity-check with an admin on the same database.

Add CollectionContent cases driving the upload button from the
database's can_upload response.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:13:18 +02:00
Nemanja Glumac
32176e152a Keep the Google Drive modal closed until the connection status loads (#83009)
While the connection query was loading, its missing status was read as
"not-connected", so connected admins briefly saw the connect modal. The
modal now renders nothing until the status arrives.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:12:16 +02:00
Chris Vandoodewaard
1d5c08b71d make number of versions we patch nightly configurable (#82766)
* make number of versions we patch nightly configurable

* if 0 should still patch current version
2026-09-28 08:51:21 -04:00
Anna Tomka
bc75a88888 GDGT-2458: Accordion (#82562)
* test(ui): add Accordion Storybook story and Loki filter

* feat(ui): restyle Accordion from Figma

* fix(Accordion): keep theme selectors one class deep so call-site overrides still win

* refactor: drop Accordion call-site overrides now covered by the theme

* test(ui): add Loki reference for Accordion Overview story

* fix(Accordion): hide the default chevron from assistive tech

* test: update DataComplexityCards snapshot for the Accordion separated default

* fix(RemoteSyncDependencyModal): drop overrides the new Accordion theme makes redundant, keep control transparent

* fix(RemoteSyncDependencyModal): let the Accordion theme space the rows

* fix(Accordion): keep icon grey on disabled hover, honour the radius prop

* test(ui): add disabled hover row to the Accordion Overview story

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-09-28 10:20:07 +00:00
Voytek Pituła
cd6bcc23e0 [BOT-2138] Centralize shared logic of metabot LLM provider adapters (#82165)
* Refactor metabot LLM provider adapters: centralize shared logic in `adapter.clj` while simplifying  implementations.

* Clarify error message for unsupported adapter models

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Rework auth, add some types

* drop span cusotmization

* drop extract and headers from CatalogOpts

* fix counts

* revert adapter/context-window-fn

* more tests for headers merging

* normalize-known-model fix

* doc fix

* more strict catalog name resolution

* simplify

* google coverage v1

* registry v1

* registry v2

* simplify

* align bedroc, drop dead ai-proxy?

* fix docstring

* improve tests

* rename listing to model-listing

* rename ai-proxy?

* rename keywords

* improve catalog error handling

* drop redundant content type

* move google auth inside request span

* route vllm's lsit-models through request!

* Update src/metabase/metabot/self/registry.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* remove history from docs and comments

* Update src/metabase/metabot/self/registry.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* fix var refs in optional/required

* refactor model context-window handling to use registry function

* migrate span attributes to nested :attributes map in adapter and update tests

* standardize function schemas with `mu/defn` and introduce `ResolvedRef` for adapter inputs

* remove redundant comment block in adapter test

* adjust schemas for closed maps enforcment

* reject unsupported AI proxy usage in Azure adapter and expand test coverage

* refactor registry-test to dynamically derive providers and improve adapter validation

* bring back old logging behavior

* stringify `:family` span attribute across adapters and update tests

* fix vllm test name and bedrock reference

* standardize `list-models` function schemas across adapters with `mu/defn` and update related tests

* add schema conformity test for `:supported-models` across adapters

* fix Auth schema

* Get back old tru errors

* Bake-in content-type header

* Expand `ModelListing` schema to include `learned-config` with additional closed map fields for connection reasoning.

* Remove `^:parallel` marker from test to disable parallel execution

* Update src/metabase/metabot/self/claude.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Rename `:learned-config` to `:connection-info`

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-28 11:07:48 +02:00
Fraser
381ab6c1a0 Small store batch: QueryModalType, metabot re-export (#79347)
Move QueryModalType and the metabot slice type out of shared redux

Co-authored-by: Romeo Van Snick <romeo@romeovansnick.be>
2026-09-28 21:31:09 +13:00
YB
176d0682e2 Document where action runs are logged (#82742)
document where action runs are logged
2026-09-28 13:18:46 +08:00
Fraser
daa0c256a6 Build Storybook with --test for Loki screenshot runs (#83003) 2026-09-28 11:16:08 +13:00
Romeo Van Snick
2df3f6daa0 Avoid modulejs warning in stylelint (#82988)
* Avoid modulejs warning in stylelint

* Fix eslint errors in stylelint plugin after .mjs rename

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 18:21:41 -04:00
Eric Normand
503f96cb55 GHY-4651: SendMeteringEvents does full table scan of query_execution (#83034)
GHY-4651: Let the metering query use the query_execution started_at index

SendMeteringEvents runs every 15 minutes and on every boot. It counts yesterday's query executions with WHERE CAST(started_at AS DATE) = CAST(? AS DATE). Casting the column hides it from idx_query_execution_started_at, so the app DB scans the whole query_execution table. On busy instances that table holds millions of rows.

The query now filters with a half-open range, started_at >= yesterday 00:00 UTC AND started_at < today 00:00 UTC. The DB can answer that range from the index. The db function becomes query-execution-statistics-between, and query-execution-last-utc-day computes the UTC day bounds. The docstring already said UTC day; the old cast used the DB session time zone instead.

The new test in token-check-test runs metering-stats, captures the SQL it sends for query_execution, runs EXPLAIN on the H2 app DB, and checks that the plan uses the started_at index. A second test pins the day boundaries: yesterday 00:00Z is in, today 00:00Z is out.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:09:44 -05:00
Andrei Simionescu
f2e185369f Always show the Metabot illustrations toggle (#82946)
* Always show the Metabot illustrations toggle
* Only mention the chat sidebar in the illustrations label and docs
* Test the illustrations toggle with the default icon
2026-09-25 21:30:29 +01:00
Eric Normand
9db952d568 GHY-4323: related-tables lookup does a permission query per table and an uncapped IN (#82962)
GHY-4323: Cap related-table ids before the lookup in get_fields

related-tables-by-requested-table collected every FK target of every requested table into one IN clause, then applied the 50-table cap to the result. A table with many FK columns made the lookup ask for tables that the response then dropped. The cap now applies to each table's targets before the lookup, so the IN holds at most 50 ids per requested table.

The :target hydration in the metadata fetch already drops unreadable targets, so only inactive or unbrowsable targets can now shrink the surfaced list below 50.

The other half of the ticket, one permission query per related table, is already fixed. Since 6908f26b76, field/readable-fields-only primes the table perms cache for every FK target while it hydrates :target, and the related tables are exactly those targets. The new API test covers it: as a non-admin with per-table grants and 60 related tables, the call succeeds under the N+1 guard, and it fails with that prime removed.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:18:51 -05:00
Chris Truter
9e27bdf31f Take host-part credentials off the pgvector URL (#83010)
* Take host-part credentials off the pgvector URL too

Credentials written before the host (user:pass@host) stayed on the URL.
pgjdbc reads that form as part of the hostname, so the password showed up
in its "unknown host" error and its DEBUG line.

- Move them into the credentials map, whatever the scheme, splitting at
  the host part's last @, with %-escapes decoded and + kept as is. Setting
  one both there and as a param fails at startup.
- Refuse any other unencoded @, which is what an unencoded / or ? in the
  password leaves behind, with a message that quotes none of the URL.
- Rethrow a malformed %-escape without the decoder's message, which quotes
  part of the value.

* Accept an unencoded @ in pgvector URL query values

pgjdbc decodes query values and gives @ no special meaning, so Azure's
documented user=name@server must keep working. Only a host part with no
path after it, the shape an unencoded ? in a password leaves, still
refuses an @ in the query.
2026-09-25 16:07:23 +00:00
Chris Truter
20f3f6cbdc Keep pgvector credentials out of logs (#82930) 2026-09-25 15:22:14 +00:00
Nemanja Glumac
2f3fe9904e Stop ErrorOverview recovery test from racing the search debounce (#83005)
Stop ErrorOverview recovery test from racing the search debounce

Fake timers skip the 500ms debounce so it no longer eats into findBy's
1s budget, and the lookup is scoped to the row so TreeTable's transient
off-screen measurement copy of the cell can't produce a duplicate match.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:14:18 +02:00
Nemanja Glumac
57a08becb3 Cover pivot subtotal date localization with a Jest test (#82947)
Pivot a month-bucketed date breakout under a German locale and assert the
subtotal label keeps the localized month. Formatting the value a second time
re-parses "Mai 2026" and yields "Januar 2026", which is the regression the
assertion pins.

Drop the Cypress reproduction: it rendered a pivot of Product Title x month x
User ID to check the same label, and was racing the render.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:25:36 +02:00
Andrei Chernykh
fc5cf5c469 Bump @snowplow/browser-tracker to 4.10.2 (#82955)
* Bump @snowplow/browser-tracker to 4.10.2

* Fix SDK analytics spec for batched Snowplow requests
2026-09-24 14:18:19 -07:00
bryan
c03c02a9a1 Lint values, not call style, and write the value-binding rubric down (#82686)
* Lint values, not call style, and write the value-binding rubric down

Two changes to the value-binding sweep (GHY-4477), both correcting guidance that
earlier namespace PRs were written against.

Stop asking for query maps. The linter's `lint-kv-args!` check asked every
`:column value` call to be rewritten as a query map, on the premise that a query
map is the shape the HugSQL extraction in step 2 reads off. That premise does not
hold. Both extraction spikes show it: the glossary spike (#82530) extracted
cleanly *from* kv-args, and the SSO spike (#82587) still needed substantial
rewrites *from* query maps -- a `SELECT 1 ... LIMIT 1` reshape and empty-collection
sentinels. Extraction is semantic. It turns conditional clauses into flags, picks
portable SQL, and discards the HoneySQL shape entirely (`h2x/like-substring`'s
`[:escape ...]` form becomes plain `ESCAPE '!'` text). A query map is not closer
to the target, just a different thing to throw away.

Converting also introduced a bug class. Toucan applies a model's `deftransforms`
`:in` fn only in kv-arg position -- it hooks `query/apply-kv-arg`, and a raw
`{:where ...}` map never passes through there (toucan2 calls HoneySQL "an outlet
to bypass type transforms"). So moving a keyword value into a where map drops the
transform, and HoneySQL formats a keyword in a value slot as an *identifier*:

    (t2/select :model/SearchIndexMetadata :engine :appdb)
    ;; => ["... WHERE ENGINE = ?"  "appdb"]          transform ran

    (t2/select :model/SearchIndexMetadata {:where [:= :engine :appdb]})
    ;; => ["... WHERE ENGINE = \"APPDB\""]           transform skipped, broken

That shipped twice on sweep branches before being caught (`sync/db.clj`'s
`:semantic_type :type/Name`, `collections/db.clj`'s `:type :api-key`). It compiles,
runs, and returns wrong rows. Nothing static can check the hand-written
post-transform value, so the check that produced these conversions is removed
rather than narrowed.

Lint kv-arg values instead. Removing the style check on its own would have left a
real gap: `value-nodes` only understood query-map clause shapes, so a value passed
as a kv-arg pair was never examined for a missing marker. `lint-unmarked-values!`
now walks kv-arg pairs too, starting after the `:model/...` keyword rather than at
a fixed offset (several fns take an argument before the model, e.g.
`(t2/select-one-fn :value :model/Setting :key k)`). Inside a kv-arg the column
comes from the pair key, so every argument of an operator form is a value --
`:id [:in ids]` checks `ids` -- unlike a query-map clause where the first argument
is the column.

Net effect on findings: call style is no longer reported; an unmarked value is
reported whichever style it was written in. Verified against
`settings/db.clj` (kv-arg style, opted in temporarily): both `setting-key` reads
are now flagged, and the `value` in `update!`'s changes map is correctly not.
`content_translation/db.clj`, the merged worked example, stays at zero.

Write the rubric down. docs/developers-guide/value-binding-rubric.md records every
decision the sweep needs, so the remaining namespace PRs stop re-litigating them in
review. Seven ordered rules, each with an example; the two that were previously
implicit are now explicit -- never mark a literal (it cannot carry a request value,
and in step 2 it becomes a HugSQL hole every caller fills with the same constant),
and leave a possibly-empty collection unmarked (Toucan rewrites `[:in col []]` to
`false` inside the step the marker wraps). It also records the `t2/update!` shapes
that work, what `value_guard` refuses for you, and the known limits the lint cannot
see -- so "zero findings" is replaced by "every finding explained by a rule".

* Frame the call-style rules as scaffolding that HugSQL extraction retires

A HugSQL query fn takes one map and has no override surface, so the
kv-arg-vs-query-map question -- and the transform-position trap that comes with
it -- does not exist for an extracted namespace. Say so, rather than leaving
these rules to read as permanent house style.

* See value slots inside conditionally built clauses

value-nodes stopped at a list node, so any value a when/if/cond-> built was
invisible to the unmarked-value check -- a false negative in a security lint,
and a common shape: permissions/db.clj and collections/db.clj both assemble
where clauses that way.

    (t2/select :model/Setting {:where [:and (when k [:= :key k]) [:= :o o]]})
    ;; o was flagged, k was not

Walking list-node children finds it. This surfaces real unbound values in four
namespaces (dependencies 12->17, sync 7->10, collections 5->7, search 5->6);
they are bound in the namespace PRs stacked on this branch.

* Correct the transform section: the call style breaks it, not the marker

The section implied that marking a value on a transformed column is what
produces the identifier comparison. It is not. value_guard lifts the
[:auto/param column v] 3-arity from inside apply-kv-arg, so a MARKED kv-arg
still runs the transform -- verified against :model/SearchIndexMetadata, the
bare and marked kv-arg forms compile to byte-identical SQL.

Only the where-map form skips the transform. So a marker on a transformed
column in kv-arg position is redundant rather than dangerous, and the reason to
leave it off is that the transform already binds the value.

Caught by a reviewer that compiled all three forms rather than taking the
section at its word.

* Rewrite the transform and written-value rules as single explanations

The transform correction was bolted onto the end of the section, so it stated
the rule, then contradicted its own framing, and showed the same example twice.
Rewritten as one block that shows all three forms together -- kv-arg bare,
kv-arg marked, query map -- so the variable is visible: the call style is what
takes a value off the transform path, not the marker. Adds the converse case a
sweeper actually hits: a call that was ALREADY a query map on master must carry
the post-transform value written out.

Rule 1's example showed a t2/update! CONDITIONS map while the prose was about
the changes map, which is the confusion the rule exists to prevent. Replaced
with a real settings/db.clj call where the condition is marked and the changes
map is bare, plus an explicit note that only the trailing changes map is exempt.

Also corrects the stated mechanism. The reason not to mark a written value is
that the column's `:in` transform runs on the marker and persists it as data --
mi/transform-encrypted-json is an :in transform, so an encrypted column
encrypts the marker. The earlier text attributed this to a
define-before-insert hook on :model/Setting, which does not exist; Setting's
encryption is application code plus a column transform.

* Refuse a marker outside a value slot, and see two more shapes

Three findings from review, all verified against a running app DB.

The guard was silent on the one case it most needed to catch. `auto-param`
lifts a marker wherever it sits, so one written in a `:select`, `:from`,
`:order-by` or `:group-by` clause became a bound parameter in a slot HoneySQL
wanted an identifier for -- compiling to the literal identifier `PARAM` and
discarding the value, with nothing downstream able to notice:

    (t2/query {:select [[:auto/param "name"]] :from [:setting]})
    ;; => ["SELECT \"PARAM\" AS \"P3R1MO8JB3WD0J\" FROM \"SETTING\""]

`check-marker-placement!` now refuses that as `::marker-outside-value-slot`. It
stops at a nested query map, because a subquery's own clauses are checked on
their own terms -- `t2/exists?` wraps the query in `:select [[[:exists {...}]]]`
whose inner `:where` is a real value slot, and scanning the outer `:select`
wholesale rejected it. A join's ON condition is likewise a value slot, so only
the table halves of a join clause are checked.

The lint could not see a conditions map. `(t2/update! model {:col v} changes)`
filters on `v`, but it is not a `:where` clause, so neither walker descended it
-- and the rubric recommends that exact shape, so a namespace written that way
passed vacuously. Now walked for `update!`, `update-or-insert!` and `delete!`,
taking only the map immediately after the model (a later map is the changes map,
exempt by rule 1). An operator form there holds only values, since the column
comes from the map key.

The lint could not see into a subquery. The operator branch returned its
arguments without descending, so `[:in :id {:select ... :where [:= :z z]}]` left
`z` unchecked. It now keeps each value and descends into it; descending alone
would have dropped the plain-token case.

Rubric corrections from the same review, each verified rather than reasoned:
rule 2's failure mode (marking a column keeps the comparison and binds the
column NAME, it does not drop it), rule 5 (`:not-in` gets no empty-collection
rewrite, so an empty one emits invalid `NOT IN ()` marked or not; rule 4 wins
for collections of ids; a non-empty proof must be local), rule 4 (`long`
truncates a non-integral number rather than throwing), rule 1 (a written value
is not the injection shape this project targets, rather than never structural),
rule 7 (booleans from outside are values too), the transform mechanism
(`apply-kv-arg` maps the transform over an operator form's tail, which is why a
marked kv-arg keeps it -- nothing to do with `value_guard`), and a new
false-negatives subsection, because the rubric previously described the lint as
only over-flagging.

* Scan subqueries for misplaced markers, and allow one inside an expression

Two holes in check-marker-placement!, both verified by compiling the form.

It only scanned the top-level map, so the silent case survived one level down:

    {:select [[[:exists ^:allow-subquery
                {:select [[:auto/param "name"]] :from [:setting]}] :e]]}
    ;; => SELECT EXISTS (SELECT "PARAM" AS "PACGBLW31WM3R" FROM "SETTING")

No throw, value dropped -- exactly what the check exists to prevent. The
docstring, the test, and the rubric all claimed a subquery was "checked on its
own terms", which was not implemented; nothing re-ran the clause scan on a
nested map. warehouse_schema/db.clj:317 already uses ^:allow-subquery with
:exists, so the shape is live. It now walks every nested map and scans each
one's clauses.

It also refused legitimate value slots. A comparison inside an identifier
clause is a real value slot, and both of these threw at compile:

    {:select [[[:= :engine [:auto/param "h2"]] :is_match]]}
    {:order-by [[[:case [:= :a [:auto/param 1]] 1 :else 2] :asc]]}

So a converted namespace would break the moment it grew a computed projection
or a CASE sort key. `:join` already needed a carve-out for its ON condition;
this is the same need, and the file already recognizes the shape via
`operator-form?`, so the scan descends past a keyword-headed form.

Pre-existing failures in metabase.app-db.custom-migrations-test are unchanged:
24 on origin/ghy-4477-rubric-foundation, 24 with these fixes.

* Classify identifier slots positionally, and close four lint blind spots

Findings from four independent reviews of this PR, each verified by compiling
the form or by linting a probe rather than by reasoning.

The guard missed its own target case. `marker-in-identifier-position?` decided
what was a value by head shape, but HoneySQL tells `[expr alias]` from
`[:= a v]` POSITIONALLY. So `[:t [:auto/param "al"]]` read as though `:t`
headed an operator form, and the alias slot -- the shape the check exists to
catch -- passed:

    {:from [[:t [:auto/param "al"]]]}    => "SELECT * FROM t AS param k"
    {:select [[:a [:auto/param "al"]]]}  => "SELECT a AS param k FROM t"

Now an entry is destructured as `[expr & aliases]`: index 0 may be an
expression and is descended, everything after it is a name and is refused.

`:order-by` and `:group-by` are no longer refused. HoneySQL binds a param in
both (`ORDER BY ?`), so a marker there is a pointless no-op, not a dropped
value -- and the test asserted the throw as correct, pinning a premise HoneySQL
contradicts. Refusing it would have turned a harmless mistake into a
production exception.

`:cross-join` no longer takes the alternating path. It has no ON condition, so
`take-nth 2` skipped every odd-indexed table; a marker there was missed.
Only the four join clauses that actually pair a table with a condition use it.
Added `:using`, `:create-table`, `:drop-table`, `:truncate`, `:partition-by`,
`:window`, `:select-top`.

The scan now short-circuits on `contains-marker?`. It runs on every app-DB
query at compile, and almost none carry a marker. 18.0us -> 13.7us on a
join-heavy query with no marker.

Four lint blind spots, all with live call sites:

  `[:!= :col v]` / `[:<> :col v]` were unchecked -- `value-operators` listed
  only `not=`. collections/db.clj:283 passes a request `user-id` through `:!=`
  in a namespace a stacked PR reports as converted. Added `!=`, `<>`,
  `is-distinct-from`, `is-not-distinct-from`, `not-between`.

  A value inside a function-call form -- `[:= :k [:lower v]]`, which compiles
  to `LOWER(?)` -- was dropped by the `:else` branch, whose token children
  return nothing on their own. Live in audit_app/db.clj. Same fix reaches a
  literal collection: `[:in :k [a b]]` now checks both elements.

  A two-arity `t2/update!` had its CHANGES map read as conditions. Toucan's
  arglist ends in the changes map, so a lone trailing map is changes --
  remote_sync/db.clj's mark-all-rsos-synced! was flagged for a written value,
  contradicting rubric rule 1. `delete!` has no changes map, so its lone map
  is still conditions.

  A query map passed where conditions go was walked twice, reporting every
  value twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.

Findings are now deduped by source position, which also covers a multi-arg
operator in a kv-arg: `:d [:between lo hi]` reported `hi` twice, because
`value-nodes` misreads the vector as a clause while `kv-arg-value-nodes` reads
it correctly. Deduping is robust where keeping the walkers disjoint is not.

Also: the marker payload is no longer descended (a marked value was reported as
though bare once the `:else` branch started yielding children), `ex-data`
carries the query so a dynamically-built one can be traced, and `args` is no
longer shadowed inside the conditions-map walker.

* Bring the rubric's guard and false-negative sections up to date

The guard section described the head-shape classifier and claimed :order-by /
:group-by were refused; both changed. The false-negative list still carried the
conditions-map, function-call-form, literal-collection, operator-alias and
subquery shapes as open, which are now covered -- and was missing the two that
remain open, a positional pk and a non-literal model.

* Drop the unused clojure.string require and the bang from check-marker-placement

Two clj-kondo failures on the previous push, both mine.

`clojure.string` became unused when the join carve-out stopped matching clause
names by suffix.

`check-marker-placement!` only throws -- it mutates nothing -- but the bang made
`:metabase/validate-deftest` read every call in a `^:parallel` test as a
destructive operation. Renamed rather than whitelisted, since the name was
wrong: the convention marks state change, and this is a predicate that refuses.

* Don't ask for a marker where the guard refuses one

`value-nodes` walked every clause of a query map, and the `:else` vector branch
yields each child as a candidate value. Together those reported symbols sitting
in clauses that name columns and tables, and the message always says the same
thing: write it as `[:auto/param x]`. In a `:select` or a `:from` that advice is
not merely noise -- `check-marker-placement` in this same PR throws
`::marker-outside-value-slot` when you follow it, so the two halves of the
mechanism contradicted each other.

A query map's clause values are now split. A clause that names things
contributes only what is nested inside it: a subquery's own clauses, and the
arguments of a keyword-headed comparison. Everything else is unchanged.

Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1981 warnings to 1963. Eighteen removed, none
added. All eighteen are identifier slots -- `output-table-id`,
`table-wildcard`, `sort-direction`, `order-column`, `id-column`, `cast-form`,
and a `union-query` alias. Every value-carrying shape survives: a computed
projection, a `CASE` sort key, a subquery nested in `:from`, a subquery in an
`:in`, a plain where clause, a kv-arg.

`:order-by` and `:group-by` are in the set even though the guard deliberately
allows a marker there. A marker in `:order-by` compiles to `ORDER BY ? ASC`,
which loses the ordering, so asking for one is still wrong advice.

Two related shapes stay open, both false positives that ask for a marker in a
position that holds no value:

- A clause built with `assoc` or `cond->` is not a map in the source text, so
  the walker still reads it as a plain vector. `dependencies/db.clj:492` keeps
  its three `sort-direction` warnings.
- A binding vector inside a `let` or a `for` is walked like a collection
  literal, so the binding names are reported.

Neither is urgent: the linter is opt-in per namespace, the noise surfaces one
namespace at a time during a conversion that reads every warning, and the
rubric's gate is already that a rule or a limit explains each one.

* Allow a marker at index 0 of an expression entry

In :select, :select-distinct, :select-top, :returning and :partition-by,
index 0 of an [expr alias] entry is an expression, and HoneySQL binds a
param there (SELECT ? AS a). The guard refused it as an identifier.

The table clauses also bind a param at index 0 (FROM ? AS a), but a
table cannot be a parameter, so the guard still refuses a marker there.

* Refuse a marker that is the whole identifier clause value

When the value of a clause was itself a marker, as in :from [:auto/param "t"],
the guard split it into :auto/param and the payload. Neither is a marker, so
the guard passed it, and it compiled to FROM param, k with the value dropped.
Treat a marker value as a single entry, ahead of the join branch too.

* Remove the value-binding rubric from the repo

A reviewer asked that it not live in the repo.

* Give the lint the clause context it was guessing at

Two fixes, both places where the lint's judgement of a slot did not match
`value-guard`'s.

**Split the expression-entry clauses.** `identifier-clauses` was one flat set,
so the lint stayed silent about every position in `:select`, `:returning` and
friends. The guard no longer agrees: its `expression-entry-clauses` treats index
0 of an `[expr alias]` entry as a value slot, and `{:select [[[:auto/param "a"]
:k]]}` compiles to `SELECT ? AS "K"` with the value bound. The silence there was
a false negative -- a request value in a computed projection reached the
statement text with nothing to report it.

Index 0 of an entry is now a value slot. Everything after it is a name, and a
bare entry is a name, which matches what the guard refuses:

    {:select [[:auto/param "a"]]}        refused   (bare entry)
    {:select [[:key [:auto/param "a"]]]} refused   (alias)
    {:from   [[[:auto/param "t"] :x]]}   refused   (table)
    {:select [[[:auto/param "a"] :k]]}   binds     SELECT ? AS "K"

**Carry clause context through `assoc`.** A clause written outside a map literal
reached the walker through the list branch, which keeps no record of anything,
so the name-slot check never ran. The lint then asked for a marker on a column
name and on a sort direction. Both fixes it named break the query, and one
breaks it in silence: marking the direction throws a ClassCastException, and
marking the column compiles to `ORDER BY ?` and loses the ordering with nothing
at runtime to catch it.

A clause key inside an `assoc` call now routes its value the way a key in a map
literal does. `cond->` is covered because the walk descends into the nested
`assoc`.

Measured over `src/metabase` and `enterprise/backend/src`, with the linter
forced on for every namespace: 1963 warnings to 1954.

Fifteen removed, all of them the `assoc` case: `sort-direction` and
`sort-column` in task_history/db.clj:174, cache/db.clj:102 and
dependencies/db.clj:492, `column` in queries/db.clj:53, models/db.clj:152 and
:171 and documents/db.clj:58, and `table-level-case` in permissions/db.clj:89.

Six added, all of them expression entries the flat set had been hiding:
`output-table-id` in data_studio/db.clj (three sites), `cast-form` in
app_db/db.clj:20, `domain-expr` in internal_stats/db.clj:206 and
`admin-group-id` in permissions/db.clj:681. Each holds a column reference today,
so each is a rule 2 finding to record rather than a value to mark -- but the
lint can no longer tell, which is the point.

Still open, and not addressed here: a binding vector inside a `fn` or a `let` is
walked like a literal collection, so its names are reported; `:order-by` and
`:group-by` are absent from the guard's set, where a bare marker entry drops the
value; and the guard's DDL keys are still missing from the lint's set.

* Give the rubric comments their own reasons

The rubric left the repo, so five comments cite a file a reader cannot open.
"Rubric rule 1" was a useful short form while it was here; now it asks the
reader to go looking in the git history.

Each one now states the reason instead of pointing at it. A written value is
never marked because the column's `:in` transform runs on the marker itself and
stores it as data, and the conditions-map shape is checked because the sweep
leaves it in place.

Comments only. No behaviour changes.

---------

Co-authored-by: Eric Normand <ericwnormand@gmail.com>
2026-09-24 21:05:55 +00:00
Kevin Kelleher
af8ca49a25 Prevent showing stale exploration summary documents (#82442)
avoid fetching stale document while a save is ongoing
2026-09-24 15:32:23 -04:00
Kevin Kelleher
cfb8cbd6f9 Hide exploration error pages for unrequested query variants (#82450)
filter out failed variant pages
2026-09-24 15:32:11 -04:00
Eric Normand
3eff0fac3d GHY-4213: Remote Sync: importing a GUI model before the target's database schema sync completes stores untyped columns (#82945)
GHY-4213: Infer untyped model metadata after a metadata sync

A GUI model that is deserialized (for example by a Remote Sync import) before the target has synced its tables cannot infer its result metadata. The import then stores only the exported model overrides, with base_type type/* and no field ids, and nothing revisits it. Filters on those columns offer only "Is empty" and "Not empty".

After each metadata sync of a database, look at the unarchived MBQL models on that database. For each model whose stored metadata has no known base type, infer the metadata again, keeping the model overrides. A model that still cannot be inferred is left as it is. The update goes straight to the app DB, so it publishes no card-update event and does not mark remote-synced content dirty.

Tests: an API-level test imports a GUI model through POST /api/ee/remote-sync/import before its table is synced, runs POST /api/database/:id/sync_schema, and checks the column types in GET /api/card/:id. Contract tests cover untyped-metadata? and backfill-untyped-model-metadata!.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:16:53 -05:00
metamben
b6f625ed4b Use jdbc-type for SQL Server user defined types as fallback (#82860)
Closes #62335
Closes QUE2-143
2026-09-24 17:49:26 +00:00
Eric Normand
bb9482f0fd GHY-4399: Remote Sync doesn't clean up nested content when a parent collection is archived, so orphaned files accumulate in git forever (#82876)
* GHY-4399: Cascade collection archive to nested content in the remote sync ledger

Archiving a collection archives its whole subtree in bulk SQL and publishes one collection-update event for the top collection only. The remote sync collection handler marked only that collection as delete, so the next push removed its own file and left every nested collection, card, and other item in the repo for good.

The handler now brings the ledger rows of the subtree in line with each entity archived state when the collection is archived, or when its own row was a pending delete (it was unarchived before a push). Rows of archived entities become delete. Delete rows of entities no longer archived become update, so restoring a folder before a push does not delete its files.

subtree-expr matched descendants by the prefix /<id>/, which is correct only for root collections. It now uses the location the children have (location + id + /), which is the same string for roots and is also correct for nested collections.

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

* GHY-4399: Cascade collection archive to transforms in the remote sync ledger

The cascade marked a subtree row as delete only when the entity itself was archived. Transforms have no archived column, so a transform in an archived Transforms folder stayed synced, and the next incremental push left its file on the remote. This is the first repro in the ticket.

A row now also counts as archived when the collection it is in is archived. Unarchiving restores it to update when neither the entity nor its collection is archived and the entity still exists.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 11:59:46 -05:00
Andrei Chernykh
8317274709 Migrate AutocompleteInput dropdown off Emotion (#82948) 2026-09-24 09:46:29 -07:00
Sébastien
c796ec01ec Handle both list and search default-value pickers in embedding e2e test (#82727) 2026-09-24 12:35:20 -04:00
Eric Normand
fa7362a1e4 GHY-4554: Mark untrusted data in MCP v2 tool results with an unforgeable boundary (#82879)
* GHY-4554: Mark untrusted data in MCP v2 tool results with an unforgeable boundary

Tool results put instance data (card names, warehouse rows, column comments) next to server prose, with nothing to mark where the data stops. A planted instruction in a name could pass for an instruction from Metabase.

message/data holds data from outside the server. render writes it as JSON between <data boundary="..."> and </data boundary="..."> lines that carry a random 32-hex-digit value, which the data cannot predict and so cannot close early. The initialize instructions explain the convention in one line.

message/json-text encodes JSON and then writes invisible, line-breaking, private-use, and unassigned code points (Cc, Cf, Zl, Zp, Co, Cs, Cn) as \uXXXX escapes. Decoded values do not change. The transport encodes JSON-RPC responses with it, so structuredContent is escaped on the wire too.

Every (message/raw (json/encode ...)) site and common/success-content now use message/data. Tool tests read the text through test-util/strip-data-boundary.

* GHY-4554: Make question_write and transform_write text-only

Both tools sent their payload as structuredContent as well as in the text block. Claude Code shows the model structuredContent in place of the text block, so the model saw the payload with no data boundary and with invisible characters raw. No client reads their structuredContent, so they now answer text-only.

* GHY-4554: Say to keep untrusted content out of structuredContent

A client may show the model structuredContent in place of the text block, and there the data has no boundary and its invisible characters decode raw.

* GHY-4554: Put glossary lookup results inside a data boundary

Glossary terms and definitions are user-written free text. The glossary() listing already put them inside the per-response data boundary, but glossary(term) quoted them into server prose. The lookup now returns its matches in the same data envelope as the listing, so a planted closing tag or instruction reads as data.

* GHY-4554: Make strip-data-boundary strict

The test helper returned text with no data boundary unchanged, so a tool that stopped wrapping its result still passed its suite. It now throws, and the suites that read error results through the same helper read those raw. With the renderer changed to emit bare JSON, every MCP v2 tool suite now fails.

* GHY-4554: Extend the never-follow rule to quoted values

Server prose quotes every value it did not write, so a planted instruction can reach the model inside a quoted value as well as inside a data boundary. The initialize instructions now name both.

* GHY-4554: Name the sliced table by id in the get_fields paging line

The paging line put the warehouse-controlled table name in server prose, outside the data boundary, and printed null when a fields projection dropped the name and id. It now names the table by the id from the source row and gives the exact next call, so the name stays inside the data boundary and the agent always knows which table to page.
2026-09-23 21:21:22 +00:00
Eric Normand
361fc91d80 GHY-4636: MB CLI doesn't have flag for expected branch, git-sync import fails with expected_branch: missing required key, received: nil (#82874)
GHY-4636: Let remote-sync import run without expected_branch

The mb CLI calls POST /api/ee/remote-sync/import with only a branch. GHY-4019 made expected_branch a required key, so every CLI import failed schema validation with 'expected_branch: missing required key'.

expected_branch is a compare-and-swap guard against a stale browser tab. A headless client has no cached view that can go stale, so the key is now optional. The endpoint runs the guard only when the client sends the key. The web UI still sends it, so its behavior does not change.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:49:22 -05:00
Nemanja Glumac
f4ab93af92 Extract the backend profiling trigger into a single entry point (#82854)
* Give on-demand test profiling a single entry point

backend.yml, app-db.yml, drivers.yml and semantic-search.yml each carried their
own workflow_dispatch block, their own PROFILE_TESTS/ONLY_TESTS env block, and a
selector input, all to support the same thing: run one leg of one suite on
demand, under a profiler, optionally narrowed to named tests.

profile.backend.yml now owns the only workflow_dispatch and calls the four
unchanged. They declare nothing for this feature: inside a called workflow
`github.event` is the caller's payload, so they read `target` and `tests` off
`github.event.inputs` directly. On a normal CI run those are absent and render
empty, so every gate's `== ''` clause holds and each suite behaves as before.

One `target` dropdown rather than a suite plus a leg keeps the gates at their
existing two clauses, and disambiguates `postgres`, which names a leg in three
suites.

Also fixes two ways a profiled run produced no flamegraph:

- test-driver built `:profile-tests` into BASE, but BASE is used only on the
  ONLY_TESTS branch. Profiling without a test selection ran unprofiled while
  still reporting profiled=true. The alias now goes on ALIASES too, which the
  other three branches use.
- The multi-JVM path runs each JVM from a sibling directory, so the profiler's
  relative output dir resolves per JVM. merge_outputs copied back junit and
  logs but not the flamegraphs.

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

* Collect profile output from multi-JVM test runs

collect-profile predates the multi-JVM test path and reads only the layout a
single-JVM run produces. A partitioned run nests its junit XML one directory per
partition under target/junit, and merges one logs/test-log-p<index>.json per
JVM, so the non-recursive `target/junit/*.xml` glob and the exact
`logs/test-log.json` path matched nothing: an empty wall-time summary, and an
artifact holding the flamegraphs alone.

The two summary greps become `grep -r --include='*.xml'`, which reads both
layouts without needing globstar, and the upload paths become globs matching
test-driver's own failure-log upload.

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

* Simplify inputs

* Use `test` namespace

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:40:49 +00:00
Timofey Kachalov
6c4b0182c1 Fixed custom actions from the mapQuestionClickActions plugin not appearing in EditableDashboard click action popover (#82832) 2026-09-23 22:33:12 +04:00
Phoomparin Mano
0694a11c90 Fix typed schema action template tag handling (#82488)
Co-authored-by: Timofei <timofei@metabase.com>
2026-09-23 19:41:59 +04:00
Egor Grushin
fa20179ff6 Restyle Button to the Figma spec and migrate call sites (#82236) 2026-09-23 16:59:13 +02:00
Riley Thompson
cdc7f386af Compile mbql stages to CTEs for postgres (#81914)
* initial commit

* Use CTEs for every multi-stage Postgres query

Drop the "standalone statement" gate and the native/persisted-stage and
join-source fallbacks. Postgres accepts WITH everywhere we embed a compiled
query (JOIN parens, CTE bodies, card-reference subqueries, CREATE TABLE AS,
INSERT INTO), so the only remaining condition is the driver opting in and
the query having more than one stage.

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

* fix changelog

* simplify with mapv

* comments

* Build CTE tests with lib and assert results only

Rewrite the four multi-stage tests added on this branch to build their
queries with lib/query and a metadata provider instead of mt/mbql-query,
and drop the compiled-SQL string assertions so they only check that each
query runs on Postgres and returns the expected rows.

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

* trim comments

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:09:50 +00:00
github-automation-metabase
d994a365f7 Auto-update documentation for master (#82878)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-23 09:40:25 -04:00
Phoomparin Mano
d7b9a71698 Fix including library sub-collection throws an error in typed schemas (#82513)
Co-authored-by: Timofei <timofei@metabase.com>
2026-09-23 17:34:22 +04:00
YB
12a22c0392 [GDGT-3229] read ClickHouse indexes from SHOW CREATE TABLE instead of system tables (#82573)
ClickHouse denies SELECT on system.data_skipping_indices, so index verification failed for every request (the index itself was created). fetch-table-indexes now reads one SHOW CREATE TABLE and parses its INDEX and ORDER BY lines. A failed warehouse read now carries the driver's message on the request and as warehouse_error on the indexes list, with an alert on the indexes page.
2026-09-23 21:14:23 +08:00
Ryan Laurie
6ec07f7518 Pin to clickhouse 26.8 (#82868)
pin to clickhouse 26.8
2026-09-22 22:28:11 +00:00
Fraser
aa9c3797e8 Move the embed setup modal state into the embedding module (#81656)
Co-authored-by: Romeo Van Snick <romeo@romeovansnick.be>
2026-09-22 18:27:38 -04:00
Eric Normand
8ce3936116 GHY-4642: Renaming a collection and exporting via Remote Sync doesn't update repo paths for content in renamed collection (#82865)
Remote sync: full export when a synced collection is renamed or moved (GHY-4642)

A collection path is also the directory of its contents. The incremental export wrote the renamed collection file and deleted the old one, but left every descendant file under the old directory, and kept their stale file_path values. Later incremental pushes did not repair it.

An update to a Collection whose stored path differs from its new path now falls back to a full export, which rewrites every path and records each file_path. This matches the import side, which already does a full import for any Collection change. In-place collection edits that keep the path stay incremental.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:11:38 -05:00
Oleksandr Yakushev
5bcd89d33f [pivot] Optimize BE+FE pivot generation (#82811) 2026-09-22 21:22:53 +03:00
Eric Normand
916ee6554c SEC-1208: Login throttle failures crash with fk_login_history_session_id FK violation, causing self-reinforcing lockout (#82859)
SEC-1208: test that a concurrent session delete cannot fail a login

Add an API test for POST /api/session. Between the session insert and the login_history insert, another connection deletes the user's sessions, as a password change does. The test logs in 11 times like this. Each login must return 200. Then a normal login must work, and GET /api/login-history/current must show all 12 logins.

The bug is already fixed. Commit 4a7f73029a (#80718, backported to 61 as #80962, first in v0.61.20.1) writes the session row and the login_history row in one transaction. The reporter ran v0.61.1, which does not have it. When that transaction is removed, this test fails as the ticket describes: each login returns 401 with an fk_login_history_session_id violation, and after 10 of them the throttle returns "Too many attempts" for the correct password.

The test does not use mt/with-temp for the user. with-temp binds a transaction connection, and the test client conveys it into the request, so the delete could not see the login's rows. For the same reason, the delete runs on a plain Thread, not a future.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:33:28 -05:00
github-automation-metabase
fff70175e0 Update translations 2026-09-22 (#82845)
* Update translation sources 2026-09-22

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-09-22 06:21:08 +00:00
Alexander Polyankin
74216b3098 Transform tests batch 2 (#82770) 2026-09-21 16:17:38 -04:00
github-automation-metabase
0509e1a5e2 Tighten ratchets (#82829)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-21 18:27:18 +00:00
Oleksandr Yakushev
81736f712a [kondo] Squelch missing-docstring linter for Potemkin-imported vars (#82812) 2026-09-21 21:21:36 +03:00
Michael Eby
812e9b2254 add IP allowlist page to nav (#82774)
* add IP allowlist page to nav

* fix nav and metabase ip addresses label
2026-09-21 12:40:46 -04:00
Timofey Kachalov
d0ce86c19e Fixed InteractiveQuestion and StaticQuestion flashing the "run your code" empty state instead of a loader while card.query is still unresolved (#82693) 2026-09-21 18:48:24 +04:00
Jeff Bruemmer
72e2e9b234 docs - ai providers updates (#82376) 2026-09-20 14:33:58 +00:00
github-automation-metabase
93742a629c [chore] Update E2E test timings with recent data (#82804)
update e2e timings 20260920-062425

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-20 07:03:45 +00:00
Nathan Voxland
b485c0f7fa Improve *-allowed-network validation (#82519) 2026-09-20 01:32:39 -05:00
Nathan Voxland
76979e6167 Limit unauthenticated request body sizes (#82572) 2026-09-19 15:32:34 -05:00
Nathan Voxland
da7629bba6 Serve no values for a filter whose widget is an Input box (#82535) 2026-09-19 15:32:12 -05:00
Nathan Voxland
280c1e7be2 Improve indexed-entity handling in xrays (#82699) 2026-09-19 14:50:48 -05:00
Aleksandr Lesnenko
3d924036db bind dev Docker ports, socket REPL, and rspack dev server to loopback only (#82707) 2026-09-19 10:27:04 +07:00
Chris Truter
883e944cce Preserve Slack attachment names in thread history when text is missing (#82631) 2026-09-19 02:48:34 +00:00
Tyler Plude
9d1c919f6b Move glossary entity_id migration from v65 to v64 for backport (#82776)
* move glossary entity_id migration from v65 to v64 for backport

* reset Liquibase changelog cache in glossary precondition test

* Scope glossary precondition test column check to the current schema
2026-09-18 18:06:53 -07:00
Tyler Plude
b08a2f0e65 [GHY-4593] Make remote sync robust against restarts (#82553)
* add remote_sync_task.last_heartbeat_at and a per-task heartbeat thread

* always close a remote sync task row when its worker exits

* close this JVM's running remote sync tasks on graceful shutdown

* reap stale remote sync tasks at boot and when the current-task endpoint observes one

* keep a committed remote sync version as the sync base when its task is cancelled or fails afterwards

* return the initiating user on remote sync task responses

* show interrupted, cancelled, and quiet states in the remote sync progress modal

* report remote sync import progress through every phase with one throttled reporter

* use with-dynamic-fn-redefs for the heartbeat touch-task! redefs in remote_sync_task_test

* log the ids of stale remote sync tasks when the reaper closes them

* fix migration

* materialize data apps inside the import's version transaction

* discover a running or stuck sync from a fresh tab

* drop the unreachable sync interrupted view
2026-09-18 15:43:48 -07:00
John Swanson
22b1a393dc Read c3p0 pool stats from the pool objects instead of JMX (#82760)
* Read c3p0 pool stats from the pool objects instead of JMX

The Prometheus c3p0 collector enumerated every pool MBean and read its
attributes over JMX. A JMX read locks the MBean and then the pool, while
pool construction locks the pool and then the MBean
(swaldman/c3p0#95). The collector guarded against this by holding the
warehouse pool guard, but the application, Quartz and semantic-search
pools are built outside that guard, so a scrape overlapping one of those
constructions deadlocked and then held the guard forever: a fresh pod
passed its health checks but never completed a warehouse query.

`connection-pool-info` now enumerates c3p0's own registry
(`C3P0Registry/getPooledDataSources`, a copy made under the registry
lock) and reads each `PoolBackedDataSource` with the default-user
getters the MBean used. No JMX, no `locking`. A pool closed mid-scrape
throws and is skipped. Metric families, labels and the bug-reporting
endpoint's output shape are unchanged.

Warehouse pools of a non-default connection type get a name suffix
(`-write`, `-admin`, `-transform`) so two pools of one warehouse no
longer share a `database` label and overwrite each other.

The regression test scrapes on one thread while another builds pools
through the app-db path and a third builds a warehouse pool through the
guarded path; on the old collector it deadlocks within a second.

* Disable c3p0 JMX MBean registration

The Prometheus collector no longer reads pool MBeans, but any other
in-process JMX reader (the Datadog agent's JMXFetch, JConsole, a JMX
exporter) can still deadlock a pool construction the same way
(swaldman/c3p0#95). Set the c3p0 management coordinator to the null
one in resources/c3p0.properties so no pool or registry bean is
registered. c3p0 reads the key once through its layered config, with
system properties last, so an operator can restore the beans with
-Dcom.mchange.v2.c3p0.management.ManagementCoordinator=...ActiveManagementCoordinator.

Verified with a standalone harness: with the property, 37534 pool
creations under a concurrent JMX reader over 30 s with no deadlock and
no c3p0 beans; with the -D override, the beans return and the harness
deadlocks in 0.5 s, as it does without the property.
2026-09-18 14:44:18 -07:00
appleby
f58c520891 Require channel write permission to send to a channel by id (#82608) 2026-09-18 14:15:33 -07:00
Nathan Voxland
f2364b1d27 Format the blocked table's ID as a plain number in error message (#82772)
Don't format ids in error messages as if they were normal numbers
2026-09-18 20:35:31 +00:00
github-automation-metabase
39bd87ede3 Tighten ratchets (#82777)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-18 20:20:06 +00:00
Tyler Plude
56232fc3f8 [GHY-4600] Improve memory allocation of remote sync (#82625)
* move the serdes :field normalize schema into a var

* extract the library once on a merging pull or push

* stream the export preflight extraction instead of realizing it

* index each merge side once for the merge and its casualties

* fix test

* share one registered :field normalize schema between serdes and resolve

* test the merging pull and push extract the library once
2026-09-18 13:14:18 -07:00
Michael Eby
61b6635f13 docs: IP allowlist (#82761)
* docs: IP allowlist

* edits from jeff
2026-09-18 15:16:45 -04:00
Alexander Polyankin
86b1f4bca4 Transform tests (#82495) 2026-09-18 14:50:16 -04:00
Eric Normand
ac1f4cdc00 Add a glossary tool to the MCP server (#82696)
* Let a v2 MCP tool describe itself from the instance's own data

deftool gains an optional :description-suffix, a fn of no arguments whose
result is appended to the tool description on every tools/list. The static
part stays in the manifest cache and only the suffix is recomputed, so a
suffix that reads the app DB doesn't drag the schema conversion along with it.

tools-hash skips it on purpose. The keepalive polls that hash every 30 seconds
on every open stream, so covering descriptions would buy a change notification
for a suffix at the price of its query per stream per tick. A suffix reaches a
client when it next lists tools instead.

* Add a glossary tool to the MCP server

glossary() returns every term with its definition; glossary(term) returns
one, matched case-insensitively, since a model echoes a term as it read it
rather than as it happens to be stored.

The term names ride the tool's own description rather than waiting behind the
call. A model only looks a word up when something tells it the word is worth
looking up, and the terms that most need a company definition are the ones
that read as ordinary English -- account, active user, churn -- which a model
believes it already understands. Metabot gets this for free by pasting the
whole glossary into every message it sends, because it owns the prompt; here
the description is the only channel that reaches the model unprompted.

Definitions stay behind the call, and the term list is capped at 100. Nothing
bounds a glossary -- no cap on entries, and a definition is TEXT -- and a
description long enough for a client to truncate costs more than its tail is
worth.

A client may cache a description for the life of its session, so a term added
mid-session shows up when that client next connects.

* Keep glossary out of the driver-test trigger set

mcp now uses glossary, and mcp's transitive dependents are effectively the
whole graph because some modules declare :uses :any. That made a glossary
change reach driver, query-processor and transforms, so every glossary edit
would have run the full driver suite. mcp is already excused from this for the
same reason; glossary joins it rather than raising max-allowed-count, since
none of it is driver logic.

Also fixes two whitespace-linter errors: a literal U+2028 landed in the
glossary test where an escape sequence was meant. The separator is now built
with char, which is what the test was trying to say anyway.

* Reject a :description-suffix that only takes one argument

ifn? was the wrong predicate. Keywords, maps, sets and vectors satisfy it but
are callable only with one argument, so they passed registration and then threw
ArityException at the first tools/list -- the deferred failure the guard exists
to prevent. Worse, the accepted registration sat in the shared registry, so the
throw came from listing any tool at all, not from the one that was wrong.

Vars stay allowed: one holding a fn of no arguments is callable, and :handler
is already registered as a var.

* Point the MCP server instructions at the glossary

The initialize instructions are the only channel that reaches the model before
any tool call, so this is where the glossary gets announced.

The line is unconditional on purpose. An instruction to look a term up when you
don't recognize it never fires, because the words that most need a company
definition are the ones that read as ordinary English -- account, active user,
churn -- which a model believes it already understands. So it asserts that
these terms matter and to fetch them before answering, with no qualifier about
which words. A test pins that: it fails if the line goes missing, and it fails
if the line grows a "don't recognize" style qualifier.

Static, so the instructions stay a compile-time constant. Whether an instance
has any terms at all is reported by the tool's own description instead.

* Instruct rather than list, and page the glossary listing

Three fixes from review.

The description no longer names the terms. The old cap was on the number of
terms, but the thing being protected is the length of a description clients
truncate -- Claude Code at 2048 characters. Seventy ordinary-length terms
produced 2444 characters with no pointer to the rest, so the client cut the
tail and the model saw a partial list it had every reason to read as the whole
glossary. That is worse than no list: a model believing the glossary is covered
never calls. So the description now carries the count and an unconditional
instruction to call before answering, which is inherently bounded and asks the
model to notice nothing.

glossary() is paged. It rendered every row, term and definition, into one
block, with definition a TEXT column that nothing bounds at any layer. Now
limit and offset through the usual list envelope, defaulting to 50 and matching
browse_collection -- the closest analogue, a plain unranked listing of rows
carrying prose.

glossary(term) returns every match. term is unique case-sensitively, so "ARR"
and "arr" are two entries with two meanings, and the lookup is deliberately
case-insensitive because a model echoes a term as it read it. Taking the first
match silently picked one and gave no sign the other existed.

* Make the glossary tool description static again

The suffix reported how many terms an instance defines. The only thing that
bought was letting a model skip one call on an instance with no glossary --
a call that is cheap and self-correcting, since it returns "no terms are
defined" and the model moves on. That does not pay for a public extension
point on deftool, its load-time validation, and a query of every glossary row
on every tools/list.

So the instruction moves into the static description and the machinery goes.
registry.clj is byte-identical to its state before this branch; every change
it had carried was in service of the suffix, including the list-tools split
that existed only so tools-hash could avoid computing one.

The instruction itself is unchanged in substance and still unconditional: call
glossary() before answering any question about this instance's data, with no
qualifier about which words, because the words that most need a company
definition read as ordinary English and the question will not say which ones
are defined.

* Simplify description

* Fixes

* Read the glossary through its module API, not its db namespace

The MCP tool required metabase.glossary.db, which meant exporting another
module's raw Toucan namespace just to read from it. metabase.glossary.core is
the module's stated API namespace and says so in its own docstring; it only
lacked a read, which is why db had to be exported at all.

So core gains entries, and db leaves the module's :api. Nothing outside the
module reaches into it now.

* Match the glossary tests to the simpler unknown-term error

The unknown-term error no longer names the terms that do exist, so the two
tests asserting it did were red. They now assert what it says: the term the
caller asked for.

The third was the cleaning guard, which had been riding on that error naming a
stored term. Retargeted onto a successful lookup, where stored text still
reaches prose the model reads as the server speaking. It now covers the stored
definition as well as the term -- definitions had no cleaning coverage before,
and the definition is the longer field and the one a payload would fit in.

The listing is deliberately not covered: list-content JSON-encodes its envelope
and wraps it raw, so it is bounded by JSON escaping rather than by clean, and
asserting otherwise would have been false.
2026-09-18 13:14:17 -05:00
Bartosz Rybacki
0e83852557 GDGT-3137: accept emoji up to 10 code points as a comment reaction (#82652)
* GDGT-3137: accept emoji up to 10 code points as a comment reaction

The emoji schema counted UTF-16 units, so the England flag (7 code
points, 14 units) got a 400. Postgres and MySQL count varchar(10) in
code points and already fit every picker emoji, so only the schema and
the H2 column change. The MySQL collation set by v63 stays untouched.
2026-09-18 20:04:39 +02:00
Nathan Voxland
61d0fa5d0f Check permissions on nested cards (#82600) 2026-09-18 13:02:41 -05:00
Kevin Kelleher
504b8ea9ee Enable explorations (#82594)
* Revert "Disable explorations (#82230)"

This reverts commit c505a9a785.

* add back test improvements

* Restore up-front profile validation in the agent endpoint

The "Disable explorations" PR (#82230) also fixed an unrelated bug: a
request naming a nonexistent profile was not rejected up front. The SSE
stream opened, the turn was persisted, and the conversation-title LLM
call was made before anything failed. Reverting that PR here dropped
the fix along with the explorations changes.

This re-applies only the validation: `profile-registered?` in the
profiles registry, and a 400 "Unknown profile" check in
`streaming-request` right after the profile id is resolved and before
`start-turn!` persists anything or the title job runs. Explorations
stay registered.

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-09-18 13:12:20 -04:00
Mahatthana (Kelvin) Nomsawadi
61ce133268 test: Fix failed split permissions modal test (#82757)
test: drop the wall clock from the split modal test

The test gave the metadata request a 100ms fetch-mock delay and then
asserted the loader was still showing. On a loaded CI runner the
render plus the awaits burn through that budget, the loader is gone
and getByTestId throws. Resolve the metadata response explicitly
instead, so the loading window does not depend on timing.

Fixes EMB-2405


Claude-Session: https://claude.ai/code/session_01GuH5ewGP1DzQs5jwNpTgWc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:54:18 +01:00
github-automation-metabase
f24251fb4e Auto-update documentation for master (#82710)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-18 11:51:01 -04:00
Timofey Kachalov
7994ad35d8 Add data-apps scope to BE endpoints to prevent calling unmarked endpoints from inside a data app (#79382) 2026-09-18 19:34:58 +04:00
Timofey Kachalov
eb845c2ea6 Add version field support to data_app.yaml (#82557) 2026-09-18 18:57:25 +04:00
John Beers
3d8a3901a9 docs: update URL configuration (#82744)
[docs] Add theme keyword
2026-09-18 09:11:12 -04:00
Oleksandr Yakushev
16bd231ba7 [spec] Prevent t2.transformed/in-transforms from throwing on :model/ModelIndexValue (#79634) 2026-09-18 13:00:59 +00:00
Maksym Yakubych
89da5b2bc6 Fix unreadable low-rating data complexity badge (#82665) 2026-09-18 12:00:54 +02:00
Romeo Van Snick
91f5798258 Make SQL editor typing responsive again (DEV-3545) (#82724)
* Stop reconfiguring the SQL editor on every keystroke (DEV-3545)

* Apply native editor edits outside the keystroke (DEV-3545)

* Defer editor edits with Mantine's debounced callback
2026-09-18 19:58:46 +10:00
Jakub Chodorowicz
e49c2e60af Gdgt 3238 release sdk v2 canary1 (#82733)
release v2 canary.1
2026-09-18 11:05:32 +02:00
Mahatthana (Kelvin) Nomsawadi
496db401a1 fix: keep permissions tabs visible while data loads (#82633)
* fix: keep permissions tabs visible while data loads

The Data tab returned a bare spinner until databases, groups and
permissions loaded, so the tabs above it vanished on slow networks.
The page layout now always renders and the spinner shows in its
content area, like the Collections tab.

Fixes EMB-2392

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

* fix: open the split permissions modal after load

The Data tab now renders its layout while data loads, so the one-time
split permissions modal could open over the spinner. Dismissing it
there turns the user setting off before the table it explains shows.
Only allow the modal once loading finishes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 08:05:46 +00:00
Kevin Kelleher
188c8f412f Fix failing Loki test caused by #82684 (#82713)
fix loki failure
2026-09-17 20:14:55 -07:00
Aleksandr Lesnenko
62831f5e4d infer db from query in transform_write mcp tool (#82461)
* infer db from query in transform_write mcp tool

* dedupe

* review

* fix
2026-09-17 23:20:26 +00:00
Romeo Van Snick
d5d6eba4c5 Move ErrorBoundary into common (#82624)
Claude-Session: https://claude.ai/code/session_01FeHJzstcAE34DTu4G6btjx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:16:47 +10:00
Romeo Van Snick
a3b46fdd3c Return a question from the card hook, not a builder (#82602)
* Return a question from useQuestionFromCard instead of a builder

* Stop exporting the provider factory selector, which has no callers

* Memoise the connected-table link url
2026-09-18 09:16:37 +10:00
Romeo Van Snick
61c0e2915e Stop thirteen sites reaching through question.metadata() (#82552)
* Read the model's table, the list's entity type and the native editor's table from their own requests

* Take the native preview's provider from the store and the metabot table names from Lib

* Let the question carry its own metadata into derived questions and steps

* Take the pristine table URL, the native editor's databases and the last-run card off the Metadata object

* Mock the databases endpoint in the inline SQL prompt spec
2026-09-18 09:16:23 +10:00
Eric Normand
5701125c34 Per-scope checkboxes on the OAuth consent page (GHY-4555) (#82527)
* List every MCP v2 tool regardless of token scopes (GHY-4543)

tools/list filtered out tools the token lacked scope for, so a client
holding a narrow grant never saw the write, SQL or delivery tools and
had nothing to attempt or step up for. Scope stays enforced at
tools/call; the disabled-tools and client-extension filters still apply.

With scopes out of the listing, list-tools and tools-hash drop their
token-scopes parameter, and the transport's tools-hash-fn becomes
zero-arity.

* Explain MCP v2 scope denials to the model (GHY-4543)

Clients replace a scope denial with their own text ("token expired",
"Insufficient scope", "Tool execution failed"), so the model tells the
user their login expired. Server instructions now say that failure is a
missing permission, and how the user reconnects to grant it. Every tool
description ends with a static sentence naming its required permission by
its consent-screen label, identical for every token: Claude Code caches the
first description it loads for the whole session.

* Answer MCP v2 scope denials with 403 insufficient_scope (GHY-4543)

A scope denial was an HTTP 200 carrying a JSON-RPC error or an isError
result, so clients never stepped up: Claude Code only saves a step-up
scope, and mcp-remote only starts one, on a real 403 with an
insufficient_scope WWW-Authenticate challenge. The registry gate and the
four in-handler scope checks now mark their refusal, and the transport
answers a single (non-batched) message with 403 plus a challenge whose
scope is the held v2 scopes plus the required one.

* Keep a DCR client's registration scope from narrowing its step-up (GHY-4543)

Claude Code registers with the scope it read from the protected-resource
metadata, then steps up on the same client_id with the wider scope from a
403 insufficient_scope challenge. oidc-provider validates a requested scope
against the client's registered scopes, so that step-up was refused with a
400 invalid_request. Once scopes_supported is narrowed to the baseline, no
Claude Code user could reach write, SQL, or delivery.

A dynamically registered client's ceiling is now its registered scopes plus
the default grant scopes, computed on read. Registering can widen the
ceiling (a client that registers for mb:full keeps it) but never narrows
it. RFC 8707 resource narrowing is unchanged.

* Declare each MCP v2 tool's OAuth scope in securitySchemes (GHY-4543)

ChatGPT only steps up for scopes a tool declares: without per-tool
securitySchemes it re-authorizes for its narrow login scope in a loop,
and with them it re-authorizes once for held plus declared scopes.
The scheme is derived from the tool's static :scope in the cached
manifest, so it is the same for every token.

* Advertise only the baseline MCP v2 scopes to new clients (GHY-4543)

The protected-resource metadata `scopes_supported` and the 401 challenge
`scope` now list only `agent:content:read agent:resource:read`, so users
who never need write, SQL, or delivery never grant them. Claude Code and
the Claude connectors take their first-login scope from the resource
metadata; ChatGPT takes it from the challenge. All of them reach the rest
through the 403 `insufficient_scope` step-up and per-tool
`securitySchemes`.

What the resource accepts is unchanged: RFC 8707 narrowing still keeps all
six v2 scopes, and the authorization-server metadata stays wide because
Codex requests that list on every login and cannot step up.

* List every MCP v2 resource regardless of token scopes (GHY-4543)

resources/list now matches tools/list: it is token-independent, so a client can
see the resources it could step up for, and a client that pre-fetches the MCP
Apps shells before stepping up to agent:query:run does not cache them as
missing. resources/read keeps its scope gate.

* Answer MCP v2 resources/read scope denials with 403 insufficient_scope (GHY-4543)

resources/list now shows every resource, so answering a denied read with
"Resource not found" hides nothing and leaves a client with no step-up
signal. A client that pre-fetches an MCP Apps shell with a baseline token
now gets the same challenge tools/call sends, naming agent:query:run.
Unknown URIs stay -32602; inside a batch the denial is an in-band -32600.

* Lead MCP v2 tool descriptions with their required permission (GHY-4543)

Clients truncate long tool descriptions (Claude Code at 2048 characters),
so the appended "Requires the ... permission" sentence was cut from
document_write and execute_query. The model needs that sentence to name
the missing permission after a scope denial, so it now opens every
description, and the server instructions say it starts the description.

* Serve every MCP v2 resource on resources/read regardless of token scopes (GHY-4543)

Claude Desktop reads an MCP Apps shell alongside the tool call. When that
read was refused with 403 insufficient_scope, Desktop never stepped up after
the tool call's own 403; with the shell served, it stepped up automatically
and retried. The shell carries no data and, for a token without the shell's
scope, no UI credential: read-resource now withholds the credential instead
of refusing the read. Chart data stays gated by the tool call,
refresh_ui_credential, and the UI-surface route costs.

* Restore the scope check on MCP v2 data resource reads (GHY-4543)

Only the MCP Apps UI shells need to be served to any token: a refused shell
read stopped Claude Desktop stepping up after the tool call's 403. The fields
catalog carries data and has no such constraint, so reading it without
agent:resource:read again gets the 403 insufficient_scope challenge (in-band
-32600 inside a batch). UI shells stay unscoped, minting a credential only for
a token holding their scope.

* Fit MCP v2 tool descriptions under Claude Code's 2048-character limit (GHY-4543)

Claude Code truncates tool descriptions at 2048 characters, so document_write and
execute_query lost their closing guidance, including execute_query's pointer away
from execute_sql for filtered-dashboard cards. Condense both, and get_content for
margin, deferring detail to learn() topics and argument schemas. A test now holds
every listed description to the limit.

Also correct docstrings and comments left stale by the resources/read and
tools/list scope changes and by credential delivery through refresh_ui_credential.

* Spell out the scope union in the MCP v2 challenge docstring (GHY-4543)

The whitespace linter rejects the confusable Unicode character U+222A.

* Add agent:query:run to the MCP v2 baseline scopes (GHY-4543)

Claude Desktop retries a tool after a step-up over a session that does not
declare MCP Apps support, so a stepped-up visualize_query is refused and the
first chart after a step-up never embeds. Querying and charting therefore
must not require a step-up. The protected-resource metadata, the 401
challenge and default-ask-scopes now advertise agent:content:read
agent:query:run agent:resource:read; writes, raw SQL and delivery still step
up.

* Point MCP v2 chart requests at visualize_query in the server instructions (GHY-4543)

The routing line named only execute_query, and clients like Codex load tool descriptions on demand, so chart requests could miss visualize_query.

* Mint only the scopes chosen on the OAuth consent decision (GHY-4555)

The signed scope stays the offered set. The user's choice arrives in a
separate, repeatable granted_scope field and may only narrow it: a choice
outside the offer is rejected as params_tampered, and an approval that
leaves nothing granted is a 400. Offered MCP baseline scopes are always
granted. The token response already echoes the code's scope, so it now
names exactly what was granted.

* Add per-scope checkboxes to the OAuth consent page (GHY-4555)

Each offered scope is a granted_scope checkbox inside the consent form.
The six MCP v2 scopes are listed least to most harmful, with any other
scope after them in request order. The MCP baseline scopes are ticked,
disabled, and marked as always granted; every other scope, mb:full
included, starts unticked. The full-access warning now sits in the
mb:full row.

* Gate native query handles in transform_write on agent:sql:run and the kill switch (GHY-4543)

A query handle is not proof the SQL gates were spent: /api/embed-mcp/drills
stores a native query under agent:query:run alone. transform_write now runs
the same scope and mcp-execute-sql-enabled checks on a handle that resolves
to native SQL as it does on an inline native definition, matching
question_write.

* Tighten resource registration, step-up scopes, and the DCR ceiling (GHY-4543)

- register-resource! takes a closed map and strips :ui?, so a data resource
  can never be served to a token without its scope.
- step-up-scopes matches held scopes instead of looking them up, so a token
  holding a wildcard such as agent:content:* keeps the surface scopes it
  covers when a client replaces its grant with the challenged scope. It now
  takes the single required scope both callers pass.
- The default grant ceiling is applied only while dynamic registration is
  enabled, so turning registration (or MCP) off stops widening existing
  dynamic clients. The merge is now a pure function with unit tests, and an
  HTTP test pins that static clients are never widened. The legacy-client
  migration test runs with registration disabled, where the stored snapshot
  alone decides, so it exercises the migration again.

* Debounce the OAuth consent decision and require a ticked scope (GHY-4568)

The consent page gets an inline script, nonce'd and opted into a
script-src nonce so a production CSP allows it. On submit it shows a
spinner on the clicked button and disables both, carrying the decision
in a hidden input since a disabled button is not submitted, so a second
click can no longer race the first. The buttons come back when the page
is restored from the bfcache. It also keeps Authorize disabled while no
scope checkbox is ticked (GHY-4555).

* Correct MCP v2 tool descriptions, the documents skill, and server instructions (GHY-4543)

- document_write lists the valid entity-link models again.
- The documents skill said edits store new_str literally; the tool parses it
  as Markdown, as edit-replacement-parses-markdown-test pins.
- execute_query no longer claims to save cards (its handles save as cards),
  and names the previous-stage field ref shape instead of an ambiguous
  column-name string.
- The server instructions attribute the rewritten permission failure to the
  client and cover resource reads, which get the same 403.

* Tell the MCP v2 model which scopes the connection holds (GHY-4555)

The initialize instructions are now built per request from the calling token's scopes. A scoped token is told which v2 surface permissions it has and lacks, by consent-screen label; an unrestricted caller gets no list. The instructions also stop claiming the consent screen has no per-permission choices: a permission the connection lacks starts unticked, so the model warns the user to tick it.

* Note the unticked consent checkbox in the MCP v2 insufficient_scope description (GHY-4555)

Tool and data-resource 403 challenges now end their error_description with a note that the permission starts unticked on the consent screen and the user must tick it. The README describes the new consent screen and the per-connection instructions.

* Rename the agent:resource:read consent label to "Read MCP resources" (GHY-4555)

* Tighten the MCP v2 scope instructions (GHY-4555)

The general paragraph states the scope-failure guidance once; the per-connection paragraph carries only the held and missing permissions and how far to trust that list.

* Pin the MCP v2 scope-denial and UI-credential contracts in tests (GHY-4543)

Closes gaps the review found, where removing a check would stay green:
- question_write and subscription_write in-handler denials carry
  :insufficient-scope at the registry, like alert_write's.
- A denied tools/call sent as a notification is a bare 202, no challenge.
- Wildcard and unrestricted tokens get the shell credential; the fields
  catalog mints none, over the registry and over HTTP.
- tool-and-resource-scopes-agree-test asserts the credential is realized,
  since any token now reads a shell.
- advertised-scopes-are-distinct-test covers the advertised baseline, which
  is a vector and can repeat, and its docstring says so.

* Clean up MCP v2 scope-denial helpers, header quoting, and docs (GHY-4543)

- Share one scope-denial message and detail builder between tool calls and
  resource reads, one step-up challenge helper, and a common
  throw-insufficient-scope! for the four in-handler scope checks.
- quoted-string replaces " and \ instead of backslash-escaping them, since
  RFC 6750 excludes both from scope and error_description values; the 401
  challenge now quotes through it too.
- Trim docstrings to their contract and move the reasons into comments.
- The unrestricted UI credential comes from any unrestricted session (cookie,
  API key, or an mb:full token), not only cookie sessions.
- Note that the DCR ceiling applies only while registration is enabled, and
  fix stale comments, test names and strings, long lines, order-sensitive
  scope assertions, and hard-coded consent labels in tests.

* List scope IDs only in the MCP v2 instructions, have the model make the call once the user agrees, and cap instructions at 2048 characters (GHY-4555)

* Pre-tick consent scopes the same app already holds (GHY-4555)

A step-up asks for the held scopes plus the new one, and every non-baseline box started unticked, so ticking only the new scope silently dropped a held one. A scope that a live access or refresh token of the same app holds now starts ticked but stays enabled. The same app is the same client_id, the exact same https redirect, or a loopback redirect with the same host, path and client name on any port, matched against the redirect this request delivers the code to.

* Narrow the same app's tokens when a held scope is unticked (GHY-4555)

Unticking a pre-ticked scope now removes exactly that scope string from the same app's other live access and refresh tokens, so the choice holds across windows. Only scopes offered on the screen and held by the app are removed; nothing is ever added, and a token is revoked only when no scope is left. This runs at the approve decision, after the code is issued, because the authorization code row has no field to carry the declined scopes to the token exchange without a migration.

* Tell the MCP v2 model that reconnecting before a refused call won't offer the permission (GHY-4555)

* Always widen dynamic OAuth clients to the MCP scopes (GHY-4543)

Claude Code registers with the baseline scopes and steps up on the same
client_id. With dynamic registration turned off, that step-up /authorize
was answered with a raw 400. A dynamic client now always gains the six v2
MCP scopes on read, each still behind consent; the remaining agent-API
ceiling is added only while registration is enabled. Registration itself
stays blocked.

* Close the MCP v2 scope test gaps (GHY-4543)

Cover the transform_write update path's native gates, the HTTP 403
insufficient_scope challenge question_write and transform_write send from
inside their handlers, saving a drill handle minted through
/api/embed-mcp/drills, and the closed resource registration schema. Give
the locale test a positive control, merge the two tools/list
token-independence tests into one that compares the whole payload, and
drop the permission-sentence truncation test the fit and starts-with
tests already cover.

* Tidy MCP v2 scope wording and naming (GHY-4543)

Rename registry/insufficient-scope to insufficient-scope-detail so it
does not read like the transport fn of the same name. Drop the server
instructions clause telling the model to read a resource's scope off an
error clients hide. Document agent:resource:read and the handler-internal
scope checks in the module README, correct the stale comments about UI
resources gating on agent:query:run, and wrap the lines this branch left
over 120 columns.

* Let the test fallback template win over a local frontend build (GHY-4543)

The MCP Apps shell renders from the built `embed-mcp.html` when one exists,
so a worktree that has run a frontend build shadowed the inline fallback the
credential tests install. The production template has embedded no
`uiCredential` since #81041, so those tests failed locally and passed on CI,
where backend-only runs produce no build.

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

* Tell the MCP v2 model a permission can be removed mid-session (GHY-4555)

* Drop cross-connection scope narrowing from the OAuth consent decision (GHY-4555)

Unticking a scope the same app already held used to strip it from that app's
other live access and refresh tokens. Every verified defect in this PR lived in
that path:

- the authorization code and the approved event are persisted before the
  narrowing runs, and both sit inside one `try`, so a narrowing failure answers
  400 while a redeemable code exists and the old tokens keep the scope;
- dynamic client registration is open, so an attacker can register a client
  carrying another app's https redirect -- or a loopback redirect with a copied
  `client_name`, since the port is ignored -- and strip or revoke the real app's
  tokens after phishing a single Authorize click;
- a pending authorization code is not narrowed, so redeeming one mints a
  just-removed scope straight back.

No comparable provider does this: GitHub, Google, Microsoft and Slack all leave
an existing grant alone when a new authorization asks for less. Taking a granted
permission away deserves its own flow, with its own confirmation and audit
trail; a follow-up ticket covers it.

What stays is the pre-tick: the consent page still ticks a scope the same app
already holds, so a step-up does not silently drop it, and the same-app matching
that finds those tokens is unchanged. A test now pins that an untick leaves the
app's other live tokens -- access and refresh -- exactly as they were.

Also drops the two db.clj scope-update helpers nothing else used, and corrects
two claims the removal falsifies: the MCP `initialize` instructions no longer
tell the model that another window can untick a permission mid-session (a
re-authorization without it, or a revocation, is how it goes away now), and the
MCP README says the connection's scopes are listed by scope ID rather than by
consent-screen label.

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

* Store a repeated OAuth scope once in the granted token (GHY-4555)

`scope=agent:content:write agent:content:write` was split without
deduplication, and the decision filters the grant from that list, so the
duplicate reached the token row and the token response's `scope`. The consent
page already deduplicated for display, so nothing on screen hinted at it.

Deduplicate in `scope-tokens` instead, which is the one place both the page and
the decision read the requested scopes from.

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

* Pre-tick the OAuth consent page from the app's newest live grant (GHY-4555)

The held set was the union of every live token the app had for the user. A
refresh token outlives several authorizations, so the union let an old, wider
grant keep setting the defaults: day 1 grants content:write and sql:run with a
30-day refresh token, day 5 the user re-authorizes and leaves sql:run unticked,
and on day 10 the union pre-ticks sql:run again from the day-1 token -- silently
undoing the day-5 decision every time they re-authorize.

Take the newest live grant instead, so the user's latest choice is the default.
A grant's access and refresh rows are written by one token request and share a
`created_at`, so the newest timestamp names a grant rather than a row: every row
carrying it speaks for that grant, which is what keeps the newest grant readable
once its access token has expired and only its refresh token is left. Erring
narrow is safe now that an untick only shapes the token being minted.

Also records why the loopback key ignores the port, and what that costs, next to
the branch that decides it.

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

* Drop consent-page pre-ticking of already-held scopes (GHY-4555)

Every non-baseline scope now renders unticked, whatever the user already
holds. Pre-ticking needed to identify the connection being stepped up,
and the consent page cannot:

- the step-up challenge expands a wildcard grant like `agent:content:*`
  into concrete scopes, but the held check matched exact strings, so
  `content:write` started unticked and a step-up dropped it;
- refresh rotation writes new rows with a current `created_at`, so an
  older broad grant refreshed after a newer narrow one looked newest and
  re-ticked a scope the user had dropped;
- the newest app-wide grant is not necessarily the connection stepping
  up: window A holds content:write, window B authorizes later without
  it, then A steps up and gets B's defaults;
- prechecking across registrations is a consent boundary. Registration
  is unauthenticated, so a local app copying the client name and
  loopback callback path, or a client registering another app's https
  redirect, got those scopes from one generic Authorize click instead of
  the user selecting them.

Doing it properly needs grant identity carried into the authorize flow,
the redirect recorded on tokens, and wildcard-aware matching. GHY-4627
tracks that.

Removes `metabase.oauth-server.held-scopes` and the `db` helpers that
existed only for it. A test pins the removal: with live access and
refresh tokens holding `agent:content:write`, the consent page still
offers it unticked.

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

* Correct two MCP scope claims in the README (GHY-4555)

Per-tool `securitySchemes` is draft SEP-1488 (modelcontextprotocol issue
1488), supported by ChatGPT. It is not part of MCP 2025-03-26, the
version this server reports, nor of the final 2026-07-28 tools spec, so
other clients learn a missing scope from the 403 instead. The comment
above `default-ask-scopes` implied it was generally available too.

Automatic scope step-up is likewise defined in MCP 2026-07-28, so
clients are not required to implement it against this server: a scope
left unticked can trigger another step-up in clients that support it,
while others need manual reauthorization.

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

* Drop the per-connection permission list from the MCP v2 instructions (GHY-4555)

The list caused the failure the "make the call anyway" clause was added to
fix: with it in context the model sometimes refused a write without calling
the tool, so no 403 came back, the client saved no step-up scope, and the
user's re-authorization offered only the baseline — the permission never
appeared as a checkbox. Without the list and without that clause, the model
calls the tool, reads the 403, names the permission and gives the reconnect
steps.

So the `initialize` instructions are one static string again: general
scope-failure guidance, plus one sentence that every optional permission on
the consent screen starts unticked, so the user re-ticks the ones they want
to keep. `transport.clj` goes back to a plain `:instructions` option.

* Tell the MCP v2 model the tool list is not a permission grant (GHY-4555)

Every tool is listed whatever the token holds, but a scope-filtered list is
the conventional design and the protocol has no field to signal ours. Asked
what a baseline connection could do, the model read the roster as a grant and
answered that it held every agent scope, reasoning that the server filters
tools by granted scope -- true of Metabase until the filter was removed.

Only a failed call reveals a missing permission, so say that.

* Fix counterfactual docstrings and pin the offered scope non-blank (GHY-4555)

Two docstrings stated the behavior the dedup prevents as though it were the
behavior it has, so a reader chasing the invariant learned the wrong one.
Both now say "would be stored twice".

scope-to-grant gains a postcondition. The decision endpoint answers a nil
grant with 403 params_tampered, which is only the right status while the sole
way to reach it is a choice naming an unoffered scope. That holds because
scope-to-grant throws rather than returning a blank offer; the postcondition
turns a future relaxation into a failing assertion instead of an honest
approval reported as tampering.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:34:41 -05:00
Ryan Laurie
ecb8fe82b2 Merge automated PRs with the automation user's token so CI runs (#82701)
GitHub starts no workflow run for a push made with `GITHUB_TOKEN`, so
enabling auto-merge with it lands backports on release branches, and
translation updates on master, with no CI at all. Approving still uses
the other token, since a token cannot approve its own pull request.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:19:51 +00:00
Kevin Kelleher
40ebe0bb9f Fix static viz Row Chart failures on remapped columns (#82684)
* make remapping idempotent

* copy the existing remapping so it doesn't get mutated
2026-09-17 19:55:42 +00:00
Eric Normand
0700179dad GHY-4542: Reject unregistered and empty OAuth scopes; fail closed on scope-less bearer tokens (#82498)
* Reject unregistered scopes in OAuth dynamic client registration

POST /oauth/register is unauthenticated and stored any scope string the
client sent. A client's registered scopes are the ceiling /oauth/authorize
checks requests against, so a self-nominated `*` or `agent:*` could later
be requested and granted, and scope-matches? treats both as wildcards.

Registration now answers 400 invalid_client_metadata, naming the offending
scopes, when any requested scope is not a registered scope. An omitted
scope still gets the default ceiling, and `mb:full` is still accepted.

* Reject unregistered scopes at OAuth authorize

Clients that registered a wildcard such as `*` before registration
validated scopes can still request it, since /oauth/authorize only checks
a request against the client's own registration. /authorize now answers
400 invalid_scope when any requested scope is not a registered scope. The
check runs before resource narrowing, which would otherwise drop the
scope when `resource` is sent and keep it when it is not.

Both endpoints now use one error_description that points the client at
the authorization server metadata listing scopes_supported, instead of
echoing the scopes it sent.

Clients registered on v0.60-v0.61 still hold agent:database:read,
agent:table:read, and agent:metric:read, which are no longer registered;
requesting them now fails. The authorize test fixtures move from the
unregistered "profile" to agent:content:read.

* Reject OAuth authorize requests without a scope

A request to /oauth/authorize with no scope rendered a consent screen
listing no permissions and minted a token with no scopes. It now answers
400 invalid_scope whether scope is absent, empty, or whitespace. A blank
scope is dropped before the provider validates the request, so an unknown
client or mismatched redirect URI is still reported first.

The error_description names no request input and points at the
authorization server metadata listing scopes_supported.

The OAuth authorizations E2E spec drove consent without a scope; it now
requests agent:content:read.

* Reject an empty scope in OAuth dynamic client registration

A registration that sent scope as null, an empty string, or whitespace
was stored with no scopes, and such a client can never authorize now
that /oauth/authorize requires a scope. /oauth/register answers 400
invalid_client_metadata instead, with an error_description pointing at
scopes_supported.

Omitting scope still registers the client with the default ceiling, which
is what MCP clients that register without a scope rely on.

* Fail closed on OAuth bearer requests without token scopes

Nil :token-scopes means scope-unaware auth, and sessions, API keys,
the scope middleware, the agent API, and the MCP transport all let it
through as unrestricted. For an OAuth access token, only an empty set
surviving every hop kept a scope-less token from becoming unrestricted.

The session middleware no longer authenticates an access token with no
scopes, and marks requests it does authenticate with an OAuth token as
:authenticated-via-oauth?. Behind it, enforce-scope and
ensure-scopes-checked answer 403, the agent API stops defaulting nil
scopes to unrestricted, and the MCP transport answers its invalid_token
401 for an OAuth-authenticated request with no scopes.

The marker is set by the session middleware rather than inferred from an
Authorization: Bearer header, which the agent API JWT path also uses and
which a session request may carry alongside its session. Sessions, API
keys, JWT, and the MCP Apps UI credential never set it.

* Redirect OAuth authorize errors to the client per RFC 6749

GET /oauth/authorize answered every validation failure with a 400 JSON
body, which strands the user on a raw error in their browser tab and
leaves the client waiting for a callback that never arrives.

The endpoint now looks up the client and checks the redirect_uri is an
exact match for one it registered before validating anything else. If
either is missing or invalid, it keeps the non-redirect 400, as RFC 6749
section 4.1.2.1 requires. Every other failure redirects to that
registered redirect_uri with error, error_description, state, and the
RFC 9207 iss parameter, built by the library's denial redirect builder.

Error codes: invalid_scope for unregistered, missing, fully narrowed,
and unregistered-for-the-client scopes (the last was mislabeled
invalid_request); unsupported_response_type for a response_type the
client did not register; invalid_target for an invalid resource
indicator; invalid_request for PKCE problems and malformed parameters.

* Send RFC 6750 bearer challenges on scope-denial 403s and agent API 401s

RFC 6750 section 3 requires a resource server to answer a bearer token
that does not grant access with a WWW-Authenticate challenge. The scope
middleware's 403s, including the fail-closed ones for OAuth requests
without scopes, sent only a JSON body, so an OAuth client could not tell
it should re-authorize for more scope.

enforce-scope and ensure-scopes-checked now add
  Bearer error="insufficient_scope", scope="...", error_description="..."
with scope naming the endpoint's required scope, and omitted in
ensure-scopes-checked, where the endpoint declares none. The format and
value sanitizing match the MCP transport's insufficient_scope challenge.
The JSON bodies are unchanged.

The agent API's 401 for a bearer token that does not authenticate now
carries Bearer error="invalid_token".

* Answer malformed OAuth resource indicators with client errors, not 500s

A resource value that is not syntactically a URI, such as
"http://bad uri", made oidc-provider's resource check throw a
URISyntaxException rather than an ex-info. Nothing caught it, so
/oauth/authorize, /oauth/authorize/decision, and /oauth/token all
answered a client input error with a 500.

Each endpoint now checks that every resource indicator parses before
handing the request to the library:

- /oauth/authorize: once the client and redirect_uri are validated, an
  RFC 8707 invalid_target error redirect. Otherwise the existing
  no-redirect 400.
- /oauth/authorize/decision: the existing 400 invalid_request, with no
  redirect, since its parameters are untrusted until the signature
  verifies.
- /oauth/token: a 400 invalid_target.

Only URISyntaxException is caught, and only while parsing the resource
values.

* Send a plain Bearer challenge on agent API 401s without a bearer token

RFC 7235 requires a WWW-Authenticate challenge on every 401. The agent
API's missing_authorization 401 (no credentials) and
invalid_authorization_format 401 (a non-Bearer Authorization scheme)
sent only a JSON body.

Both now carry WWW-Authenticate: Bearer. There is no error attribute,
because RFC 6750 section 3.1 says a challenge to a request with no
bearer token should not include an error code. Status codes and bodies
are unchanged.

* Keep OAuth authorize errors non-redirecting

Delivering /oauth/authorize errors as a redirect to the client's
registered redirect_uri made Metabase a zero-click open redirector.
Dynamic client registration is unauthenticated, so anyone can register
a client whose redirect_uri points at their own site, and a single
/oauth/authorize link on the trusted Metabase host would then send any
logged-in user there with no consent step. RFC 9700 section 4.11 says to
answer with an error rather than redirect when the redirect URI is not
trusted.

Every error from GET /oauth/authorize is a 400 JSON body rendered in the
user's browser again, with no Location header. The login redirect for
unauthenticated users is unchanged.

The error codes stay accurate: a scope outside the client's registered
set is invalid_scope, an unregistered response_type is
unsupported_response_type, and an invalid resource indicator, including
one that is not syntactically a URI, is invalid_target. Malformed
resource indicators still cannot reach the 500 they used to.

* Drop unregistered scopes at /oauth/authorize instead of refusing

Registration and authorization see the same problem from different
sides. An error from /oauth/register reaches the client program at setup
time, where it can act on it, so that endpoint keeps rejecting
unregistered scopes outright. An error from /oauth/authorize lands in a
user's browser tab as JSON while the client waits for a callback, and it
also breaks step-up for a client that still holds a scope shipped in
v0.60-v0.61 and since removed from the registry.

/oauth/authorize now filters the requested scopes down to the registered
ones and carries on with what survives, which RFC 6749 section 3.3
allows. Filtering runs before resource narrowing, so a request behaves
the same with and without a resource indicator, and the consent page and
the minted token carry exactly the surviving scopes. Least privilege is
unchanged: an unregistered scope, including a wildcard a client
registered before registration validated scopes, still can never be
granted.

A request in which nothing survives the filter is refused with
invalid_scope and its own description, distinct from the one for a
request narrowed away by its resource indicator. A missing, empty, or
whitespace-only scope is unchanged.

* Enforce the scope rules where the authorization code is issued

The HMAC over the consent form is keyed by the CSRF token, which the
consent page prints for the user, so the user can recompute it over
whatever scope they like. It proves no third party tampered with the
form; it does not prove the scope was ever validated.
/oauth/authorize/decision is what issues the code, so it now applies the
scope rules itself instead of trusting the signature.

Both endpoints call one scope-to-grant function: refuse a missing or
blank scope, drop the scopes that are not registered, refuse when none
survive, and grant exactly what is left. A denial grants nothing and
skips it. /oauth/authorize behaves as before; a refusal at the decision
endpoint keeps its existing 400 JSON shape and never redirects.

A correctly signed form asking for a scope the consent screen never
showed was previously honored: a re-signed wildcard minted a token
carrying it, and a re-signed blank scope minted one with no scopes at
all.

* Tidy the OAuth scope and challenge code

Follows the style review. No behavior change except one described below.

- narrow-scope-to-resource's docstring described a contract it no longer
  has: that nil means both "nothing requested" and "nothing survived",
  that the caller may drop the parameter, and that narrowing can never
  turn a valid authorization into a rejected one. It returns nil when no
  requested scope survives, and the caller refuses.
- One check-resource-parseable! replaces the same pre-check written three
  times with three different ex-data shapes, and the note about why it
  exists sits on unparseable-resource! once instead of at each call site.
- invalid_target now always carries the description saying what a valid
  resource indicator looks like. It used to depend on where the error
  came from: the pre-check said so, while a relative indicator or one
  with a fragment fell back to the generic description. This is the one
  behavior change, and the authorize test that pinned the generic
  description is updated.
- The three inline invalid_client_metadata 400s in /register go through
  one helper.
- The error_description strings and their helpers move out of the
  module's core namespace, where they were public with a single caller,
  into the api namespace as private defs grouped together.
- The MCP transport's two invalid_token 401s were identical; they share
  one response now.
- The agent API's error-response takes the WWW-Authenticate challenge as
  an argument, which is what all three of its callers were adding
  afterwards.
- Dropped an inaccurate comment about parameter order matching the MCP
  transport's challenge, some rationale that belongs in line comments
  rather than docstrings, and a double negative.

* Tighten the OAuth scope and challenge tests

Follows the style review.

- quoted-string is now exercised directly with a quote, a backslash and
  non-ASCII input. The charset assertion it replaces only ever saw
  constants, so it could not fail.
- The agent API's invalid_token 401 test moves to the agent API tests,
  where the behavior lives, and its no-credentials case goes: that is
  already covered there.
- Deleted bearer-token-without-scopes-gets-no-tool-access-test. Its 401
  comes from the transport's invalid-bearer branch, because the session
  middleware declines a scope-less token before the transport sees it, so
  it passed with or without the fail-closed branch and duplicated
  bearer-bridge-refuses-token-without-scopes-test. The branch itself is
  covered by oauth-request-without-token-scopes-is-refused-by-the-
  transport-test, which invokes the handler directly.
- The DCR unregistered-scope test compares the whole error_description
  instead of a substring plus assertions that cannot fail.
- Scope-denial tests assert the exact error code each case produces
  rather than accepting either.
- A test description stated its own history; it states the behavior now.
- Wrapped lines over 120 characters.

* Declare :authenticated-via-oauth? in the request schemas

Master closed ::current-user-info and added a closed ::request schema
enumerating every key the middleware stack may attach. This branch marks
a request the OAuth bearer bridge authenticated with
:authenticated-via-oauth?, which both schemas then rejected as an extra
key: the bridge threw on its return value, so no OAuth bearer request
could authenticate.

* Describe only our own resource indicators as malformed

The error_description helper answered every invalid_target that carried no
description of its own with the one describing a malformed resource indicator.
oidc-provider raises that code without a description for a different condition:
a refresh whose resource is outside the original grant. A client sending a
well-formed absolute URI was told its URI was malformed.

The endpoint's own check now carries its description in the exception, and the
helper falls back to the endpoint's generic description for anything else. That
check also validates what it claims to describe, so a relative indicator or one
with a fragment is answered by it rather than by the library.

* Answer 401 when no JWT provider is registered

The agent API's stateless-JWT fallback dispatches auth-identity/authenticate
on :provider/jwt. That provider ships only in EE, so on OSS nothing derives
the keyword into the auth-identity hierarchy and the dispatch throws
UnsupportedOperationException. Every bearer token the OAuth bridge declines
falls through to it, so the agent API answered 500 with a stack trace instead
of the RFC 6750 invalid_token challenge it owes the request.

Treat an unregistered provider the way a disabled one is treated: the same
jwt_not_configured result, which enforce-authentication already renders as a
401 carrying the invalid_token challenge.

Also stop bearer-bridge-refuses-token-without-scopes-test from holding
with-temp's rollback-only transaction open across its four HTTP round-trips.
The request runs on a conveyed connectable, so anything in its path that
commits implicitly invalidates the savepoint and the scope exit throws
instead of rolling back.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:57:02 -05:00
Nathan Voxland
e96a5d884d Improve OSS include_editable_data_model logic (#82451) 2026-09-17 13:40:38 -05:00
Nathan Voxland
587b659ee7 Don't include archived cards in embedded dashboards (#82529) 2026-09-17 13:39:39 -05:00
Andrei Simionescu
4b6744c1f4 Apply the chat system prompt to Metabot in Slack (#82639) 2026-09-17 18:06:54 +01:00
Andrei Simionescu
4c5146d536 Don't feed Metabot queries the user has no permission to run (#80083)
* BOT-1956: Fix permission-check gaps in transform/query export

Card/measure/segment export lookups routed through the loud, audit-logging
read-check even for routine filtering, one query-export call site defaulted
to the unchecked store, and a withheld query left a dangling header instead
of saying so.

* BOT-1956: Audit the permission check on client-supplied query ids

Ids arriving inline in the viewing context are a real access attempt, so the database gate
and the card ids inside the query both keep the audited read-check while stored-query export
stays quiet. export-query-for-llm now takes an explicit content store, so every call site
shows which one it picked.

* Don't audit permission refusals the caller discards (#79969)

* BOT-1956: Resolve -1337 to the real database and gate transform sources

The virtual id passed the database gate as a 404 while the export then
resolved it to the source card's real database, so the gate now resolves
first and fails closed on ids it cannot. Transform sources get the same
gate, queries and charts seeded from the viewing context keep the audit
trail when a read_resource denial withholds them, and the withheld
message no longer blames the database when a card inside the query was
the refusal.

* BOT-1956: Drop the em dashes from two docstrings

* BOT-1997: Don't feed Metabot queries the user has no permission to run

* BOT-1997: Shorten the comments on the Metabot permission checks

* BOT-1997: Close the two remaining fail-open paths in the query export

Exports now defer to the query processor's can-run-query?, the same
check the save path uses, instead of a database-wide can-query?
shortcut plus SQL analysis. For native stages that means the
database-level query-builder-and-native rule with no parsing: the
analyzer was best-effort and could return an incomplete table set with
no completeness signal, so a clean analysis never proved the query was
authorized, and unsupported drivers withheld queries from fully
authorized users. Deferring to the QP contract also keeps queries
sourced from saved questions working; those authorize through the
card's collection, not through database query permission. The
name-resolution checks (queryable tables, sandbox-visible fields,
readable cards) stay on top of the run check.

The same check now also covers conversation state. read_resource used
to export a stored query after only a database read-check, so a user
who could read the database's metadata but not run the query could
recover resolved table and field names through metabase://query/{id}
and metabase://chart/{id}. Both fetchers now share the viewing-context
formatter's exportable-query? check.

* BOT-1956: correct three stale comments

The withheld-message docstring claimed a deleted database is withheld too, when it
deliberately renders. The content-store test docstring counted three pinned properties
while listing four, and the repair comment called every pass best-effort when only the
mini-resolve lookups are.

* BOT-1997: authorize conversation queries by run permission alone

The query and chart resources read-checked the raw :database before the run-permission
check, which broke the two cases the canonical check exists for: a collection-only user
running a saved-question query got a 403 from a database they never touch, and a legacy
-1337 database id 404ed before normalization could resolve it through the source card.
The read-check is gone and the viewing-context predicate now lets normalization resolve
-1337 instead of gating on the raw id.

The source-card tests also stop granting manage-table-metadata, which had made the
database readable and hidden exactly these cases; they now run with no database
permission at all, over both the real and the -1337 id, alongside a new refusal test
for a card in an unreadable collection.

* BOT-1997: Hide transform source queries the user can't run

Reading a transform is authorized per database, but rendering its source resolves
table and field ids to names, so the details tool and the transform resource now
drop the query unless the user could run it. The viewing-context path also stops
normalizing twice and logs a query it can't permission-check at debug, instead of
letting the calculator log an error on every prompt.

* BOT-1956: make the query export gates table-granular

* BOT-1956: fail closed when a native query can't be analyzed

* BOT-1956: Fold the two query database gates into one with an audited? flag

* BOT-1997: Calculate perms inside the try, assert the 403 text in tests

* BOT-1956: Check exports with can-run-query? instead of parsing native SQL

* Build the saved-question test card with Lib instead of the deprecated mbql-query

* Use metabot.db/database-exists? instead of a direct Toucan call

* BOT-1956: Name can-run-query?'s flag for the errors it rethrows

* BOT-1956: Suppress repair's refusal auditing at its call site

* BOT-1956: Keep :client-ids out of the conversation state

The ids describe the viewing context of the request being served rather than the conversation, and final-state-part was handing them back to the client as if they were state.

* BOT-1956: Gate a query export with one Database read and one normalization

* BOT-1956: Audit a chart's refusal against the query it was built from

* BOT-1956: Make run permission the whole gate on a query export

Reading the database was checked on top of it, which withheld a saved question from a user who can read the card and run it but has no grant on the database behind it. Dropping that check also leaves one Database lookup where there were two, so a deletion landing between them can no longer escape as a 404.

* BOT-1997: Render a query we could not permission-check rather than hiding it

A query that will not normalize, or whose permissions will not calculate, was reading as a refusal and disappearing from the transform output and the conversation resources, admins included. It now comes back without a metadata provider and renders through the paths that resolve no ids to names, which is what master did with its EDN fallback.

* BOT-1997: Cover the cases the export gate decides on a transform source

* BOT-1997: cljfmt

* BOT-1997: Make the unresolved fallback actually withhold names

A query that will not parse never reached the fallback, because normalization degrades to an empty map unless it is asked to be strict, so those queries still read as refusals and disappeared. Transform rendering then threw away the checked query and its provider and resolved the source a second time through an unrestricted provider, which put resolved names back in front of the model for a query whose table or sandbox lookup had thrown. Native SQL needed its own answer either way, since its table and column names are already spelled out in the text and having no provider hides nothing, so it stays out until the check has actually run. Rendering now happens where the check's result is still in hand, and an export failure falls back to the query the caller passed rather than the normalized form.

* BOT-1956: Keep a client query's provenance for as long as the query lives

Carried over from the review on #79877, which merged before this was picked up. The ids of queries and charts the client sent were request-local, so a tool could persist one of those queries and a later turn read it back after it had left the viewing context, by which point it looked tool-written and a refusal to show it went unaudited. They live in conversation state again, declared in the state schema this time, and each turn adds to what earlier turns recorded.

* BOT-1997: Tell a check that was never needed from one that could not be made

Both came back as a query with no metadata provider, which conflated two different situations. A transform whose source database has been deleted has nothing behind it to resolve, and an admin is supposed to still read that source while repairing it, but it was being withheld like a query whose permissions had thrown. Going the other way, native SQL was only held back when the source had a single stage, because the check for it was the helper that extracts a complete query's SQL and deliberately gives up on multi-stage queries, so a native stage under an MBQL stage still printed its SQL in the unresolved fallback. The two now come back distinctly, and native is recognised by looking for a native stage anywhere in the query.

* BOT-1956: Take client ids back in the shape the app DB returns them

JSON has no sets, so state read back out of metabot_message carries the ids as a vector, and the agent entry point validates its input before memory initialization turns it back into a set. Every conversation with a stored turn therefore failed to start its next one under dev and test instrumentation. The state schema accepts either shape now and normalization settles it, and the test covers the round trip rather than handing a set straight in.

* Hide native SQL from the viewing context when perms can't be checked

* Use with-dynamic-fn-redefs in the sandboxed field transform test

* Hide native SQL from read_resource when perms can't be checked

* Treat an unresolvable database id as an unchecked query

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-09-17 18:01:18 +01:00
Chris Truter
e28ae43d1d Various Slackbot upload improvements (#82216)
* Tidy up slackbot CSV upload handling

Report a file that cannot be staged on disk as one failed file rather than
letting the IOException escape to the async handler, which only logs and leaves
the Slack user with no reply at all.

Take the Slack client from the caller instead of re-reading the app token per
file, fetch the uploads Database once, build the upload target in kebab-case,
and express pre-flight failures as system messages so the API namespace stops
hand-rolling its own.

* Trim the upload docstrings and comments to their contracts

Drop the note about the upload module's private `allowed-extensions`: nothing here
relies on that set, so it would go stale silently. The `csv-file?` test asserts
`txt` is not uploadable instead, which fails if anyone widens the set.

* Polish Slackbot upload handling

* Test attachment-only upload failures

* Clarify Slackbot upload return contracts

* Regenerate the modules config after dropping the Database lookup

* Tighten the upload docstrings and comments

* Close the response stream when a redirect Location will not resolve

URI.resolve throws IllegalArgumentException on a malformed Location, and redirect-target runs
in the let binding that computes target, before the cond reaches either .close branch. A Slack
file host answering with a malformed Location therefore leaked the response stream.

Returning nil instead folds the case into the existing "Unexpected response" branch, which
closes the body.

* Cover remote attachments end to end, and say nothing when there is nothing to say

A message carrying only a file stored outside Slack had no route-level coverage: the unit
tests reached remote-file? and the history message, but nothing exercised the reply path
that replaced master's refused-files-message.

A file_share event may also carry an empty :files vector, which with blank text left
handle-message-file-share posting an empty Slack message. It now posts nothing.

* Name an unsupported file type, and stop suggesting a retry for failed uploads

Retrying a failed upload fails the same way. The 415 from create-csv-upload! is the one error safe and useful to
explain, so say the contents don't look like a CSV or TSV.

* Word the unsupported file type message to cover the filename too

create-csv-upload! also answers 415 for an extension it doesn't accept, such as data.CSV, so the message can't
blame the contents.

* Return only the upload history, and describe the size limit as the bot's own

* Show the upload module's own 4xx errors to the user

* Port the upload failure message cases from #81202 to upload-file!

* Count every attached file in the Slack upload counter, under what became of it

Size rejections, unsupported and remote files, and unavailable uploads were never counted, so the error label missed the most common outcomes. Also stop formatting the model id with %d in the success log, where a nil id would have turned an upload into a reported failure.

* Point the user at the server logs when an upload fails for an unexplained reason

Everything that reaches the generic message needs an admin to read the logs, as the #81202 wording said.
2026-09-17 16:20:55 +00:00
Andrei Simionescu
d37f1a9dd3 Record which provider served each LLM call (#82280) 2026-09-17 17:19:48 +01:00
Ryan Laurie
1a32584bb8 Improve App DB Test Sharding (#82538) 2026-09-17 10:02:46 -06:00
Chris Vandoodewaard
d3ce9de3cc Bump backend deps for open security alerts; define core-provided deps in one place (#82518)
* Bump backend deps for open Snyk alerts; define core-provided deps once

Security fixes:
- athena: netty 4.1.137.Final -> 4.1.138.Final (CVE-2026-89044, HTTP request
  smuggling in netty-codec-http). Stays on the 4.1 line because the Athena JDBC
  driver's async AWS SDK path is not binary-compatible with 4.2.x.
- snowflake: pin zstd-jni 1.5.7-16. snowflake-jdbc-thin 4.0.2 pulls 1.5.6-5,
  which has eight open out-of-bounds read alerts, all fixed in 1.5.7-14.

t-digest (CVE-2026-87822, CVE-2026-87962) is left alone deliberately: Snyk lists
no fixed version, everything from 3.1 up is affected, and it cannot be excluded
because kixi.stats.core requires kixi.stats.digest, which imports TDigest at the
namespace level. Both CVEs are in MergingDigest.fromBytes and need an
attacker-supplied serialized digest; we only ever build digests in-process and
never deserialize one, so we are not exposed. Those two alerts need a dismissal.

Per review feedback on #82261: driver uberjars are stripped of anything core
already ships (build.plugin-uberjar/prune-provided-libs!), so a driver
re-pinning a core-provided library has no effect on what loads, still lands the
artifact in that driver's generated pom.xml for Snyk to alert on, and adds
another place to bump. Replaced 22 such declarations across 7 drivers with
:exclusions on the transitive dep that drags them in, so the version is defined
only in the root deps.edn.

Verified by resolving every driver basis before and after and diffing the
effective shipped set (basis minus core-provided libs): only the two intended
security bumps move, every other driver is byte-for-byte identical. All 8
touched drivers build and pass verify-driver. Regenerated poms confirm all 15
core-provided artifacts are now declared in the root pom.xml and in zero driver
poms.

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

* Update modules/drivers/clickhouse/deps.edn

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Exclude httpcore5 and bcutil-jdk18on to close scan-only version mismatches

Per review on #82518:

- httpcore5: databricks-jdbc-thin 3.4.1 declares 5.3.6 and libthrift 0.24.0
  declares 5.2, while the root deps.edn pins 5.4.3. Only httpcore5-h2 was
  excluded, so httpcore5 still landed in the generated databricks pom.xml --
  and excluding it from the driver alone would have left libthrift's 5.2 in the
  standalone POM for scanners to flag. Excluded from both, matching what
  clickhouse and hive-like already do.

- bcutil-jdk18on: databricks-jdbc-thin declares 1.84 and snowflake-jdbc-thin
  4.0.2 declares 1.82, but both resolve to 1.85 transitively via the root's
  bcpkix-jdk18on 1.85. Same scan-only mismatch as bcpkix/bcprov, which were
  already excluded; added bcutil alongside them in both drivers.

No change to what either driver ships -- these artifacts are core-provided and
already stripped from the driver uberjars by prune-provided-libs!.

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 <crisptrutski@users.noreply.github.com>
2026-09-17 11:43:38 -04:00
technomancy
249390c4b6 Make mage start-db print a command to connect a client where known. (#82621)
Sometimes you want to start a DB and connect your client directly to
mess around, but you haven't memorized the exact command. Why not make
mage give you a lil hint?
2026-09-17 08:38:48 -07:00
Eric Normand
efbd247394 Ignore FKs from inactive tables when checking source replacement (#82618)
Sync deactivates a dropped table but leaves its fields active, so an FK
field on a dropped or archived table kept reporting
:incompatible-implicit-joins and blocked find and replace on the table
it targets. The incoming-FK check now requires the FK field's table to
be active too.

Fixes #82615
2026-09-17 15:11:05 +00:00
github-automation-metabase
bfc9d9aa91 Auto-update documentation for master (#82604)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-09-17 09:34:23 -04:00
Will Fry
8658d420d2 GDGT-3236 Move field sorting loader to end of row to avoid layout shift (#82645)
Move field sorting loader to end of row to avoid layout shift

The loader rendered before the sorting controls, pushing them right while
the field order request was in flight. Rendering it last keeps the
controls in place.

Fixes GDGT-3236

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:24:30 +00:00
github-automation-metabase
1a4afe2350 Tighten ratchets (#82655)
Co-authored-by: github-actions <github-actions@github.com>
2026-09-17 13:14:41 +00:00
Mahatthana (Kelvin) Nomsawadi
d53a909a92 Revert "Integration branch for Embedding settings reorganization (#80336)" (#82570)
* Revert "Integration branch for Embedding settings reorganization (#80336)"

This reverts commit b9d217cbb4.

Also drop enable_embedding_modular from the closed stats snapshot
schema. #82447 (Require typed, closed argument schemas in mu/defn)
added that line after #80336, so git revert leaves it behind while
removing the key from the snapshot.

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

* fix: keep writing embedding_theme.is_default

The is_default migration stays after the revert, since it may already
have run and migrations cannot be removed, so the column still exists.

- Restore its serdes :skip entry and the theme API key-set
  expectations for endpoints that return the whole row.
- Keep marking seeded Light/Dark themes as default, so the flag stays
  meaningful when the embedding hub work is picked up again.
- Keep returning it from the theme API, including the list endpoint,
  as #80336 did.

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

* refactor: keep the settings-components module

#80336 moved the settings page primitives into the shared
settings-components module. That move is not part of what is being
reverted, and later master work builds on it (#82389, #82462), so keep
it: restore the module, its lint entries, and point imports back at it.

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

* refactor: keep the setup guide rename

#79514 renamed the embedding hub checklist code to setup guide, which is
what the UI already calls it. It is a rename only: the API path, setting
names and persisted state keep their embedding-hub names. Re-apply it on
top of the revert so the name stays free for the embedding hub section
when that work is picked up again.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:08:13 +01:00
Chris Vandoodewaard
9daae78940 stop set-milestone from exploding on first CI run on new release branch (#82576) 2026-09-17 08:33:54 -04:00
Stas Gavrylov
c881cfaa4e GDGT-2793: Stabilize Python incremental transform test (#82423)
* GDGT-2793: Stabilize Python incremental transform test

* GDGT-2793: Ground Python transform timeouts

* GDGT-2793: Address PR comments
2026-09-17 14:32:10 +03:00
github-automation-metabase
b4f8c8e8aa chore(sdk): bump SDK version on master to 0.64.0-alpha.4 (#82641)
chore(sdk): bump SDK version to 0.64.0-alpha.4 (alpha)
2026-09-17 13:52:53 +04:00
YB
b826490105 [GDGT-2870] fix non-admins unable to create alerts on OSS jars with a paid token installed (#82555)
gate notification permissions on enable-advanced-permissions? not has-feature?
2026-09-17 17:45:03 +08:00
Stas Gavrylov
499bc1160b Consistent typography (part 1): Text/Title on home, collection, Metrics, Data, and Models (#81888)
* Use Title for EmptyState heading and keep heading role

* Use h1 for the Metrics page title

* Use h1 for the Data page title

* Use h1 for collection name and Title/Text in collection empty state

* Fix EmptyState spec to use the illustrationElement prop

* Add hidden h1 and use Text for home greeting and captions

* Use h1 for the Models page title and fix heading levels

- Page title: Title order={2} -> order={1} (parity with Metrics/Data)
- Empty state title: order={3} -> order={2} (avoids h1 -> h3 gap)
- Explanation banner: Title order={6} -> Text size="md" lh="sm" fw={700}
  (banner copy is not a heading; removes h1 -> h6 skip, style-parity swap)

* Remove heading level unit tests

Drops the getByRole heading-level assertions added across the branch
(8 tests in 7 spec files, incl. the new EmptyState spec). Heading
semantics remain covered by the component changes themselves; e2e/a11y
audits own regression coverage.

* Address Copilot comments

* Update HomeCaption to use div

* Fix e2e test broken by HomeCaption's new wrapper element

HomeCaption now wraps its children in a Text component, which added
an extra DOM level. The custom-viz e2e test relied on .parent() from
the caption text landing on the section's shared ancestor with the
recent-item links, which no longer holds.

Scope the assertion via a stable data-testid instead of DOM depth, so
it doesn't re-break on unrelated markup changes.

* GDGT-3181: Address PR comments
2026-09-17 11:57:29 +03:00
Phoomparin Mano
64e7f5cfc8 Whitelist product analytics ingestion origins for data apps (#82483)
whitelist product analytics ingestion origins for data apps
2026-09-17 16:54:49 +08:00
Mahatthana (Kelvin) Nomsawadi
0cf4271e33 feat: warn when the SDK host app runs React 18 (#82432)
* feat: add a host React major version helper to the SDK

Reads the major from the React the host app hands to the bundle, so
both the React 18 deprecation warning and the upcoming unsupported
React version guard read the same source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtrkKRneS6NAuJmdYXEHHi

* feat: warn when the SDK host app runs React 18

The SDK will require React 19 in an upcoming release. Surface that
through the existing usage-problem banner and its console message when
the host app renders the SDK with React 18 on localhost or in the host
app's development mode. Production hosts get the same styled console
warning without the banner, and it ignores allowConsoleLog because
losing React 18 support is a breaking change for them. The iframe embed
is excluded since its React version is Metabase's own.

The new warning has the lowest precedence so license errors, the
API-key evaluation warning and the missing "exp" warning still show
when present.

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

* fix: log SDK usage problems found after mount

The usage problem is computed into the store in an effect, so on the
first render the hook still reads null. The log-once effect ran with
that null, logged nothing and marked itself done, so the real problem
that arrived a render later was never logged.

Only log once init has finished and a problem exists. Before init
finishes, settings still hold placeholder values, so a problem
computed then can be the wrong one, such as the licensed API key
warning on an unlicensed instance.

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

* fix: show the React 18 warning before other problems

API-key users on localhost only ever saw the API key warning, so they
would not learn about the React 18 deprecation until they moved to SSO
or production. The React 18 case now comes first in the usage problem
match, right after the MCP app opt-out.

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

* fix: keep usage errors above the React 18 warning

Putting the React 18 warning first hid the license, disabled-SDK and
non-localhost API key errors on React 18 dev hosts, and let SDK
components render where those errors would stop them. Errors now come
before every warning, and React 18 stays first among the warnings.

The API key error gains `isLocalhost: false` so it can sit above the
localhost API key warnings without catching them.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 09:48:42 +01:00
Tyler Plude
69b65837bb [GHY-4523] Add remote sync support for glossary (#82390)
* add entity_id to glossary and key its serialization on it

* route glossary writes through glossary.core

* track glossary entries in remote sync when the Library is synced

* export and import glossary entries through remote sync

* tweaks

* lock glossary edits on read-only instances and backfill ledger rows on upgrade

* fe support

* tests

* resolve term-keyed glossary files to local entity ids during remote sync imports

* fix tests

* e2e adjustments

* fix test

* resolve term-keyed glossary paths without a shape check

* resolve same-term glossary files by term in the pre-load conflict check; fix migration

* Backfill glossary tracking per entry, not all-or-nothing

* fix test order
2026-09-16 22:35:14 -07:00
Romeo Van Snick
6636c786e2 Read the reference database pages from their own requests (#82481)
* Read the reference database pages from their own requests

* Remove the entity selectors from the reference store

* Pass the entity the reference update actions read
2026-09-17 13:34:01 +10:00
Chris Truter
63b1899372 Tidy up some Search comments (#82623)
* Tidy the comments and docstrings from the search index metadata fix

The sql-errors registries carried a diary block about the change instead of a note per code.
Put each vendor's state above its entry, move the driver-alignment note next to the set it
guards, and put the vendor-code rationale on the cond branch that needs it.

Elsewhere: restore a dropped blank line, move the IF EXISTS rationale into the fn body,
and rewrap the comments and docstrings that broke mid-sentence.

* Name MariaDB alongside MySQL in the sql-errors comments

* Restore SQL error registry maintenance context
2026-09-17 01:21:29 +00:00
Romeo Van Snick
0a441968d1 Make getMetadata private to the metadata store (#82551) 2026-09-17 10:28:20 +10:00
Romeo Van Snick
b4513dcdca Move the card copy form next to the other copy forms (#82549)
* Move the card copy form next to the other copy forms

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

* Point the side-effect ledger at the card copy form's new path

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:28:00 +10:00
Chris Truter
1f1643d5b8 Fix search index metadata deletion guard (#80309)
* Fix search index metadata deletion guard

Delete only the pending SearchIndexMetadata row rather than whichever row happens to match the coordinate, and
state that contract in the docstring.

The cleanups the fix leaned on, in the same change:

- Write the index DDL as Honey SQL maps instead of threading `sql.helpers`, which earns its place only when
  clauses are added conditionally.
- Identify a missing index table by SQL state rather than exception class, covering every state our app databases
  report, and name those states and the vendor error codes instead of spelling out literals at each call site.
- Take the connection as an argument rather than encoding it in an `-on-conn!` name suffix.
- Let the index poll survive an index that has not been built yet.
- Move rationale out of the docstrings into comments: a docstring is the caller's contract.
- Skip `with-model-cleanup`'s reindex when no search-relevant rows were deleted, and give `multiple-limits-test`
  its own search data so it stops depending on another test's rows.

* State which safe-batch-upsert! failures throw and which are skipped

The docstring claimed failed retries propagate; only a missing table
after refresh, a missing table on the retry, and interruption do. Any
other failure is logged and the batch is skipped.

* Classify app-db SQL errors centrally instead of matching states in search

app-db.sql-errors keeps the named SQLSTATE and vendor-code registries and
classifies an exception's cause chain into an error class keyword
(:table-not-found, :duplicate-key) that call sites dispatch on. The
search module's private copy of the missing-table states goes away, and
with it the comment about keeping them aligned with the drivers; the
duplicate-key entries are ready for their caller in the lease work
stacked above. MySQL's catch-all integrity-constraint state is
disambiguated by the vendor code, so no db-type parameter is needed.

* Relax the reset pre-delete to any non-active index metadata

delete-pending-index! becomes delete-non-active-index!: a reset now also
clears retired metadata rows for the coordinate, never active ones. The
lifecycle test covers deleting retired and pending rows while the active
row is refused.

* Count every cleanup deletion and reindex when any rows were removed

The search-hooks filter had a blind spot: raw deletes cascade (a user's
personal collection), removing search-relevant rows even when the listed
model has none. Count the total with a reduce and rebuild whenever
something was deleted; only an empty cleanup skips.

* Address search index review feedback
2026-09-16 20:13:38 -04:00
Eric Normand
95368200b1 Least-privilege MCP v2 scopes with step-up (GHY-4543) (#82452)
* List every MCP v2 tool regardless of token scopes (GHY-4543)

tools/list filtered out tools the token lacked scope for, so a client
holding a narrow grant never saw the write, SQL or delivery tools and
had nothing to attempt or step up for. Scope stays enforced at
tools/call; the disabled-tools and client-extension filters still apply.

With scopes out of the listing, list-tools and tools-hash drop their
token-scopes parameter, and the transport's tools-hash-fn becomes
zero-arity.

* Explain MCP v2 scope denials to the model (GHY-4543)

Clients replace a scope denial with their own text ("token expired",
"Insufficient scope", "Tool execution failed"), so the model tells the
user their login expired. Server instructions now say that failure is a
missing permission, and how the user reconnects to grant it. Every tool
description ends with a static sentence naming its required permission by
its consent-screen label, identical for every token: Claude Code caches the
first description it loads for the whole session.

* Answer MCP v2 scope denials with 403 insufficient_scope (GHY-4543)

A scope denial was an HTTP 200 carrying a JSON-RPC error or an isError
result, so clients never stepped up: Claude Code only saves a step-up
scope, and mcp-remote only starts one, on a real 403 with an
insufficient_scope WWW-Authenticate challenge. The registry gate and the
four in-handler scope checks now mark their refusal, and the transport
answers a single (non-batched) message with 403 plus a challenge whose
scope is the held v2 scopes plus the required one.

* Keep a DCR client's registration scope from narrowing its step-up (GHY-4543)

Claude Code registers with the scope it read from the protected-resource
metadata, then steps up on the same client_id with the wider scope from a
403 insufficient_scope challenge. oidc-provider validates a requested scope
against the client's registered scopes, so that step-up was refused with a
400 invalid_request. Once scopes_supported is narrowed to the baseline, no
Claude Code user could reach write, SQL, or delivery.

A dynamically registered client's ceiling is now its registered scopes plus
the default grant scopes, computed on read. Registering can widen the
ceiling (a client that registers for mb:full keeps it) but never narrows
it. RFC 8707 resource narrowing is unchanged.

* Declare each MCP v2 tool's OAuth scope in securitySchemes (GHY-4543)

ChatGPT only steps up for scopes a tool declares: without per-tool
securitySchemes it re-authorizes for its narrow login scope in a loop,
and with them it re-authorizes once for held plus declared scopes.
The scheme is derived from the tool's static :scope in the cached
manifest, so it is the same for every token.

* Advertise only the baseline MCP v2 scopes to new clients (GHY-4543)

The protected-resource metadata `scopes_supported` and the 401 challenge
`scope` now list only `agent:content:read agent:resource:read`, so users
who never need write, SQL, or delivery never grant them. Claude Code and
the Claude connectors take their first-login scope from the resource
metadata; ChatGPT takes it from the challenge. All of them reach the rest
through the 403 `insufficient_scope` step-up and per-tool
`securitySchemes`.

What the resource accepts is unchanged: RFC 8707 narrowing still keeps all
six v2 scopes, and the authorization-server metadata stays wide because
Codex requests that list on every login and cannot step up.

* List every MCP v2 resource regardless of token scopes (GHY-4543)

resources/list now matches tools/list: it is token-independent, so a client can
see the resources it could step up for, and a client that pre-fetches the MCP
Apps shells before stepping up to agent:query:run does not cache them as
missing. resources/read keeps its scope gate.

* Answer MCP v2 resources/read scope denials with 403 insufficient_scope (GHY-4543)

resources/list now shows every resource, so answering a denied read with
"Resource not found" hides nothing and leaves a client with no step-up
signal. A client that pre-fetches an MCP Apps shell with a baseline token
now gets the same challenge tools/call sends, naming agent:query:run.
Unknown URIs stay -32602; inside a batch the denial is an in-band -32600.

* Lead MCP v2 tool descriptions with their required permission (GHY-4543)

Clients truncate long tool descriptions (Claude Code at 2048 characters),
so the appended "Requires the ... permission" sentence was cut from
document_write and execute_query. The model needs that sentence to name
the missing permission after a scope denial, so it now opens every
description, and the server instructions say it starts the description.

* Serve every MCP v2 resource on resources/read regardless of token scopes (GHY-4543)

Claude Desktop reads an MCP Apps shell alongside the tool call. When that
read was refused with 403 insufficient_scope, Desktop never stepped up after
the tool call's own 403; with the shell served, it stepped up automatically
and retried. The shell carries no data and, for a token without the shell's
scope, no UI credential: read-resource now withholds the credential instead
of refusing the read. Chart data stays gated by the tool call,
refresh_ui_credential, and the UI-surface route costs.

* Restore the scope check on MCP v2 data resource reads (GHY-4543)

Only the MCP Apps UI shells need to be served to any token: a refused shell
read stopped Claude Desktop stepping up after the tool call's 403. The fields
catalog carries data and has no such constraint, so reading it without
agent:resource:read again gets the 403 insufficient_scope challenge (in-band
-32600 inside a batch). UI shells stay unscoped, minting a credential only for
a token holding their scope.

* Fit MCP v2 tool descriptions under Claude Code's 2048-character limit (GHY-4543)

Claude Code truncates tool descriptions at 2048 characters, so document_write and
execute_query lost their closing guidance, including execute_query's pointer away
from execute_sql for filtered-dashboard cards. Condense both, and get_content for
margin, deferring detail to learn() topics and argument schemas. A test now holds
every listed description to the limit.

Also correct docstrings and comments left stale by the resources/read and
tools/list scope changes and by credential delivery through refresh_ui_credential.

* Spell out the scope union in the MCP v2 challenge docstring (GHY-4543)

The whitespace linter rejects the confusable Unicode character U+222A.

* Add agent:query:run to the MCP v2 baseline scopes (GHY-4543)

Claude Desktop retries a tool after a step-up over a session that does not
declare MCP Apps support, so a stepped-up visualize_query is refused and the
first chart after a step-up never embeds. Querying and charting therefore
must not require a step-up. The protected-resource metadata, the 401
challenge and default-ask-scopes now advertise agent:content:read
agent:query:run agent:resource:read; writes, raw SQL and delivery still step
up.

* Point MCP v2 chart requests at visualize_query in the server instructions (GHY-4543)

The routing line named only execute_query, and clients like Codex load tool descriptions on demand, so chart requests could miss visualize_query.

* Gate native query handles in transform_write on agent:sql:run and the kill switch (GHY-4543)

A query handle is not proof the SQL gates were spent: /api/embed-mcp/drills
stores a native query under agent:query:run alone. transform_write now runs
the same scope and mcp-execute-sql-enabled checks on a handle that resolves
to native SQL as it does on an inline native definition, matching
question_write.

* Tighten resource registration, step-up scopes, and the DCR ceiling (GHY-4543)

- register-resource! takes a closed map and strips :ui?, so a data resource
  can never be served to a token without its scope.
- step-up-scopes matches held scopes instead of looking them up, so a token
  holding a wildcard such as agent:content:* keeps the surface scopes it
  covers when a client replaces its grant with the challenged scope. It now
  takes the single required scope both callers pass.
- The default grant ceiling is applied only while dynamic registration is
  enabled, so turning registration (or MCP) off stops widening existing
  dynamic clients. The merge is now a pure function with unit tests, and an
  HTTP test pins that static clients are never widened. The legacy-client
  migration test runs with registration disabled, where the stored snapshot
  alone decides, so it exercises the migration again.

* Correct MCP v2 tool descriptions, the documents skill, and server instructions (GHY-4543)

- document_write lists the valid entity-link models again.
- The documents skill said edits store new_str literally; the tool parses it
  as Markdown, as edit-replacement-parses-markdown-test pins.
- execute_query no longer claims to save cards (its handles save as cards),
  and names the previous-stage field ref shape instead of an ambiguous
  column-name string.
- The server instructions attribute the rewritten permission failure to the
  client and cover resource reads, which get the same 403.

* Pin the MCP v2 scope-denial and UI-credential contracts in tests (GHY-4543)

Closes gaps the review found, where removing a check would stay green:
- question_write and subscription_write in-handler denials carry
  :insufficient-scope at the registry, like alert_write's.
- A denied tools/call sent as a notification is a bare 202, no challenge.
- Wildcard and unrestricted tokens get the shell credential; the fields
  catalog mints none, over the registry and over HTTP.
- tool-and-resource-scopes-agree-test asserts the credential is realized,
  since any token now reads a shell.
- advertised-scopes-are-distinct-test covers the advertised baseline, which
  is a vector and can repeat, and its docstring says so.

* Clean up MCP v2 scope-denial helpers, header quoting, and docs (GHY-4543)

- Share one scope-denial message and detail builder between tool calls and
  resource reads, one step-up challenge helper, and a common
  throw-insufficient-scope! for the four in-handler scope checks.
- quoted-string replaces " and \ instead of backslash-escaping them, since
  RFC 6750 excludes both from scope and error_description values; the 401
  challenge now quotes through it too.
- Trim docstrings to their contract and move the reasons into comments.
- The unrestricted UI credential comes from any unrestricted session (cookie,
  API key, or an mb:full token), not only cookie sessions.
- Note that the DCR ceiling applies only while registration is enabled, and
  fix stale comments, test names and strings, long lines, order-sensitive
  scope assertions, and hard-coded consent labels in tests.

* Always widen dynamic OAuth clients to the MCP scopes (GHY-4543)

Claude Code registers with the baseline scopes and steps up on the same
client_id. With dynamic registration turned off, that step-up /authorize
was answered with a raw 400. A dynamic client now always gains the six v2
MCP scopes on read, each still behind consent; the remaining agent-API
ceiling is added only while registration is enabled. Registration itself
stays blocked.

* Close the MCP v2 scope test gaps (GHY-4543)

Cover the transform_write update path's native gates, the HTTP 403
insufficient_scope challenge question_write and transform_write send from
inside their handlers, saving a drill handle minted through
/api/embed-mcp/drills, and the closed resource registration schema. Give
the locale test a positive control, merge the two tools/list
token-independence tests into one that compares the whole payload, and
drop the permission-sentence truncation test the fit and starts-with
tests already cover.

* Tidy MCP v2 scope wording and naming (GHY-4543)

Rename registry/insufficient-scope to insufficient-scope-detail so it
does not read like the transport fn of the same name. Drop the server
instructions clause telling the model to read a resource's scope off an
error clients hide. Document agent:resource:read and the handler-internal
scope checks in the module README, correct the stale comments about UI
resources gating on agent:query:run, and wrap the lines this branch left
over 120 columns.

* Let the test fallback template win over a local frontend build (GHY-4543)

The MCP Apps shell renders from the built `embed-mcp.html` when one exists,
so a worktree that has run a frontend build shadowed the inline fallback the
credential tests install. The production template has embedded no
`uiCredential` since #81041, so those tests failed locally and passed on CI,
where backend-only runs produce no build.

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

* Gate the MCP grant-ceiling widening on the MCP kill switch (GHY-4543)

A dynamically registered client gained the MCP surface scopes on every read of
its client record, unconditionally. Only the wider `default-grant-scopes`
ceiling was gated, on `oauth-server-dynamic-registration-enabled`.

`agent:resource:read` is one of those MCP scopes and is also the declared scope
of `POST /api/agent/v1/read-resource`, which a separate lever gates. So with MCP
disabled and the agent API enabled, a dynamic client could authorize for a scope
its registration never included and spend it at a surface still serving.

Read `mcp-enabled?` in the effectful client-store wrapper and pass no MCP scopes
down when it is off; `widen-to-grant-ceiling` stays pure. While MCP is enabled
the existing behavior is unchanged: the MCP scopes are still added whatever the
registration setting says, so a client registered with the baseline can step up.

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

* Cover the built MCP embed template's no-credential property (GHY-4543)

Serving MCP Apps UI shells to any token holder rests on the built
embed-mcp.html discarding the credential it is handed (since #81041) — the
iframe fetches its own through `refresh_ui_credential`. Nothing asserted that,
so a frontend change reintroducing a `uiCredential` placeholder would land
silently now that the outer scope gate on shell reads is gone.

Add `metabase.mcp.ui-resource-test` rendering the built template with a
credential and asserting the output carries neither the credential nor a
`uiCredential` placeholder. Backend-only CI runs have no frontend build, so the
test logs a skip instead of failing when the resource is absent.

Also gate the test fallback template's precedence over the built one on
`is-test?`, so the reorder can only ever apply in a test process.

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 18:21:34 -05: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
4391 changed files with 190693 additions and 124226 deletions

View File

@@ -1,111 +1,79 @@
---
name: ai-backend-expert
description: "Use this agent for Metabase Clojure backend work on AI features — Metabot, LLM integrations, tool calling, context engineering, the agent API, SQL generation/fixing, entity analysis, or dashboard/question description generation. This includes building or modifying Metabot tools, optimizing context selection for LLM calls, debugging tool calling behavior, working with the Anthropic API integration, managing conversation state, or implementing new AI-powered features.\n\nExamples:\n\n- user: \"Metabot is generating SQL that misunderstands column semantics\"\n assistant: \"Let me use the ai-backend-expert agent to improve the table metadata context to include semantic annotations and sample values.\"\n <commentary>LLM context quality for SQL generation. Use the ai-backend-expert agent.</commentary>\n\n- user: \"We need to add a new Metabot tool for creating filters\"\n assistant: \"Let me use the ai-backend-expert agent to implement the tool using the deftool macro with proper schema, permissions, and LLM-friendly descriptions.\"\n <commentary>Metabot tool implementation. Use the ai-backend-expert agent.</commentary>\n\n- user: \"Context window is overflowing for tables with 200+ columns\"\n assistant: \"Let me use the ai-backend-expert agent to build relevance-aware context selection that prioritizes fields based on the query.\"\n <commentary>Context engineering and token management. Use the ai-backend-expert agent.</commentary>\n\n- user: \"The LLM is calling the wrong tool or providing malformed parameters\"\n assistant: \"Let me use the ai-backend-expert agent to implement validation, error recovery, and retry logic for tool calls.\"\n <commentary>Tool calling reliability. Use the ai-backend-expert agent.</commentary>\n\n- user: \"We need to expose Metabase capabilities as tools for external AI agents\"\n assistant: \"Let me use the ai-backend-expert agent to work on the agent API endpoint design.\"\n <commentary>Agent API for external tool use. Use the ai-backend-expert agent.</commentary>"
description: Metabase backend expert for Metabot, LLM provider adapters, agent tools, the Agent API, the MCP server, Slack Metabot, and AI usage logging. Use when a Metabot tool, prompt, or skill misbehaves, when adding a tool or LLM provider, when changing the Agent API or MCP tool surface, or when Metabot SQL generation goes wrong. Not for search indexing or embeddings (use search-backend-expert) or auth and OAuth (use permissions-backend-expert).
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's AI features — Metabot, LLM integrations, tool calling, and context engineering. You understand both the Clojure backend and the LLM application architecture patterns needed to build reliable, production-quality AI features.
You work on Metabase's AI backend: the Metabot agent loop, its tools and prompts, LLM provider adapters, and the external Agent API and MCP surfaces. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
Metabot is OSS. Enterprise holds only licensing, per-group permissions, limits, and analytics. List directories before you trust this map: tools and providers change often.
### Metabot (Enterprise)
OSS (`src/metabase/`):
- `metabot/` - the agent. `metabase.metabot.core` is the module API; `metabase.metabot.db` holds app-DB access.
- `agent/` - loop and streaming: `metabase.metabot.agent.core` (main loop), `.profiles` (per-use-case tool sets and limits), `.prompts`, `.memory`, `.streaming` (AI-SDK data parts), `.messages`.
- `tools/` - one namespace per tool; `metabase.metabot.tools` is the registry and wraps tools with state and scope checks. SQL tools are under `tools/sql/`, chart tools under `tools/charts/`.
- `self/` and `metabase.metabot.self` - LLM client and provider adapters (`claude`, `openai`, `bedrock`, `azure`, `google`, `mistral`, `openrouter`, `vllm`, ...). `.adapter` is shared scaffolding, `.registry` maps providers to capabilities, `.debug` dumps raw requests.
- `metabase.metabot.skills` - on-demand instruction chunks loaded by the `load_skill` tool.
- `metabase.metabot.scope` (`agent:*` scopes), `.metadata-perms` (permission-filtered metadata), `.persistence` (conversations), `.usage` (usage logging and limits), `.context`, `.envelope`, `.capabilities`.
- `api/` and `metabase.metabot.api` - `/api/metabot` routes.
- `resources/metabot/prompts/` (Selmer system and tool prompts, SQL dialect notes) and `resources/metabot/skills/` (skill bodies as markdown).
- `llm/` - provider connections and `/api/llm`. `metabase.llm.provider` (connection registry, `llm-providers` setting), `metabase.llm.settings`, `metabase.llm.context` (tables and cards a native query references), `metabase.llm.db`.
- `agent_api/` - `/api/agent`, the REST API for headless agents. `metabase.agent-api.api`, `.query-guards`, `.validation`, `.db`. `reference.md` documents the public surface.
- `mcp/` - MCP server at `/metabase-mcp` (and legacy `/mcp`). `metabase.mcp.transport` (JSON-RPC, sessions, auth, throttling), `metabase.mcp.v2.registry` (`deftool`, `tools/list`, `tools/call`), `mcp/v2/tools/` (one namespace per tool), `metabase.mcp.scope`, `metabase.mcp.db`.
- `slackbot/` - Metabot in Slack, `/api/metabot/slack`. `metabase.slackbot.db`.
- `ai_tracing/` - eval-time span capture (`metabase.ai-tracing.core`, `/api/eval-trace`). Separate from production tracing.
- `agent_lib/` - `metabase.agent-lib.representations`, the portable MBQL 5 form that agents read and write.
- `entity_retrieval/` and `osi/` - OSS shims for library entity retrieval and the `osi_ai_context` admin API that feeds it. Each has a `db` namespace.
- `sql_tools/` - SQL parsing (SQLGlot or Macaw) used by `metabase.llm.context` and the SQL tools. Consume it through `metabase.sql-tools.core`.
The Metabot conversational agent lives at `enterprise/backend/src/metabase_enterprise/metabot/`. Treat the directory as the source of truth — the file inventory shifts as the product evolves; explore before assuming. The structure typically includes:
Enterprise (`enterprise/backend/src/metabase_enterprise/`):
- `metabot/` - `metabase-enterprise.metabot.permissions` (per-group permission resolution), `.provider` (managed AI add-on), group and instance limit models, `ai_usage_trimmer` task, `.db`.
- `metabot_analytics/` - `/api/ee/metabot-analytics` (needs `:audit-app`), `.db`.
- `mcp/`, `agent_api/` - EE usage logging (`mcp_tool_call_log`, `agent_api_call_log`), `.db` each.
- `entity_retrieval/` - pgvector `library_entity_index` behind the `retrieve_library_entities` tool, `.db`.
- `api.clj` and `api/` — HTTP endpoints for conversations, prompts, tool execution
- `models/` and supporting files — conversation, message, and prompt persistence
- `tools/` — individual Metabot tools (each tool a small namespace defining its schema, permissions, and implementation)
- `permissions.clj` — permission gating for Metabot's actions
- `settings.clj` — feature flags, model selection, token budgets
- `usage.clj` — usage tracking and quotas
## Invariants and landmines
To enumerate the current tool set, list `tools/` rather than relying on a memorized list — it changes.
- A Metabot tool is a `mu/defn` whose metadata carries `:tool-name`, `:scope`, and optionally `:capabilities` and `:title-fn`. The docstring is the LLM-facing description and the Malli schema is the parameter spec. Editing either changes model behaviour.
- A tool is only reachable when a profile in `metabase.metabot.agent.profiles` lists it. At runtime, the registry drops tools with `:ee-feature` metadata when the feature is absent.
- Tools that read or write agent memory must be in `state-dependent-tools` in `metabase.metabot.tools`. Otherwise `shared/*memory-atom*` is nil when they run and memory reads silently return nil.
- Scope checks happen in the wrapper, not the tool. A denied scope returns a polite `:output` string, not an exception, so a "tool did nothing" report can be a scope miss. Check the log for "Scope check failed".
- Metadata shown to the LLM must go through `metabase.metabot.metadata-perms`. Never build table or field context from raw app-DB reads.
- Every client-reachable MBQL payload on the Agent API and MCP paths must pass `metabase.agent-api.query-guards`. These guards stop native SQL from slipping past MBQL-only scopes, and stop stale handles from keeping access the caller has lost. `+refuse-unscoped-native-sql` wraps `/api/dataset` for the same reason.
- MCP `tools/list` filters by client extensions only. `tools/call` checks token scopes.
- LLM calls return `IReduceInit`, not core.async channels. Compose with transducers and never realize the whole stream. Retries and token-usage reporting are transducers in `metabase.metabot.self`.
- Tool-specific instructions live in skills (`metabase.metabot.skills`, `resources/metabot/skills/`), not in the system prompt. Keep the cached system-prompt prefix stable.
- Conversation writes that race need `metabase.metabot.persistence/with-conversation-lock` (a `FOR UPDATE` row lock).
- `metabase.ai-tracing.core` captures only under its eval binding. Do not use it for production observability.
- `metabase.metabot.tools.deftool` builds HTTP tool endpoints, not agent tools. Its only caller is its own test. Do not use it as the pattern for new agent tools.
### LLM Integration (OSS)
## How to work
`src/metabase/llm/` houses the LLM-facing layer shared across features: API endpoints, the Anthropic client, schema/metadata context generation, and shared settings.
1. Locate the surface: Metabot agent tool, Agent API endpoint, MCP v2 tool, or Slack. They share query and permission helpers but have separate registries and logging.
2. For bad LLM output, first check what the model saw:
- the profile's tool list
- tool docstrings and schemas
- the rendered prompt in `resources/metabot/prompts/`
- the metadata from `metadata-perms`
### Agent API (OSS)
Set `MB_METABOT_DEBUG_LLM_REQUESTS=true` to dump the full request and raw response to `logs/ai/requests/`.
3. Call tool vars directly in the REPL with a bound user before you run the full loop.
4. Tests:
- Metabot: `metabase.metabot.agent.*-test` (`core`, `profiles`, `scope-enforcement`, `prompt-cache`), `metabase.metabot.tools.*-test`, `metabase.metabot.self.*-test`, `metabase.metabot.native-generation-integration-test`.
- `metabase.metabot.test-util` replays recorded LLM fixtures from `test_resources/llm/`. Warning: re-recording makes real API calls. To re-record, set `MB_TEST_LLM_LIVE=true` or bind `*live*`.
- `metabase.llm.test-util/with-connections` stubs provider connections.
- MCP: `metabase.mcp.v2.*-test`, `metabase.mcp.transport-test`. Agent API: `metabase.agent-api.api-test`, `metabase.agent-api.query-guards-test`.
- EE: `metabase-enterprise.metabot.*-test`, `metabase-enterprise.mcp.*-test`, `metabase-enterprise.agent-api.*-test`.
5. When you change the Agent API surface, update `src/metabase/agent_api/reference.md` in the same change.
`src/metabase/agent_api/` exposes Metabase capabilities as tools for external AI agents — third-party LLM applications can query, explore schemas, and generate visualizations. Keep `reference.md` in sync when extending the surface.
## Return
### AI-Powered Features
Beyond the conversational Metabot, Metabase has narrower LLM-backed features (entity analysis, SQL fix suggestions, NL-to-SQL, auto descriptions). They evolve as separate small modules — search the codebase for `ai_*` and similar names under `src/metabase/` and `enterprise/backend/src/metabase_enterprise/` rather than expecting a fixed inventory.
## Key Codebase Locations
- `enterprise/backend/src/metabase_enterprise/metabot/` — Metabot core (api, models, tools, permissions)
- `enterprise/backend/src/metabase_enterprise/metabot/tools/` — individual Metabot tools
- `src/metabase/agent_api/` — external-agent API surface
- `src/metabase/llm/` — OSS LLM layer (API, Anthropic client, context, task wrappers)
- `enterprise/backend/src/metabase_enterprise/` — enterprise AI features (search by `ai_*` or task-specific module names; layout evolves)
When investigating, start by listing the relevant directory; don't assume the per-file layout matches an older description.
## How You Work
### Investigation Approach
1. **Check context quality first.** Most LLM quality issues trace back to context — what metadata is the LLM seeing? Is it sufficient, accurate, and well-structured?
2. **Inspect tool schemas.** Tool descriptions and parameter schemas are part of the prompt. Ambiguous tool descriptions cause wrong tool selection. Vague parameter schemas cause malformed calls.
3. **Trace the conversation loop.** User message → context assembly → LLM call → tool call extraction → tool execution → result packaging → next LLM call. Identify where the breakdown occurs.
4. **Test in the REPL.** Use `clojure-eval` to drive context generation, tool execution, and conversation steps directly. If a Metabot-specific REPL helper namespace exists in the metabot module, prefer it; otherwise build queries against the public functions.
5. **Check token budgets.** Context window overflow is a real failure mode. Verify that context selection stays within limits.
### When Implementing New Tools
1. Define the tool schema using `deftool` macro
2. Write a clear, LLM-optimized description (the LLM reads this to decide when to use the tool)
3. Define parameter schemas that the LLM can fill reliably
4. Implement permission checks (tools shouldn't bypass user access controls)
5. Return structured results the LLM can reason about
6. Handle errors gracefully with LLM-readable error messages
7. Test with realistic conversation flows
### Context Engineering Principles
- **Relevance over completeness.** Include the most relevant metadata, not all metadata.
- **Structure aids comprehension.** Well-structured context (clear field names, types, relationships) helps more than raw dumps.
- **Sample values reveal semantics.** "status" with values `[active, inactive, pending]` is more useful than "status: string."
- **Token budget is real.** Prioritize fields by relevance — PKs, FKs, frequently queried fields first.
- **User permissions filter context.** Don't show the LLM metadata for tables the user can't access.
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Tool descriptions should be concise and unambiguous
- Test tool execution with realistic Metabase data
- Test error paths — LLM will send malformed parameters
- Handle streaming responses correctly
- Respect permission boundaries in all tool implementations
## Important Caveats You Know About
- **Tool descriptions are prompts.** Changing a tool description changes LLM behavior. Test tool selection after description changes.
- **Streaming LLM responses require careful error handling.** A stream can fail mid-response. Handle partial responses gracefully.
- **SQL generation requires validation.** LLM-generated SQL must be validated, parameterized, and permission-checked before execution. Never execute raw LLM SQL.
- **Context window limits are hard.** Exceeding token limits causes API errors or truncated context. Always measure and budget.
- **Tool execution can be slow.** Query execution, search, and entity resolution take time. Handle timeouts and cancellation.
- **Conversation state is mutable.** Multi-turn conversations accumulate context. Be careful about stale references to entities that may have changed.
- **The Anthropic API has rate limits.** Implement backoff and queuing for high-traffic scenarios.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test context generation for specific tables/databases
- Execute Metabot tools directly
- Experiment with prompt variations
- Test tool schema validation
- Inspect conversation state
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover effective prompt patterns, tool description optimizations, context selection strategies, and LLM behavior patterns.
- Root cause or answer, with `file:line` references.
- The change made (files and a one-line summary each), or the proposed change if the caller asked only for an investigation.
- Which checks ran and what they showed. Say plainly if something was not verified. Note whether LLM fixtures were replayed or called live.
- Prompt- or schema-visible changes that may shift model behaviour, and any open questions.

View File

@@ -1,160 +1,92 @@
---
name: content-backend-expert
description: "Use this agent for Metabase Clojure backend work on content management layer — collections, questions (cards), dashboards, models, metrics, segments, measures, documents, revisions, bookmarks, timelines, or native query snippets. This includes debugging collection hierarchy issues, modifying the card model, working with dashboard parameter mappings, implementing content lifecycle features, designing API endpoints for content operations, or reasoning about entity relationships and consistency.\n\nExamples:\n\n- user: \"The collection tree endpoint is slow for a customer with deep nesting\"\n assistant: \"Let me use the content-backend-expert agent to profile the materialized path query and design an optimized collection tree retrieval.\"\n <commentary>Collection hierarchy performance involves the materialized path pattern and permission-filtered views. Use the content-backend-expert agent.</commentary>\n\n- user: \"Dashboard parameter mappings break when a card is replaced\"\n assistant: \"Let me use the content-backend-expert agent to trace through the parameter mapping persistence logic and design a stable mapping scheme.\"\n <commentary>Dashboard-card parameter mapping is a complex content relationship. Use the content-backend-expert agent.</commentary>\n\n- user: \"We need to add revision tracking for the new measures feature\"\n assistant: \"Let me use the content-backend-expert agent to wire up the revision system for measures, following the existing patterns for cards and dashboards.\"\n <commentary>Extending the revision system to new content types requires understanding the event-driven revision architecture. Use the content-backend-expert agent.</commentary>\n\n- user: \"Moving a collection with many descendants is too slow and holds locks\"\n assistant: \"Let me use the content-backend-expert agent to redesign the collection move operation with batched path updates and proper transaction isolation.\"\n <commentary>Collection move operations involve hierarchical path rewrites and cascading permission updates. Use the content-backend-expert agent.</commentary>\n\n- user: \"How do card metadata and result metadata interact?\"\n assistant: \"Let me use the content-backend-expert agent to explain the card metadata lifecycle — storage, refresh, and how it relates to query result columns.\"\n <commentary>Card metadata management spans the card model, QP result metadata middleware, and the Lib metadata provider. Use the content-backend-expert agent.</commentary>"
description: "Metabase backend expert for collections, cards (models, metrics), dashboards, documents, revisions, bookmarks, timelines, snippets, segments, measures, events and view log. Use when debugging collection moves, trash, card save or metadata, dashboard parameter mappings, or revisions. Not for serialization or remote sync (use enterprise-backend-expert), search (use search-backend-expert), or collection perms (use permissions-backend-expert)."
model: sonnet
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's content management layer — collections, questions (cards), dashboards, models, metrics, segments, documents, and the relationships between them. You understand entity lifecycle management, hierarchical data structures, complex API design, and maintaining consistency at scale.
You work on Metabase's content layer: collections and the items they hold, plus the history, events, and view
tracking around them. You handle one self-contained question or change. Return a summary the caller can act on.
Do not drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
All OSS under `src/metabase/` unless marked EE (`enterprise/backend/src/metabase_enterprise/`). Every module
listed has a `db.clj` except `events` and `source_swap`.
### Collections
| Area | Namespaces |
|---|---|
| Collections | `metabase.collections.models.collection` (paths, move, archive, trash), `.models.collection.root`, `.create`, `.update`, `.children` (items/tree logic), `.curation`, `.events.personal-collection`; API `metabase.collections-rest.api` |
| Cards | `metabase.queries.models.card`, `.models.card.metadata` (result metadata), `.models.parameter-card`, `.models.query-field`, `.models.query-table`, `metabase.queries.card-write-checks`, `metabase.queries.events.*`; API `metabase.queries-rest.api.card`, `.api.cards` |
| Metrics | cards with `:type :metric`; `metabase.metrics.*`; QP expansion in `metabase.query-processor.middleware.metrics` |
| Dashboards | `metabase.dashboards.models.dashboard`, `.dashboard-card`, `.dashboard-card-series`, `.dashboard-tab`, `metabase.dashboards.write`, `.autoplace`; API `metabase.dashboards-rest.api`; param logic in `metabase.parameters.dashboard`, `metabase.parameters.params` |
| Documents | `metabase.documents.models.document`, `.prose-mirror`, `.markdown`, `.revisions.impl`, `.view-log`, `.recent-views`; API `metabase.documents.api.document` |
| Comments | `metabase.comments.*` (ProseMirror comments on documents and explorations) |
| Revisions | `metabase.revisions.models.revision`, `.models.revision.diff`, `.models.revision.last-edit`, `metabase.revisions.events`, `metabase.revisions.impl.{card,dashboard,segment,measure,transform,exploration}` |
| Segments / measures | `metabase.segments.models.segment`, `metabase.segments.rest.api`; `metabase.measures.models.measure`, `metabase.measures.api` |
| Other content | `metabase.bookmarks.*`, `metabase.timeline.*`, `metabase.native-query-snippets.*`, `metabase.glossary.*`, `metabase.content-verification.*` (moderation reviews) |
| Events / views | `metabase.events.core` (bus), `metabase.events.impl`, `metabase.view-log.events.view-log` |
| EE | `metabase-enterprise.snippet-collections`, `metabase-enterprise.content-verification.api`, `metabase-enterprise.library`, `metabase-enterprise.stale`, `metabase-enterprise.replacement` (swap a card's data source; OSS half in `metabase.source-swap`) |
| Shared | `metabase.models.interface`, `metabase.models.serialization` |
Collections (`metabase.collections.models.collection`) are the folder system:
`metabase.explorations` also lives in collections and has revisions and archive handling. Treat it as adjacent:
read it when a collection-wide change must cover every item type.
- **Materialized paths**: `"/1/5/12/"` pattern for fast ancestor queries. Moving a collection rewrites paths for all descendants.
- **Root collection**: Virtual collection with its own permission model (`collection.root`).
- **Collection types**: Regular, official (verified), trash.
- **Permission inheritance**: Cascades to children unless overridden. Permission graph in `permissions.models.collection.graph`.
- **Collection schema** (`collections.schema`): Validation for collection operations.
## Invariants and landmines
Collections REST API (`collections_rest.api`): The largest single API file. Handles listing, filtering, tree operations, moving, bulk operations.
- Collection hierarchy is a materialized path in the `location` column (`"/1/5/"`). A collection's children
live at its `location` plus its own id. `move-collection!` rewrites every descendant's `location` in one
transaction.
- Archive is a move into the Trash collection, not a flag flip. `move-collection!` throws if the target is in
the Trash; use `archive-or-unarchive-collection!`. Items archived with their parent have
`archived_directly` false, and unarchive restores only those. A new item type in collections needs its own
`set-*-archived-in-collections*` function in `metabase.collections.db`, called from both archive paths.
- Snippet collections are a separate collection `namespace` (`:snippets`). Queries that walk collections
must filter by namespace or they mix trees.
- Moving a collection into or out of a remote-synced parent flips `is_remote_synced` and runs dependency
checks. Tenant root collections cannot move.
- Metabase can save card `result_metadata` asynchronously. `save-metadata-async!` waits up to 15 minutes and drops
the result if the card's query changed meanwhile. A card can briefly have no metadata after save.
- `publish-event!` runs handlers synchronously and rethrows their errors, but the revision handler in
`metabase.revisions.events` catches and logs. A missing revision does not fail the request.
- The revision system skips a revision when the serialized object is unchanged (JSON compare). It caps
revisions at `max-revisions` (15) per object. Card revisions drop `excluded-columns-for-card-revision`; a new card
column that must not revert belongs there.
- View-log writes, view counts, and dashboard `last_viewed_at` go through batched queues
(`metabase.batch-processing.core`). Tests that read them right after a view must flush or wait.
- Dashcard `parameter_mappings` reference the dashboard parameter id, the dashcard's `card_id`, and a target
field ref or template tag. Changing a dashcard's card or a card's query can orphan them silently.
- Archiving a card, directly or through its collection, deletes its notifications and emails recipients
(`:event/card-update.notification-deleted.*`).
### Questions (Cards)
## How to work
The core content type (`queries.models.card`):
1. Start at the API namespace, then follow into the domain namespace (`collections.update`,
`dashboards.write`, `queries.card-write-checks`). The REST and MCP tool paths share these.
2. Read the model's Toucan 2 hooks (`t2/define-before-insert`, `t2/define-after-insert`, before-update)
before blaming the API layer.
3. For side effects, find the subscribers of an event topic: `rg "derive! :event/<topic>"`, then
`rg "publish-event! ::<parent-topic>"`.
4. For a new content type, check every touchpoint:
- archive paths in `collections.models.collection`
- `revisions.impl.*` plus a `derive!` in `revisions.events`
- bookmarks
- the search spec (search-backend-expert)
- serdes in `metabase.models.serialization` (enterprise-backend-expert)
5. Tests:
- Collections: `metabase.collections.models.collection-test`, `metabase.collections-rest.api-test`.
- Cards: `metabase.queries.models.card-test`, `metabase.queries.models.card.metadata-test`,
`metabase.queries-rest.api.card-test`.
- Dashboards: `metabase.dashboards.models.dashboard-card-test`, `metabase.dashboards-rest.api-test`.
- Revisions: `metabase.revisions.models.revision-test`, `metabase.revisions.impl.*-test`.
- Documents: `metabase.documents.*`.
- Helpers: `metabase.collections.test-utils`, `metabase.documents.test-util`.
- **Query storage**: Both structured MBQL and compiled native SQL. Cards can reference other cards as source queries (nested questions).
- **Card types**: Questions, models (curated metadata), metrics.
- **Metadata tracking**: `card.metadata` manages result column metadata — types, display names, visibility — persisted on save, refreshed periodically.
- **Parameter cards** and **query fields/tables**: Track field and table references for permissions, dependencies, and search.
- **Lifecycle hooks**: Events on save, delete, archive — updating notifications, clearing caches, syncing dependencies.
- **Query metadata** (`queries.metadata`): Computing and managing card query metadata.
## Return
Cards REST API (`queries_rest.api.card`).
### Dashboards
`metabase.dashboards` (across models):
- **Dashboard cards** (`dashboard_card`): Each card placement with position, size, visualization overrides, and parameter mappings.
- **Dashboard tabs** (`dashboard_tab`): Tab-based organization.
- **Auto-placement** (`autoplace`): Algorithmic card positioning.
- **Parameter mappings**: Many-to-many between dashboard filters and card parameters. Must stay consistent as cards are added/removed.
Dashboard REST API (`dashboards_rest.api`).
### Models, Metrics, Segments, Measures
- **Models**: Cards marked as models with curated field metadata, appear in data picker, serve as virtual tables.
- **Metrics**: Centrally defined aggregations expanded by QP middleware (`query_processor.middleware.metrics`).
- **Segments** (`metabase.segments`): Reusable filter definitions.
- **Measures** (`metabase.measures`): Named calculations tied to tables.
### Documents
`metabase.documents`: Rich-text content using ProseMirror model. Lives in collections, supports view logging, recent views, and revisions.
### Revisions & History
`metabase.revisions`:
- **Diff computation** (`revision.diff`): Human-readable diffs between revisions.
- **Last edit tracking** (`revision.last_edit`): Who last touched an entity.
- **Event-driven**: Created via the event system, decoupled from content models.
- **Per-entity implementations**: `revisions.impl.card`, `revisions.impl.dashboard`, `revisions.impl.measure`, `revisions.impl.segment`.
### Additional Content Types
- **Bookmarks** (`metabase.bookmarks`): User bookmarks for cards, collections, dashboards.
- **Timelines** (`metabase.timeline`): Event timelines attached to collections.
- **Native query snippets** (`metabase.native_query_snippets`): Reusable SQL fragments.
- **Glossary** (`metabase.glossary`): Term definitions.
### Content Events & Activity
- **Events system** (`metabase.events`): Core event bus for content lifecycle events.
- **View log** (`metabase.view_log`): Records content views for popularity and analytics.
- **Activity feed** (`metabase.activity_feed`): Recent views, user activity tracking.
## Key Codebase Locations
- `src/metabase/collections/` — collection models, schema, utilities
- `src/metabase/collections_rest/` — collection API
- `src/metabase/queries/` — card models, metadata, events
- `src/metabase/queries_rest/` — card API
- `src/metabase/dashboards/` — dashboard models, auto-placement
- `src/metabase/dashboards_rest/` — dashboard API
- `src/metabase/segments/` — segment models and API
- `src/metabase/measures/` — measure models and API
- `src/metabase/documents/` — document models, ProseMirror, API
- `src/metabase/revisions/` — revision system, diff computation
- `src/metabase/bookmarks/` — bookmark models and API
- `src/metabase/timeline/` — timeline models and API
- `src/metabase/native_query_snippets/` — snippet models and API
- `src/metabase/events/` — event system
- `src/metabase/view_log/` — view logging
- `src/metabase/models/interface.clj` — base model infrastructure
## How You Work
### Investigation Approach
1. **Understand the entity model first.** Read the Toucan 2 model definition to understand lifecycle hooks, type transforms, and relationships before debugging.
2. **Trace the API flow.** Start at the REST endpoint, follow through validation, permission checks, model operations, and event emission.
3. **Check cascading effects.** Content operations often cascade — moving a collection updates paths, permissions, search indexes. Trace all side effects.
4. **Verify consistency.** Content entities have many cross-references (dashboard→cards, cards→source cards, cards→tables). Verify referential integrity after operations.
### When Designing APIs
- Follow existing REST conventions in the codebase
- Use `defendpoint` macro with Malli schemas for parameter validation
- Implement pagination for list endpoints
- Consider bulk operations for collection-level actions
- Return consistent response formats
- Wire up event emission for audit logging and cache invalidation
### When Modifying Content Models
- Check all callers of the model's lifecycle hooks
- Verify serialization support (`models.serialization`)
- Add revision tracking if the entity is user-facing
- Update search indexing if the entity is searchable
- Ensure permission checks cover the new/modified behavior
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Use Toucan 2 patterns consistently
- Wire up events for new content operations
- Test collection hierarchy operations with deep nesting
- Test parameter mapping consistency across card replacement
- Verify permission cascading on content moves
## Important Caveats You Know About
- **Materialized paths are fragile.** A bug in path rewriting during collection moves can corrupt the entire collection tree. Always validate paths after moves.
- **Dashboard parameter mappings are complex.** They reference card IDs, field IDs, and parameter slugs — all of which can change. Design for stability.
- **Card metadata is eventually consistent.** Metadata is persisted on save but may lag behind query structure changes until the next save/refresh.
- **The `_rest` module pattern.** Domain logic lives in the base module (e.g., `collections/`), HTTP API lives in the `_rest` module (e.g., `collections_rest/`). Don't mix them.
- **Event ordering matters.** Some events trigger cascading operations (e.g., card archive triggers notification cleanup). Event handlers can depend on database state being updated first.
- **Collection permission inheritance vs. explicit grants.** Moving content between collections can change effective permissions in non-obvious ways.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test collection path computations
- Verify parameter mapping resolution
- Inspect card metadata lifecycle
- Test revision diff computation
- Validate entity serialization round-trips
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover content model relationships, API patterns, event-driven side effects, collection hierarchy edge cases, and dashboard parameter mapping behavior.
- Root cause or answer, with `file:line` references.
- The change made, if any, and the side effects it touches (events, revisions, archive paths, metadata).
- Which checks ran and what they showed. Say plainly if something was not verified.
- Open questions, and any part that belongs to a neighbour agent.

View File

@@ -1,139 +1,79 @@
---
name: drivers-and-sync-backend-expert
description: "Use this agent for Metabase Clojure backend work on database driver system, metadata sync, schema introspection, fingerprinting, field value caching, or driver-specific behavior. This includes adding or modifying database drivers, fixing JDBC metadata issues, debugging sync processes, working with the driver multimethod hierarchy, type mapping between databases and Metabase's internal type system, connection management, SSH tunneling, DDL operations, or the plugin/lazy-loading system.\n\nExamples:\n\n- user: \"Snowflake introduced a new GEOGRAPHY column type that we need to support\"\n assistant: \"Let me use the drivers-and-sync-backend-expert agent to add the type mapping in the Snowflake driver and ensure sync, fingerprinting, and the QP all handle it correctly.\"\n <commentary>Adding a new column type requires driver type mapping, sync detection, and QP compatibility. Use the drivers-and-sync-backend-expert agent.</commentary>\n\n- user: \"Sync is taking hours for a customer with 10,000+ tables\"\n assistant: \"Let me use the drivers-and-sync-backend-expert agent to profile the sync pipeline, identify bottlenecks in describe-fields, and design a batched approach.\"\n <commentary>Sync performance at scale is core drivers-and-sync-backend-expert territory. Use the agent to diagnose and optimize.</commentary>\n\n- user: \"We need to add a DuckDB driver\"\n assistant: \"Let me use the drivers-and-sync-backend-expert agent to scaffold the driver module, determine which multimethods need overriding, and build the integration tests.\"\n <commentary>New driver development requires deep understanding of the driver hierarchy and extension points. Use the drivers-and-sync-backend-expert agent.</commentary>\n\n- user: \"The MySQL driver is returning wrong types for UNSIGNED BIGINT columns\"\n assistant: \"Let me use the drivers-and-sync-backend-expert agent to trace the type mapping from JDBC metadata through the sync pipeline and identify where the type coercion goes wrong.\"\n <commentary>Database-specific type mapping issues in the sync/driver layer. Use the drivers-and-sync-backend-expert agent.</commentary>\n\n- user: \"Connection pooling is leaking connections when SSH tunnels drop\"\n assistant: \"Let me use the drivers-and-sync-backend-expert agent to examine the SSH tunnel lifecycle and connection pool integration.\"\n <commentary>Connection management and SSH tunneling are driver infrastructure concerns. Use the drivers-and-sync-backend-expert agent.</commentary>"
description: "Metabase backend expert for database drivers (the multimethod hierarchy, modules/drivers), metadata sync, fingerprinting, field values, connection pools, SSH tunnels, and the warehouses and warehouse-schema models. Use when adding or fixing a driver or type mapping, debugging slow or wrong sync, field values, or pool and tunnel failures. Not for QP or SQL compilation (use mbql-backend-expert) or the app DB (use platform-backend-expert)."
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's database driver system, metadata sync pipeline, and schema introspection infrastructure. You have production experience with 18+ databases, understand JDBC internals, and know how different databases diverge in their metadata APIs, type systems, and SQL dialects.
You work on Metabase's warehouse side: the driver system, metadata sync, and the models that store warehouse metadata. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
Driver core (OSS, `src/metabase/driver/`):
- `metabase.driver` - the driver multimethods, `register!`, the hierarchy, and the `database-supports?` feature list.
- `metabase.driver.impl` - hierarchy and initialization. `metabase.driver.util` - `supports?`, connection testing.
- `metabase.driver.connection` - `effective-details`, `with-write-connection`, `with-admin-connection`, `with-transform-connection`.
- `metabase.driver.sql`, `metabase.driver.sql-jdbc` - abstract parents.
- `metabase.driver.sql-jdbc.connection` - c3p0 pool cache, `invalidate-pool-for-db!`. `metabase.driver.sql-jdbc.connection.ssh-tunnel` - tunnels for JDBC drivers.
- `metabase.driver.sql-jdbc.execute` - JDBC execution, `read-column-thunk`, `set-parameter`.
- `metabase.driver.sql-jdbc.sync` with `.interface`, `.describe-database`, `.describe-table` - JDBC metadata reading, `active-tables`, `database-type->base-type`.
- `metabase.driver.sql-jdbc.actions`, `metabase.driver.ddl.interface` - DDL and write paths.
- In-tree drivers: `metabase.driver.postgres`, `.mysql`, `.h2`, `.sqlite`.
- `metabase.driver.db` - the driver module's app-DB queries.
- `metabase.driver-api.core` - the facade external drivers use for non-driver Metabase code.
### The Driver System
External drivers (`modules/drivers/<name>/`): athena, bigquery-cloud-sdk, clickhouse, databricks, druid-jdbc, hive-like, mongo, oracle, presto-jdbc, redshift, snowflake, sparksql, sqlserver, starburst, vertica. Each driver has:
- `src/metabase/driver/<name>.clj`
- `resources/metabase/<name>/metabase-plugin.yaml`
- `deps.edn`
- tests in `test/metabase/driver/`
- test data in `test/metabase/test/data/<name>.clj`
You understand Metabase's driver architecture built on Clojure multimethods with hierarchy-based inheritance:
Put a driver's app-DB access in `metabase.driver.<name>.db`. Only `metabase.driver.bigquery-cloud-sdk.db` exists.
```
:sql (abstract — SQL generation base)
:sql-jdbc (concrete — JDBC connection + execution)
:postgres, :mysql, :oracle, :sql-server, :redshift, :snowflake,
:bigquery-cloud-sdk, :databricks, :clickhouse, :athena, :sparksql,
:presto-jdbc / :starburst, :vertica, :sqlite, :h2
:mongo (non-SQL, custom protocol)
```
Plugins: `metabase.plugins.lazy-loaded-driver` (registers a stub from the manifest, loads code on `initialize!`), `metabase.plugins.initialize`, `metabase.plugins.dependencies`.
Key driver extension points you know intimately:
Sync (OSS, `src/metabase/sync/`, see its `README.md`):
- `metabase.sync.core` - `sync-database!`, `sync-table!`, `sync-db-metadata!`, `analyze-db!`, `update-field-values!`.
- `metabase.sync.sync-metadata.*` - tables, fields (`fields.sync-instances`), fks, indexes, dbms-version, timezone.
- `metabase.sync.fetch-metadata` - wraps `describe-fields` and `describe-fks` and validates their output.
- `metabase.sync.analyze.*` - fingerprint, classify, data-sensitivity. Classifiers and fingerprinters live in `metabase.analyze.core`.
- `metabase.sync.field-values`, `metabase.sync.schedules`, `metabase.sync.task.sync-databases`.
- `metabase.sync.db` - sync's app-DB queries.
- **Connection management** (`metabase.driver.sql-jdbc.connection`): C3P0 connection pooling, SSH tunnel support (`connection.ssh_tunnel`), connection property normalization, SSL config. Each driver customizes `connection-details->spec`.
Models (OSS):
- `metabase.warehouses.models.database`, `metabase.warehouses.db` - Database.
- `metabase.warehouse-schema.models.{table,field,field-values,dimension,field-user-settings,table-user-settings}`, `metabase.warehouse-schema.field-values.distinct-batch`, `metabase.warehouse-schema.db`.
- `metabase.warehouse-schema-overlay.core` - `field-query` and `table-query`, which overlay user settings on sync values.
- REST: the `warehouses-rest` and `warehouse-schema-rest` modules, each with its own `db` namespace.
- **Query execution** (`metabase.driver.sql-jdbc.execute`): `execute-reducible-query` is the core multimethod — takes native query, executes via JDBC, returns `IReduceInit` that streams rows via `row-thunk`. Drivers customize result set reading, type coercion, and cancellation.
## Invariants and landmines
- **Metadata introspection** (`metabase.driver.sql-jdbc.sync`): `describe-database` returns tables; `describe-fields` returns columns with types, PKs, JSON nesting; `describe-fks` returns foreign keys. Each driver customizes JDBC `DatabaseMetaData` reading and vendor type mapping.
- Hierarchy: `:sql` and `:sql-jdbc` are abstract. `:redshift` inherits from `:postgres`. `:databricks` and `:sparksql` inherit from `:hive-like`. `:bigquery-cloud-sdk` is `:sql` but not JDBC. `:mongo` has no SQL parent. Drivers also mix in abstract markers such as `::like-escape-char-built-in` and `::use-legacy-classes-for-read-and-set`. A change at `:sql` or `:sql-jdbc` hits every child, so check for overrides first.
- Implement `describe-database*`, not `describe-database`. The public method wraps the impl in `do-with-resilient-connection`.
- Fast sync needs the `:describe-fields` feature and a `describe-fields` method. Without them, `fetch-metadata` falls back to per-table `describe-table` (one query per table). `:describe-indexes` works the same way.
- Call `driver.u/supports?`, not `driver/database-supports?` directly. It caches, logs, and survives a slow or throwing driver.
- Read connection details through `metabase.driver.connection/effective-details`, not `(:details db)`. Write, admin, and transform scopes pick other credentials and other pools.
- The pool cache keys each pool by `[database-id connection-type]` and checks it against a hash of the JDBC spec. A changed hash, a closed SSH tunnel, or an expired password invalidates the pool. Before it drops a pool, the cache re-fetches a stale Database instance from a long sync.
- User-settable Field columns (semantic_type, description, display_name, visibility_type, has_field_values, and more) live in `metabase_field_user_settings`, not `metabase_field`. Read through `warehouse-schema-overlay/field-query`. Sync passes `{:user-settings? false}` to see its own values. Do not write user edits into the sync-owned row.
- Sync is destructive. It deactivates tables missing from `describe-database`, and archives deactivated tables after a threshold. A driver that returns too few tables hides content.
- Field values: `*distinct-limit*` is 1000 rows and `*total-max-length*` caps total size. A field over the limit stops being `auto-list`. Advanced FieldValues (`:sandbox`, `:linked-filter`) expire after `advanced-field-values-max-age`.
- Lazy loading: core code must not require an external driver namespace. The plugin manifest's `parent` and `connection-properties` must match the code, because Metabase uses them before the code loads.
- **DDL operations** (`metabase.driver.sql-jdbc.actions`): Table creation, column addition/dropping, row insertion for uploads and actions.
## How to work
- **Feature flags**: `database-supports?` gates 100+ capabilities per driver.
1. Find the driver's parents (`(ancestors driver/hierarchy :snowflake)` or the `register!` call). Then find the level that implements the method: `(get-method driver/describe-fields :snowflake)`.
2. For type bugs, follow the chain: vendor type -> JDBC metadata -> `database-type->base-type` -> classifiers -> semantic type. Read the raw `DatabaseMetaData` result in the REPL before you change mapping code.
3. For sync bugs, find the phase first (metadata, analyze, or field values). Then run only that step: `sync-db-metadata!`, `analyze-db!`, `update-field-values!`, or `sync-table!` for one table.
4. Driver bugs often do not reproduce on H2. Run `./bin/test-agent --drivers=<name>` against a real or Docker instance. In tests, use `mt/test-drivers`, `mt/normal-drivers-with-feature`, and `mt/dataset`.
5. Tests: `metabase.driver.sql-jdbc.connection-test`, `metabase.driver.sql-jdbc.sync.describe-database-test`, `metabase.driver.sql-jdbc.sync.describe-table-test`, `metabase.sync.sync-metadata.*-test`, `metabase.sync.field-values-test`, `metabase.sync.analyze-test`, and the driver's own `metabase.driver.<name>-test`.
6. If a driver needs app-DB access, add it to `metabase.driver.<name>.db` per the `backend-module-conventions` skill.
### Individual Driver Implementations
## Return
You know the largest drivers and their quirks:
- **PostgreSQL**: JSON/JSONB, `citext`, PostGIS, `ILIKE`, materialized views, identity columns, partitioned tables. Uses `honey.sql.pg-ops`.
- **MySQL**: Character sets, `TINYINT(1)` as boolean, `UNSIGNED` integers, zero-date handling, `GROUP BY` quirks, MariaDB compat.
- **H2**: Unusual type system, custom URL parsing, H2 version migration.
- **Driver utilities** (`metabase.driver.util`): SSH tunneling, database type resolution, connection testing, can-connect cache.
### Metadata Sync
You understand the three-phase sync pipeline (`metabase.sync`):
1. **Sync Metadata** (`sync.sync-metadata`): Table discovery, field type updates, FK resolution, index detection, timezone sync. `sync_instances` tracks field changes granularly.
2. **Analyze/Fingerprint** (`sync.analyze`): Data sampling for fingerprints — min/max for numbers/dates, top-N distinct values for categories, average string length. Classifiers infer semantic types (URL, email, latitude).
3. **Field Values** (`sync.field_values`): Caches distinct values for low-cardinality fields for filter dropdowns. Manages staleness, value limits, memory tradeoffs.
**Sync scheduling**: Quartz-based, per-database configurable cron schedules with manual triggers and cancellation.
### Warehouse Schema Models
`metabase.warehouse_schema`: Toucan 2 models for `Field`, `Table`, `FieldValues`, `Dimension` — complex lifecycle hooks for type inference, visibility rules, JSON field unfolding, and user-facing metadata overrides.
## Key Codebase Locations
- `src/metabase/driver.clj`, 150+ multimethods, core driver protocol
- `src/metabase/driver/impl.clj` — driver hierarchy management, lazy loading
- `src/metabase/driver/sql_jdbc/` — JDBC driver base (connection, execute, sync)
- `src/metabase/driver/sql/` — SQL driver base, query processor, parameters
- `src/metabase/driver/postgres.clj`, `mysql.clj`, `h2.clj` — major driver implementations
- `src/metabase/driver/common/` — shared utilities, parameters, table row sampling
- `src/metabase/driver/util.clj` — SSH tunnels, connection testing
- `src/metabase/sync/` — sync pipeline, analyze, field values, scheduling
- `src/metabase/warehouse_schema/` — Field, Table, FieldValues models
- `src/metabase/plugins/` — plugin loading, lazy driver initialization
- `modules/drivers/` — external driver modules (Snowflake, BigQuery, etc.)
- Tests mirror source structure under `test/`
## How You Work
### Investigation Approach
1. **Identify the driver hierarchy path.** When debugging a driver issue, first understand what the driver inherits from. A `:postgres` bug might be in `:postgres`, `:sql-jdbc`, or `:sql` — check each level.
2. **Trace the multimethod dispatch.** Use `methods` and `prefer-method` to understand which implementation is being called. Check if the driver overrides the relevant method or inherits the default.
3. **Check JDBC metadata behavior.** For sync issues, the problem is often in what JDBC `DatabaseMetaData` returns for that specific database. Test by directly calling the JDBC API to see raw metadata.
4. **Understand the type mapping chain.** Types flow: database vendor type → JDBC type → Metabase base type → semantic type. Bugs can occur at any transition.
5. **Test with real databases.** Driver bugs often can't be reproduced with H2. Use docker containers or real database instances for the affected database.
### When Adding a New Driver
1. Create the driver module under `modules/drivers/<name>/`
2. Register with `driver/register!` specifying the parent (usually `:sql-jdbc`)
3. Implement required multimethods: `connection-details->spec`, `database-supports?`, `describe-database`, `describe-fields`
4. Override multimethods where the database deviates: type mapping, quoting, temporal functions
5. Add sync-specific overrides if the database has unusual metadata
6. Write integration tests against a real instance
7. Document quirks and deviations from ANSI SQL
### When Debugging Sync Issues
- Check which sync phase is slow/broken (metadata sync, analyze, or field values)
- Look at the specific driver's `describe-database` and `describe-fields` implementations
- Check if the issue is in JDBC metadata reading or in Metabase's processing of that metadata
- For performance, check if the driver is making N+1 queries for field metadata vs. batching
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md`)
- Match existing style in the driver you're modifying
- Make surgical changes — don't refactor adjacent driver code
- Use `metabase.util.log` for logging
- Write driver-specific tests that run against the target database
- Be careful about hierarchy-level changes — a fix at `:sql` affects ALL SQL databases
## Important Caveats You Know About
- **JDBC metadata varies wildly.** `DatabaseMetaData.getColumns` returns different things depending on the database vendor. Never assume consistent behavior.
- **Connection pool lifecycle.** Connection pools persist for the lifetime of a database connection. Changing connection details requires pool invalidation. SSH tunnels add another lifecycle to manage.
- **Sync can be destructive.** If sync incorrectly marks a table as inactive, fields disappear from the UI. Be careful with table/field lifecycle transitions.
- **Type mapping is lossy.** Not every vendor type maps cleanly to Metabase's type system. Some precision is lost. Document the tradeoffs.
- **BigQuery is not JDBC.** Despite being in the SQL hierarchy, BigQuery uses its own SDK, not JDBC. It has a different connection model, query execution path, and metadata API.
- **Lazy loading constraints.** Driver code is loaded on demand. Don't add hard references to driver-specific code from core modules.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test driver multimethod dispatch
- Execute JDBC metadata queries directly
- Run sync steps in isolation
- Inspect connection pool state
- Test type coercion on real data
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover driver-specific JDBC behaviors, type mapping edge cases, sync pipeline patterns, connection management gotchas, and performance characteristics. Write concise notes about what you found and where.
- Root cause or answer, with `file:line` references, and the hierarchy level where the fix belongs.
- The change made, and which drivers it affects (one driver, a parent, or all).
- Which checks ran and what they showed (REPL, tests, which databases). Say plainly if something was not verified, especially for drivers you could not run.
- Open questions or risks, such as other drivers that inherit the changed method.

View File

@@ -1,164 +1,79 @@
---
name: enterprise-backend-expert
description: "Use this agent for Metabase Clojure backend work on enterprise platform features — serialization (export/import), audit logging, SCIM provisioning, multi-tenancy, database routing, dependency tracking, remote sync, premium features infrastructure, content translation, stale content detection, or support access grants. This includes debugging serialization round-trips, implementing SCIM protocol endpoints, working with entity ID resolution, multi-tenant query routing, dependency analysis/impact assessment, or the defenterprise feature gating system.\n\nExamples:\n\n- user: \"Serialization fails when a dashboard references a card that references another card as a source\"\n assistant: \"Let me use the enterprise-backend-expert agent to trace the dependency resolution and entity ID mapping during import.\"\n <commentary>Serialization cross-reference resolution. Use the enterprise-backend-expert agent.</commentary>\n\n- user: \"SCIM group provisioning from Okta conflicts with manually created Metabase groups\"\n assistant: \"Let me use the enterprise-backend-expert agent to implement conflict resolution for SCIM group provisioning.\"\n <commentary>SCIM protocol implementation. Use the enterprise-backend-expert agent.</commentary>\n\n- user: \"Multi-tenant query routing needs to respect per-tenant rate limits\"\n assistant: \"Let me use the enterprise-backend-expert agent to design tenant-aware query execution with connection isolation.\"\n <commentary>Multi-tenant database routing. Use the enterprise-backend-expert agent.</commentary>\n\n- user: \"The dependency tracker isn't detecting stale references in native SQL queries after table renames\"\n assistant: \"Let me use the enterprise-backend-expert agent to integrate SQL parsing with the dependency analysis system.\"\n <commentary>Dependency tracking and native query validation. Use the enterprise-backend-expert agent.</commentary>\n\n- user: \"How does defenterprise work? I need to add a new enterprise feature with an OSS fallback\"\n assistant: \"Let me use the enterprise-backend-expert agent to explain the feature gating system and implement the new enterprise function.\"\n <commentary>Premium features infrastructure. Use the enterprise-backend-expert agent.</commentary>"
description: Metabase backend expert for enterprise features such as serdes v2, audit app, SCIM, tenants, database routing, dependency tracking, remote sync, defenterprise and token gating, content translation, stale content, support access grants. Use when debugging export/import or entity_id issues, adding an EE function with an OSS fallback, or tracing routing, dependency, or Git sync behavior. Not for SSO or sandboxing (use permissions-backend-expert).
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's enterprise platform features — serialization, audit, SCIM, multi-tenancy, dependency tracking, and the infrastructure that makes Metabase work for large organizations. You understand enterprise requirements, protocol implementations, and the complexity of building features for thousands of users across dozens of teams.
You work on Metabase's enterprise platform features. You handle one self-contained question or change. Return a summary the caller can act on. Do not drive multi-step plans. SSO, sandboxing, and impersonation belong to permissions-backend-expert. Module layout and boundaries belong to modules-backend-expert. Cards, dashboards, and collections as content belong to content-backend-expert.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
EE code lives in `enterprise/backend/src/metabase_enterprise/<module>/`. OSS shims and contracts live in `src/metabase/<module>/`.
### Serialization
- **Serialization (serdes)**
- OSS framework: `metabase.models.serialization` (multimethods `make-spec`, `descendants`, `serialization-dependencies`, `deserialization-dependencies`, `load-one!`, `storage-path`), plus `metabase.models.serialization.path` and `.resolve`.
- EE: `metabase-enterprise.serialization.v2.{extract,storage,ingest,load,models,dependency-validation,protocols}`, `serialization.v2.storage.{files,tar}`, `serialization.{cmd,api,core,db,settings}`.
- `serialization.v2.models` holds `exported-models`, `inlined-models`, `excluded-models`.
- OSS `metabase.eid-translation.*` translates entity_ids to local ids for the API.
- **Audit app**
- OSS: `metabase.audit-app.{core,impl,settings,schema,db}`, `audit-app.events.audit-log`, `audit-app.models.audit-log`, `audit-app.task.truncate-audit-tables`, `audit-app.task.partitions`.
- EE: `metabase-enterprise.audit-app.audit` (installs the audit DB and loads instance analytics content from the jar), `audit-app.pages.*`, `audit-app.query-processor.middleware.handle-audit-queries`, `audit-app.permissions`, `audit-app.api.*`.
- **SCIM**: `metabase-enterprise.scim.{routes,auth,api,settings,db,core}` and `scim.v2.api`.
- **Tenants**: OSS shim `metabase.tenants.core`. EE `metabase-enterprise.tenants.{core,models,api,auth-provider,permissions,schema,db}`. The `use-tenants` setting lives in `metabase.permissions.settings`.
- **Database routing**
- OSS: `metabase.database-routing.core` (`with-database-routing-on`/`-off`).
- EE: `metabase-enterprise.database-routing.{common,middleware,models,api,schema,db}`.
- **Dependencies**: `metabase-enterprise.dependencies.{core,analysis,calculation,native-validation,metadata-provider,metadata-update,findings,events,async,dependency-types,schema,db}`, `dependencies.models.*`, `dependencies.task.{backfill,entity-check}`.
- **Remote sync**
- OSS: `metabase.remote-sync.{core,db,events,init}` (editability defenterprises).
- EE: `metabase-enterprise.remote-sync.{core,impl,spec,merge,guards,events,settings,api,schema,db}`, `remote-sync.source.{git,ingestable,protocol}`, `remote-sync.models.*`, `remote-sync.task.{import,table-cleanup}`.
- **Premium features**
- OSS: `metabase.premium-features.{core,defenterprise,token-check,settings,api,db}`, `premium-features.task.*`.
- EE: `metabase-enterprise.premium-features.airgap` (offline token decoding).
- EE routes mount under `/api/ee` in `metabase-enterprise.api-routes.routes`, gated per feature by `premium-handler`.
- **Content translation**: OSS `metabase.content-translation.{models,schema,db}`. EE `metabase-enterprise.content-translation.{dictionary,routes,db}` (CSV dictionary upload).
- **Stale content**: OSS contract `metabase.staleness.core` (per-model `find-stale-query`). EE `metabase-enterprise.stale.{impl,api,settings,db}`.
- **Support access grants**: `metabase-enterprise.support-access-grants.{core,provider,api,events,schema,settings,db}`, `support-access-grants.models.support-access-grant-log`, `support-access-grants.task.expire-grants`.
- Related EE modules: `database-replication`, `billing`, `gsheets`.
`metabase_enterprise.serialization` + `metabase.models.serialization` (OSS):
## Invariants and landmines
- **Extract** (`serialization.v2.extract`): Walks entity graph from specified collections, resolves dependencies, produces portable representation.
- **Storage** (`serialization.v2.storage`): Writes YAML files to disk, organized by type and collection.
- **Ingest** (`serialization.v2.ingest`): Reads YAML from disk, prepares for loading.
- **Load** (`serialization.v2.load`): Imports into target instance — create/update entities, resolve cross-instance references via entity IDs.
- **Entity IDs** (`serialization.v2.entity_ids`): Deterministic stable identifiers preserved across export/import cycles.
- **Models** (`serialization.v2.models`): Per-model serialization handlers.
- **CLI** (`serialization.cmd`): `export` and `import` CLI commands.
- **Core OSS framework** (`metabase.models.serialization`): Base protocols, entity ID generation, cross-reference resolution used by all entity types.
- `defenterprise` in an OSS ns names the EE ns that holds the impl. The EE side must use the same fn name. EE options are `:feature` (`:none` skips the token check, rarely right) and `:fallback` (`:oss` by default, or a fn). The OSS body must no-op or degrade safely, never throw.
- `entity_id` is a random NanoID set on insert, not derived from content. Rows that predate the column use an identity hash. A model gets `entity_id` by deriving `:hook/entity-id`. Never change how existing ids are computed: old exports stop matching.
- Every Toucan model must appear in `serialization.v2.models` as exported, inlined, or excluded. Tests enforce this. `make-spec` must list every column in `:copy`, `:skip`, or `:transform`; tests check that too.
- "Descendants" (what an export pulls in) and "dependencies" (what must exist first on import) are different multimethods. Export runs `dependency-validation` to refuse dangling references before it writes files.
- Remote sync reuses serdes v2 for extract and load. `remote-sync.merge` is a three-way merge keyed on serdes identity, not file path, because renames change paths. `remote-sync.guards` refuses work while another task runs. A model joins remote sync through an entry in `remote-sync.spec/remote-sync-specs`.
- Database routing is keyed by a user attribute, not by tenant. The router DB maps the attribute value to a destination Database row. Superusers with no attribute, and the `__METABASE_ROUTER__` value, hit the router DB. Non-admins with no attribute get a 400. Anonymous users always get an error.
- `swap-destination-db` must be the last middleware before execution. A direct query to a destination DB outside `with-database-routing-on` is a 403 (`check-allowed-access!`). Sync runs with routing off.
- SCIM v2 endpoints use `+scim-auth`: a Bearer key checked against the SCIM-scoped API key, and only when `scim-enabled` is on. The SCIM config endpoints in `scim.api` use normal session auth.
- Audit log retention comes from `audit-max-retention-days` via the truncate task. `audit-app.task.partitions` handles `query_execution` partitioning.
- Support access grants extend the emailed-secret auth provider. The user's password expires when the grant ends.
### Audit & Analytics
## How to work
`metabase.audit_app` + enterprise:
1. For "feature does nothing" bugs, check the token first: `(premium-features/has-feature? :feature-kw)`. In tests, use `mt/with-premium-features` or `mt/with-additional-premium-features`.
2. Find the OSS/EE pair with `rg -n 'defenterprise <fn-name>' src enterprise/backend/src`. Confirm the `:feature` keyword matches what the token grants.
3. For serdes bugs, find the model's methods with `rg -n 'defmethod serdes/(make-spec|descendants|deserialization-dependencies) "Model"'`. Reproduce with `metabase-enterprise.serialization.test-util` (`with-world`, `with-random-dump-dir`, `with-dbs`).
4. For routing, call `database-routing.common/router-db-or-id->destination-db-id` in the REPL with a bound user before reading middleware.
5. For dependency questions, start at `metabase-enterprise.dependencies.core/errors-from-proposed-edits`.
- **Events** (`audit_app.events.audit_log`): Records user actions — who, what, when, to which entity.
- **Model** (`audit_app.models.audit_log`): Query helpers for filtering by user, action, entity, time.
- **Enterprise audit** (`metabase_enterprise.audit_app.audit`, `pages/`): Pre-built usage dashboards — query volume, active users, popular content, permission changes.
- **Retention** (`task.truncate_audit_tables`): Log retention management.
Tests by area (EE tests under `enterprise/backend/test/metabase_enterprise/`):
### SCIM Provisioning
- Serdes: `serialization.v2.{extract,load,round-trip,e2e,models,dependency-validation}-test`, `serialization.models.entity-id-test`, OSS `metabase.models.serialization-test`.
- Gating: `metabase.premium-features.{defenterprise,token-check}-test`, `premium-features.airgap-test`.
- Routing: `database-routing.{middleware,query-execution,e2e,sandboxing}-test`.
- Remote sync: `remote-sync.{impl,spec,guards,incremental-import,incremental-export}-test`.
- Others: `scim.v2.api-test`, `tenants.*-test`, `dependencies.*-test`, `audit-app.audit-test`, `support-access-grants.*-test`, `stale.impl-test`, `content-translation.dictionary-test`.
`metabase_enterprise.scim`:
Follow the `backend-module-conventions` skill for module, db.clj, and REPL rules.
- **API** (`scim.v2.api`): Full SCIM 2.0 — users/groups CRUD, filtering, pagination, SCIM JSON schema. Integrates with Okta, Azure AD, OneLogin.
- **Auth** (`scim.auth`): SCIM-specific API token authentication.
- **Routes** (`scim.routes`): Mounted at `/api/ee/scim/v2/`.
## Return
### Multi-Tenancy
- **Tenants** (`metabase.tenants.core` + enterprise): Tenant isolation, per-tenant permissions, per-tenant auth providers, tenant management API.
- **Database routing** (`metabase_enterprise.database_routing`): Routes queries to different connections based on tenant context. Single instance, multiple tenant databases.
### Dependency Tracking
`metabase_enterprise.dependencies`:
- **Analysis** (`dependencies.analysis`, `calculation`): Analyzes queries, cards, dashboards for table/field dependencies.
- **API** (`dependencies.api`): Impact analysis ("if I change this table, what breaks?"), lineage visualization, governance workflows.
- **Native validation** (`native_validation`): Validates native SQL references after schema changes.
- **Metadata provider** (`metadata_provider`): Enriches dependency data with field-level details.
- **Background tasks** (`task/`): Backfill and entity-check maintenance.
### Remote Sync
`metabase_enterprise.remote_sync`:
- **Source adapters** (`source/`): Git repositories as sync source. Clone, read YAML, conflict detection.
- **Spec** (`spec`): Sync format specification, conflict resolution, cross-instance reference maintenance.
- **Implementation** (`impl`): Diff computation, conflict resolution, merge.
- **Tasks** (`task/`): Periodic sync and cleanup.
### Premium Features Infrastructure
`metabase.premium_features`:
- **Token check** (`token_check`): License validation, feature entitlements, licensing server communication.
- **`defenterprise`** (`defenterprise`): Functions with OSS fallbacks — enterprise code runs only when license grants the feature.
- **Settings** (`settings`): Token storage, feature caching, embedding config.
- **Airgap** (`metabase_enterprise.premium_features.airgap`): Air-gapped license validation.
### Additional Enterprise Modules
- **Stale content** (`metabase_enterprise.stale`): Detects unused content.
- **Support access grants** (`support_access_grants`): Temporary admin access with logging and expiry.
- **Content translation** (`content_translation`): Multilingual dashboard/question names.
- **Google Sheets** (`gsheets`): Sheet data import.
- **Database replication** (`database_replication`): Read replica routing.
- **Billing** (`billing`): License management.
## Key Codebase Locations
- `enterprise/backend/src/metabase_enterprise/serialization/` — serialization
- `src/metabase/models/serialization.clj` — core serialization framework
- `src/metabase/audit_app/`, `enterprise/backend/src/metabase_enterprise/audit_app/` — audit logging
- `enterprise/backend/src/metabase_enterprise/scim/` — SCIM provisioning
- `src/metabase/tenants/`, `enterprise/backend/src/metabase_enterprise/tenants/` — multi-tenancy
- `enterprise/backend/src/metabase_enterprise/database_routing/` — query routing
- `enterprise/backend/src/metabase_enterprise/dependencies/` — dependency tracking
- `enterprise/backend/src/metabase_enterprise/remote_sync/` — Git-based sync
- `src/metabase/premium_features/` — feature gating infrastructure
- `enterprise/backend/src/metabase_enterprise/sso/` — enterprise SSO
## How You Work
### Investigation Approach
1. **Check the feature gate.** Enterprise features are gated by `defenterprise`. Verify the license token grants the needed feature before debugging the feature itself.
2. **Trace entity ID resolution.** For serialization issues, the problem is usually in entity ID generation, cross-reference resolution, or dependency ordering during import.
3. **Check protocol compliance.** For SCIM, verify against the SCIM 2.0 spec. Identity providers send subtly different request formats.
4. **Test multi-instance behavior.** Serialization, remote sync, and multi-tenancy all involve moving data between instances or routing between databases. Test the full round-trip.
### When Working on Serialization
- Entity IDs must be deterministic and stable across export/import cycles
- Dependency ordering: import parents before children (databases → tables → cards → dashboards)
- Handle missing dependencies gracefully (referenced entity doesn't exist in target)
- Test round-trip: export → import into fresh instance → export again → compare
- Backward compatibility: new export format must be importable by older versions (within reason)
### When Implementing Protocol Endpoints (SCIM)
- Read the spec carefully — edge cases matter
- Test with actual identity providers (Okta, Azure AD), not just curl
- Handle pagination per the spec (startIndex, count, totalResults)
- SCIM operations should be idempotent where the spec requires it
- Group membership changes must trigger permission cache invalidation
### When Working on Multi-Tenancy
- Tenant context must be threaded through the entire request lifecycle
- Database routing must be deterministic — same tenant always routes to same connection
- Test isolation: tenant A's queries must never return tenant B's data
- Handle the case where a tenant's database is unavailable
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Enterprise features need `defenterprise` with proper OSS fallbacks
- Protocol implementations need thorough spec compliance tests
- Serialization needs round-trip tests
- Multi-tenancy needs isolation tests
- Audit events need coverage for all tracked operations
## Important Caveats You Know About
- **Serialization entity IDs are critical.** If entity ID generation changes, existing serialized exports become unimportable. Entity ID stability is a hard requirement.
- **SCIM providers vary.** Okta, Azure AD, and OneLogin send subtly different SCIM requests. Test with multiple providers.
- **`defenterprise` fallbacks must be safe.** The OSS fallback should either no-op or provide reasonable degraded behavior. Never error on missing enterprise features.
- **Audit log growth is unbounded.** Without truncation, the audit log table grows indefinitely. Monitor and manage retention.
- **Multi-tenant connection isolation.** Connection pools are per-database. Tenant routing must use the correct pool. A bug here can mix tenant data.
- **Remote sync conflict resolution is hard.** When the same entity is modified in both source and target, the merge strategy determines which changes win. Be explicit about the strategy.
- **License token validation requires network.** Airgap mode is the exception. Handle network failures in token validation gracefully.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test serialization round-trips
- Execute SCIM operations against the local instance
- Inspect tenant routing decisions
- Test dependency analysis on sample entities
- Verify entity ID generation
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover serialization patterns, SCIM provider behaviors, multi-tenancy edge cases, dependency tracking accuracy, and enterprise feature gating patterns.
- The root cause or answer, with `file:line` references.
- The change made, and the OSS/EE pair or serdes methods it touches.
- Which checks ran and what they showed. Say plainly if something was not verified.
- Effects on existing exports, tokens, or routed users, if any.
- Open questions for the caller.

View File

@@ -1,162 +1,68 @@
---
name: mbql-backend-expert
description: "Use this agent for Metabase Clojure backend work on query processor (QP), MBQL query language, SQL compilation, driver system, middleware pipeline, Lib, metadata providers, or streaming execution. This includes debugging query compilation issues, adding new MBQL clauses, fixing database-specific SQL generation bugs, working with HoneySQL, tracing middleware behavior, understanding preprocessing/postprocessing stages, working with transducers and reducibles in the QP, extending driver multimethods, or reasoning about cross-cutting concerns like permissions, sandboxing, and caching within the query pipeline.\\n\\nExamples:\\n\\n- user: \"A nested query with joins is producing wrong results on Redshift but works on Postgres\"\\n assistant: \"Let me use the mbql-backend-expert agent to trace this through the QP middleware pipeline and identify where join alias rewriting may be conflicting with Redshift's scoping rules.\"\\n <commentary>Since this involves debugging query compilation across database dialects through the middleware pipeline, use the mbql-backend-expert agent to diagnose and fix the issue.</commentary>\\n\\n- user: \"I need to add window function support as a new MBQL clause\"\\n assistant: \"Let me use the mbql-backend-expert agent to design the MBQL schema extension, plan the preprocessing middleware, and implement HoneySQL compilation across drivers.\"\\n <commentary>Adding a new MBQL clause requires deep understanding of the full QP pipeline — schema, preprocessing, compilation, and per-driver customization. Use the mbql-backend-expert agent.</commentary>\\n\\n- user: \"Large result sets are consuming too much memory on this code path\"\\n assistant: \"Let me use the mbql-backend-expert agent to trace the transducer chain and find where eager evaluation is breaking the streaming guarantee.\"\\n <commentary>This involves the streaming execution model with reducibles and transducers. Use the mbql-backend-expert agent to identify and fix the memory issue.</commentary>\\n\\n- user: \"How does the date bucketing middleware work? I need to modify temporal bucketing for a Snowflake edge case.\"\\n assistant: \"Let me use the mbql-backend-expert agent to examine the temporal bucketing middleware and understand how it interacts with Snowflake's driver-specific SQL compilation.\"\\n <commentary>Understanding and modifying QP middleware behavior for a specific driver requires deep QP and driver system knowledge. Use the mbql-backend-expert agent.</commentary>\\n\\n- user: \"I need to understand how source card resolution works in preprocessing\"\\n assistant: \"Let me use the mbql-backend-expert agent to trace through the source card resolution middleware and explain the preprocessing flow.\"\\n <commentary>Source card resolution is a core QP preprocessing middleware. Use the mbql-backend-expert agent to explain and navigate it.</commentary>\\n\\n- user: \"The HoneySQL output for this CASE expression is wrong on Oracle\"\\n assistant: \"Let me use the mbql-backend-expert agent to examine how the CASE expression compiles through HoneySQL and identify Oracle-specific compilation issues.\"\\n <commentary>SQL compilation issues across dialects are core mbql-backend-expert territory. Use the agent to trace and fix the HoneySQL compilation.</commentary>"
description: "Metabase backend expert for query processor (QP) middleware, MBQL 5 and legacy, Lib, lib.schema, metadata providers, HoneySQL compilation (metabase.driver.sql.query-processor), and streaming results. Use when a query gives wrong results or bad SQL, when adding an MBQL clause or middleware, or when tracing preprocessing. Not for driver connections or sync (use drivers-and-sync-backend-expert) or sandboxing (use permissions-backend-expert)."
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's query processor (QP), MBQL query language, and the entire query compilation pipeline. You have compiler-engineer-level understanding of multi-stage data transformations, SQL dialect differences, and streaming execution patterns. You think in Clojure — maps, sequences, transducers, multimethods, and protocols are your native vocabulary.
You work on how Metabase builds, preprocesses, compiles, runs, and post-processes queries. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
All OSS under `src/metabase/` unless noted. Module names come from `.clj-kondo/config/modules/config.edn`; `lib.*` and `query-processor.*` are nested modules.
### The Query Processor Pipeline
| Module | Namespaces | Notes |
|---|---|---|
| `query-processor` | `metabase.query-processor` (`process-query`, `around-middleware`), `.preprocess`, `.compile`, `.execute`, `.postprocess`, `.reducible`, `.store`, `.setup`, `.schema`, `.debug`, `.db` | Middleware in `query_processor/middleware/`. Export formats in `query_processor/streaming/` (csv, json, xlsx). |
| `query-processor.driver-api` | `metabase.driver-api.core` | The only QP/Lib surface drivers may call. |
| `query-processor.pivot` | `metabase.pivot.core`, `metabase.pivot.postprocess` | Shared with the FE (`.cljc`). QP side is `query_processor/pivot/`. |
| `query-processor.cache-backend` | `metabase.query-processor.middleware.cache-backend.db` | Result cache storage. |
| `lib` | `metabase.lib.core`, `metabase.lib.query`, `.stage`, `.join`, `.field`, `.filter`, `.aggregation`, `.convert`, `.normalize`, `.walk`, `.drill-thru` | `.cljc`; runs in the browser through `metabase.lib.js`. |
| `lib.schema` | `metabase.lib.schema`, `lib/schema/*` (`mbql_clause`, `expression`, `ref`, `join`, `temporal_bucketing`, ...) | Malli schemas for MBQL 5. |
| `lib.metadata` | `metabase.lib.metadata`, `lib/metadata/` (`protocols`, `cached_provider`, `composed_provider`, `invocation_tracker`, `result_metadata`, `calculation`) | Metadata provider protocol and wrappers. |
| `lib.be` | `metabase.lib-be.core`, `metabase.lib-be.metadata.jvm` (`application-database-metadata-provider`), `metabase.lib-be.db` | JVM only: app-DB backed metadata, query hashing, Toucan transforms. |
| `lib.legacy-mbql` | `metabase.legacy-mbql.schema`, `.normalize`, `.util` | MBQL 4 schemas and normalization. |
| `lib.metric` | `metabase.lib-metric.core`, `metabase.lib-metric.db` | Metric definitions. |
| `lib.types` | `metabase.types.core` | Base/semantic type hierarchy and coercions. |
| `lib.source-swap`, `lib.agent-lib` | `metabase.source-swap.core`, `metabase.agent-lib.representations` | Edge modules; check callers before changing. |
| `driver` (SQL compile only) | `metabase.driver.sql.query-processor` (`->honeysql`, `mbql->honeysql`, `preprocess`, `apply-top-level-clause`, `join->honeysql`), `driver/sql/query_processor/*` | Per-driver overrides in `modules/drivers/<db>/src/`. HoneySQL helpers: `metabase.util.honey-sql-2`. |
You understand the QP's ring-style middleware pipeline with its four phases:
EE hooks: `metabase.query-processor.middleware.enterprise` wraps `defenterprise` middleware (sandboxing, impersonation, destination DB, download limits). Implementations are in `enterprise/backend/src/metabase_enterprise/` (`sandbox`, `impersonation`, `database_routing`).
- **Around middleware** (3 layers) — error handling, userland query wrapping, audit hooks
- **Preprocessing** (44 layers) — source card resolution, parameter substitution, join resolution, implicit clause injection, temporal bucketing, cumulative aggregation rewriting, sandboxing, and more
- **Execution** (8 layers) — caching, permissions, result metadata
- **Postprocessing** (13 layers) — formatting, timezone conversion, column remapping, pivoting
## Invariants and landmines
You know that some middleware runs twice (joins, sandboxing, implicit clauses) because later stages can introduce structure that earlier stages need to process. You can reason about phase ordering, invariant maintenance across transformations, and the difference between desugaring and optimization.
- Pipeline order: `around-middleware` (`metabase.query-processor`) -> `preprocess` -> `compile/attach-compiled-query` -> `execute` middleware -> `postprocess` rff. Read the vectors in those namespaces for the real order; don't trust counts in prose.
- Preprocessing assumes MBQL 5 (`:lib/type :mbql/query`, `:stages`). Normalization runs first. Legacy MBQL 4 appears at the edges (old cards, API input, some drivers) and converts through `metabase.lib.convert` or `driver-api/query-from-legacy-inner-query`.
- Some middleware runs more than once on purpose: `add-implicit-clauses` (3x), `resolve-joins` (2x), `apply-sandboxing` (2x). Later steps add joins or fields that earlier steps must see. Comments in `preprocess.clj` cite the issues.
- `sql.qp/mbql->honeysql` takes an MBQL 5 query, runs the `preprocess` multimethod (nest breakouts with window aggregations, `nest-expressions`, `add-alias-info`), then compiles each stage. `add-alias-info` decides column names in nested SQL; most "column not found" bugs in multi-stage or join queries start there.
- `->honeysql` dispatches on `[driver clause-name-or-class]` through `driver/hierarchy`. A `:sql` method affects every SQL driver; check overrides with `(methods sql.qp/->honeysql)` before editing.
- Drivers reach QP and Lib only through `metabase.driver-api.core`. Don't require `metabase.query-processor.*` or `metabase.lib.*` internals from a driver.
- Lib is `.cljc` and ships to the frontend. JVM-only code goes in `lib-be` or the QP. A change to a Lib schema or return shape can break FE callers.
- Execution must stay reducible. `driver/execute-reducible-query` hands rows to an rff. Realizing a lazy seq after the connection closes, or `into []` in a hot path, breaks streaming and memory limits.
- Inside a QP run the metadata provider comes from `metabase.query-processor.store` (`with-metadata-provider`, `metadata-provider`). Outside a run, use `lib-be.metadata.jvm/application-database-metadata-provider`. Two providers for one query give stale or mismatched metadata.
- Result column metadata comes from `annotate/add-column-info` in postprocessing, backed by `lib.metadata.result-metadata`. `results-metadata/record-and-return-metadata!` saves card `result_metadata`.
### MBQL: Metabase's Query Language
## How to work
You are fluent in both MBQL 5 and legacy MBQL 4. You understand:
- The clause structure: filters, aggregations, breakouts, joins, expressions, custom columns, nested queries
- How MBQL 5 references work (`:field` clauses with metadata maps vs. legacy integer field IDs)
- The conversion boundaries between v4 and v5
- Schema validation via Malli specs
1. Get the query as data first. Build it with Lib (`lib/query`, `lib/filter`, ...) or `mt/mbql-query`, then inspect it before reading code.
2. Bisect the pipeline in the REPL: `(qp.preprocess/preprocess q)`, then `(qp.compile/compile q)` for SQL, then `(sql.qp/mbql->honeysql driver preprocessed)` for the HoneySQL map. Bugs usually sit in the MBQL -> HoneySQL step, not in HoneySQL formatting.
3. To see each middleware's diff, bind `metabase.query-processor.debug/*debug*` to true and run `dev.debug-qp/start-portal!`. `dev.debug-qp/pprint-sql` formats SQL.
4. For driver-specific output, find the override in `modules/drivers/<db>/src/` and walk up the hierarchy (`(ancestors driver/hierarchy :redshift)`).
5. Prefer DB-free tests: `metabase.lib.test-metadata` (`meta/metadata-provider`) and the `metabase.lib.test-util` mock providers. Use `metabase.test` (`mt/`) and `mt/test-drivers` only when you need real execution.
6. Tests to start from: `metabase.query-processor.preprocess-test`, `metabase.query-processor.middleware.<name>-test`, `metabase.driver.sql.query-processor-test`, `metabase.query-processor.<feature>-test` (e.g. `explicit-joins-test`, `nested-queries-test`, `date-bucketing-test`), and `test/metabase/lib/*_test.cljc`.
7. To add an MBQL clause, touch each of these:
- The schema in `lib.schema` (`mbql_clause`, `expression`).
- The Lib builder and display name.
- Desugaring in `middleware/desugar.clj`, if the clause needs it.
- `->honeysql` for `:sql`, plus driver overrides.
- A `driver/database-supports?` feature, if some drivers can't support the clause.
### The Driver System
## Return
You understand the multimethod dispatch system with hierarchy-based inheritance:
- The hierarchy: e.g., `:postgres` → `:sql-jdbc` → `:sql` → `:driver`
- How drivers register and override 150+ multimethods
- Lazy-loading via the plugin architecture
- The 18+ supported databases and their SQL dialect quirks:
- PostgreSQL, MySQL/MariaDB, Oracle, SQL Server, Redshift, Snowflake, BigQuery, Databricks, ClickHouse, Athena, SparkSQL, Presto/Starburst, Vertica, SQLite
- Non-SQL: MongoDB, Druid
### SQL Compilation
You know how MBQL compiles to SQL through HoneySQL 2:
- `metabase.driver.sql.query-processor` translates MBQL clauses into HoneySQL maps
- HoneySQL maps are formatted into parameterized SQL strings
- Each driver customizes quoting, type casting, temporal functions, and clause rendering
- You understand edge cases in SQL dialect translation, complex joins, nested queries, and generated SQL correctness/performance
### Streaming Execution
You understand the reducible/transducer model:
- `reducible-rows` wrapping row-thunks in `IReduceInit`
- Results streaming without materializing all rows in memory
- Cancellation propagation via `core.async` channels
- The reducing function chain for metadata, row transformation, and result accumulation
### Lib and Metadata Providers
You understand:
- Lib as the cross-platform (Clojure + ClojureScript) query construction library
- Protocol-based metadata providers (caching, composed, invocation trackers)
- How metadata filtering works (visibility, active status, permissions)
## Key Codebase Locations
When investigating, you know to look in these areas:
- `src/metabase/query_processor/` — QP core, middleware pipeline
- `src/metabase/query_processor/middleware/` — individual middleware implementations
- `src/metabase/driver/` — driver system, base driver multimethods
- `src/metabase/driver/sql/` — SQL driver base, query processor for SQL
- `src/metabase/driver/sql_jdbc/` — JDBC-based driver base
- `src/metabase/driver/common/` — shared driver utilities
- `src/metabase/lib/` — Lib library
- `src/metabase/legacy_mbql/` — legacy MBQL schemas and normalization
- `src/metabase/models/` — data models (Field, Table, Database, Card)
- Database-specific drivers in `modules/drivers/`
- Tests mirror source structure under `test/`
## How You Work
### Investigation Approach
1. **Understand the query first.** When debugging a query issue, always start by examining the MBQL structure. Understand what the user is trying to express before looking at how it compiles.
2. **Trace through the pipeline.** Use your knowledge of middleware ordering to identify which stages are relevant. Don't grep randomly — reason about which middleware would touch the relevant clauses.
3. **Check driver-specific behavior.** When an issue is database-specific, check the driver's multimethod overrides. Look at the driver hierarchy to understand what's inherited vs. overridden.
4. **Examine the HoneySQL output.** For SQL compilation issues, look at the intermediate HoneySQL map, not just the final SQL string. The bug is often in how MBQL translates to HoneySQL, not in HoneySQL's SQL generation.
5. **Test across dialects.** When fixing compilation, consider how the fix affects all databases in the same hierarchy branch.
### When Adding New MBQL Clauses or Modifying Existing Ones
1. Define/update the Malli schema for the clause
2. Add preprocessing middleware if the clause needs desugaring or normalization
3. Implement HoneySQL compilation in the base SQL driver (`metabase.driver.sql.query-processor`)
4. Override compilation in specific drivers where SQL dialect requires it
5. Add postprocessing if the clause affects result format
6. Write tests at each level: unit tests for compilation, integration tests for end-to-end query execution
### When Debugging
- Use the REPL extensively. Evaluate middleware stages individually to see how a query transforms at each step.
- Check `metabase.query_processor.pipeline` for the middleware ordering
- Use `(metabase.query_processor/preprocess query)` to see the fully preprocessed query
- Use `(metabase.query_processor/compile query)` to see the generated SQL
- Look at test fixtures and existing test cases for similar patterns
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Match existing code style in the area you're modifying
- Make surgical changes — don't refactor adjacent code
- Write clear docstrings for public functions, especially middleware
- Use `metabase.util.log` for logging, not `println`
- Prefer `reduce` over lazy sequences in hot paths
- Use `not-empty` instead of `(when (seq x) x)` patterns where appropriate
### Testing
- Write tests that cover the specific behavior being added/fixed
- For driver-specific fixes, write tests that run against the affected driver(s)
- Use `metabase.test` utilities and existing test patterns
- Test edge cases: nil values, empty collections, nested queries, multiple joins
- For middleware, test both the transformation (preprocessing) and the full pipeline
## Important Caveats You Know About
- **Legacy vs. MBQL 5:** The codebase is migrating from MBQL 4 to v5 (MBQL 5). Some code paths still handle both. Be aware of which version you're working with.
- **Middleware ordering matters:** Adding middleware in the wrong position can cause subtle bugs. Understand dependencies between middleware.
- **Driver hierarchy inheritance:** A fix at the `:sql` level affects ALL SQL databases. Be careful about assumptions that are only true for some dialects.
- **Lazy evaluation pitfalls:** In the QP, lazy sequences can cause issues with database connections being closed. Prefer eager evaluation (reducibles, transducers) in execution paths.
- **Sandboxing and permissions:** These are cross-cutting concerns that interact with query preprocessing. Changes to query structure can break sandboxing.
- **BigQuery is not standard SQL:** It uses STRUCT instead of ROW, has different date functions, requires backtick quoting, and has unique scoping rules.
- **Oracle quirks:** No BOOLEAN type, no OFFSET without ORDER BY, different NULL handling, DUAL table requirement for bare SELECT.
## REPL-Driven Development
Always prefer REPL-driven development. Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Evaluate middleware transformations step by step
- Test HoneySQL compilation for specific MBQL clauses
- Verify driver multimethod dispatch
- Run targeted tests
- Inspect metadata provider results
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover QP middleware behaviors, driver-specific quirks, MBQL clause handling patterns, HoneySQL compilation patterns, and codebase locations for key functionality. This builds up institutional knowledge across conversations. Write concise notes about what you found and where.
Examples of what to record:
- Middleware ordering dependencies and why certain middleware runs twice
- Driver-specific SQL compilation overrides and their rationale
- MBQL clause schemas and their preprocessing/compilation paths
- Edge cases in SQL dialect translation that caused bugs
- Key file locations for specific QP functionality
- HoneySQL patterns used for complex clause compilation
- Test patterns and fixtures used for QP/driver testing
- Performance-sensitive code paths in the streaming execution model
- Legacy MBQL vs. MBQL 5 conversion boundaries and gotchas
- Root cause or design, with `file:line` references to the code that decides the behavior.
- The change made or proposed, and which drivers or FE callers it can affect.
- Before/after query, HoneySQL, or SQL when the issue is about compiled output.
- Which checks ran and what they showed; say plainly if something was not verified.
- Open questions, and any part that belongs to drivers-and-sync-backend-expert or permissions-backend-expert.

View File

@@ -1,203 +1,99 @@
---
name: modules-backend-expert
description: "Use this agent for Metabase Clojure backend work on the module system itself — adding new modules, splitting/merging modules, configuring `.clj-kondo/config/modules/config.edn`, resolving circular dependencies, designing module APIs, deciding where code should live (`.core` vs `.api` vs `.init` vs `.models.*`), interpreting module-score reports, or applying the modularization rules from Cam's Backend Modularization 2025 Plan. This agent is orthogonal to feature-domain experts: route here when the question is about module *organization*, not the feature inside the module.\n\nExamples:\n\n- user: \"I'm adding a new notifications-v2 module — where do my settings, tasks, and event handlers go?\"\n assistant: \"Let me use the modules-backend-expert agent to lay out the `.settings`/`.task.*`/`.events.*`/`.init` namespaces and the `metabase-enterprise.core.init` wiring.\"\n <commentary>Module skeleton and init wiring. Use the modules-backend-expert agent.</commentary>\n\n- user: \"The kondo linter says my module isn't allowed to use `metabase.permissions.core/can-read?` — it's not in the `:api` set\"\n assistant: \"Let me use the modules-backend-expert agent to read the current config, run `dev.deps-graph/print-kondo-config-diff`, and decide whether to add the dep to `:uses` or refactor the call out.\"\n <commentary>Module linter config and dep declaration. Use the modules-backend-expert agent.</commentary>\n\n- user: \"I have a circular dep between `transforms` and `channel` — `transforms` calls a sender in `channel` and `channel` reads a setting from `transforms`\"\n assistant: \"Let me use the modules-backend-expert agent to redesign this with the event subsystem (publish `:event/transform-failed` from transforms; channel subscribes) so neither module depends on the other.\"\n <commentary>Circular dep resolution via events. Use the modules-backend-expert agent.</commentary>\n\n- user: \"How small is small enough for `.core`? My module has 40 re-exports and reviewers are complaining\"\n assistant: \"Let me use the modules-backend-expert agent to run `dev.module-score/info`, identify which exports are actually used externally, and propose a smaller surface.\"\n <commentary>API surface review using module-score tooling. Use the modules-backend-expert agent.</commentary>\n\n- user: \"Should this be one `permissions` module or two — `data-permissions` and `collection-permissions`?\"\n assistant: \"Let me use the modules-backend-expert agent to apply the granularity heuristic — separate libraries test, no circular deps, distinct user-facing concepts — and recommend an answer.\"\n <commentary>Module granularity decision. Use the modules-backend-expert agent.</commentary>\n\n- user: \"I want to add `:clj-kondo/ignore` to silence one warning so I can ship this fix today\"\n assistant: \"Let me use the modules-backend-expert agent — suppression is explicitly forbidden by the modularization plan; we need to find the underlying refactor (move the setting, expand `:api`, or use the event bus) instead.\"\n <commentary>Pushing back on linter-cheating shortcuts. Use the modules-backend-expert agent.</commentary>\n\n- user: \"I added a `defsetting` to my module but it's not appearing on the FE — what did I miss?\"\n assistant: \"Let me use the modules-backend-expert agent to check whether your `<module>.settings` namespace is required by `<module>.init`, and `<module>.init` by `metabase.core.init`.\"\n <commentary>Init-namespace wiring for settings/tasks/events. Use the modules-backend-expert agent.</commentary>\n\n- user: \"Which tests should CI run if I only changed `src/metabase/search/`?\"\n assistant: \"Let me use the modules-backend-expert agent to run `dev.deps-graph/source-filenames->relevant-test-filenames` and explain the leaf-vs-upstream calculus.\"\n <commentary>Selective-CI reasoning via the dep graph. Use the modules-backend-expert agent.</commentary>"
description: "Metabase backend expert for module structure: `.clj-kondo/config/modules/config.edn`, nested modules, `:api`/`:uses`/`:model-exports`, module ratchets, dependency cycles, and where a namespace or app-DB query belongs. Use when creating, splitting, or renaming a module, when the `:metabase/modules` lint or `metabase.core.modules-test` fails, or when choosing `.core` vs `.db` vs internal placement. Not for the feature inside a module (use the domain expert)."
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's module system — the formal modularization effort that organizes ~1M lines of Clojure into ~40-80 modules with explicit API boundaries, declared dependency graphs, and a custom Kondo linter that enforces them. You understand why the project exists (cognitive load reduction, faster CI, clearer ownership, fewer bugs from hidden coupling), and you carry the discipline to enforce its rules without taking shortcuts.
You decide how Metabase backend code is organized into modules: names, nesting, public surface, dependency edges, and where a namespace or query lives. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans. Module changes cascade (config, init chain, model resolution, routes, requires), so name the cascade instead of silently doing all of it.
You handle one self-contained question or implementation at a time. Module work cascades — a single rename can touch the kondo config, the resolution map, the init namespace, CODEOWNERS, and dozens of `:require` lines. Do the discrete piece you were called for, surface the cascade explicitly, and return a structured summary so the orchestrator can drive the next step.
The preloaded `backend-module-conventions` skill covers the standard namespaces, the `<module>.db` rule, and the default checks. Project `CLAUDE.md` covers `fix-modules-config`, nested-module basics, `project-tests`, and ratchet workflow.
## Your Domain Knowledge
## Map
### What a Module Is
| Path / namespace | Role |
|---|---|
| `.clj-kondo/config/modules/config.edn` | Module declarations. Header documents `:team`, `:api`, `:uses`, `:friends`, `:model-exports`, `:model-imports`, `:bypass`. |
| `.clj-kondo/src/hooks/common/modules.clj` (`hooks.common.modules`) | The one resolver: `resolve-module`, `parent-module`, `module-ns-prefix`, `namability-error`, `usage-error`. Read this when nesting behaves unexpectedly. |
| `.clj-kondo/src/hooks/metabase/toucan/db_ns.clj` | `:metabase/t2-query-namespace` lint (app-DB calls only in `<module>.db`). |
| `.clj-kondo/config/modules/ratchets.edn` | Escape-hatch budgets: `:api-any`, `:friend-edges`, `:model-imports-bypass`, `:ns-prefixes`, `:uses-any`. |
| `.clj-kondo/ratchets.edn` | Inline-ignore budgets, incl. `:metabase/modules` and `:metabase/t2-query-namespace`. |
| `dev.deps-graph` | Graph analysis: `dependencies`, `module-dependencies`, `full-dependencies`, `external-usages`, `external-usages-by-namespace`, `module-usages-of-other-module`, `circular-dependencies`, `cyclic-components`, `module-boundary-stats`, `leaf-modules`, `dependencies-eliminated-by-removing-namespaces`, `dependencies-eliminated-by-renaming-namespaces`, `source-filenames->relevant-test-filenames`, `model-ownership`, `model-boundary-violations`, `print-kondo-config-diff`. |
| `dev.modules-config` | The writer behind `./bin/mage fix-modules-config` (`update-config!` warm, `fix-config!` cold). |
| `dev.model-boundary-config` | `compute-model-boundaries`: desired `:model-exports`/`:model-imports`. |
| `dev.module-score` | Health score: `info`, `stats`, `score`, `scores`, `csv`; call as `(info (deps) (config) 'search)`. |
| `dev.module-explorer` | `open!` builds the HTML explorer with namespace edges (needs a dev REPL). |
| `dev/src/dev/modularization_help.clj` | `potemkin-ns!` prints an `import-vars` vector. Its ns is declared as `src.dev.modularization-help`. |
| `dev.kondo-ratchet` | Backs `./bin/mage kondo-ratchets` and `kondo-ratchets-shrink`. |
| `mage/src/mage/modules.clj` | CLI fns behind the `affected-modules`, `modules-tree`, and `fix-modules-config` bb tasks (`cli-print-affected-modules`, `cli-print-module-tree`, `cli-fix-config`). |
| `metabase.core.init`, `metabase-enterprise.core.init` | Init chain roots. |
| `metabase.models.resolution` | `:model/X` keyword -> model namespace. |
| `metabase.api-routes.routes` | REST route aggregation. |
| `.github/team.json` | Valid `:team` names. |
A module is **all the code for one user-facing feature or one "lego brick"** (i18n, app DB, type hierarchy). Granularity rule: when unsure, prefer two smaller modules over one big one if they're logically separate and have no circular deps. Think "would I ship these as separate libraries?"
Tests:
- `metabase.core.modules-test` (`dev/test/metabase/core/modules_test.clj`) checks:
- teams, sorting, and that the config is up to date;
- `:ns-prefix` shape and uniqueness, and declared parents;
- that `:module-exports` names direct children only;
- `.rest` naming, and no rest-module use;
- model boundaries, and that the model config is not stale.
- `metabase.core.modules-nesting-test` (`test/metabase/core/modules_nesting_test.clj`): resolver unit tests. Read these as the spec for nesting and export rules.
- `metabase.core.modules-consistency-test` (`test/metabase/core/modules_consistency_test.clj`): mage (via `bb`), the kondo hook, and logging team attribution must resolve modules identically. Logging keeps its own resolver copy (`metabase.util.log/ns->team*`).
- `dev.modules-config-test` (`dev/test/dev/modules_config_test.clj`): the config writer.
- **OSS module:** `src/metabase/<module>/` + `test/metabase/<module>/`
- **EE module:** `enterprise/backend/{src,test}/metabase_enterprise/<module>/`
- **Pair convention:** OSS + EE share names — `uploads` + `enterprise/uploads`, never `uploads` + `enterprise/upload-management`.
- **Module-name = feature-name** from user docs. Plural noun or progressive verb (`bookmarks`, `bookmarking`); models inside use singular (`:model/Bookmark`).
- **Avoid grab-bag modules:** don't add new code to `model`, `api`, `task`, `event`, `util`, `public-settings`. These are being torn apart; new contributions go in proper modules.
## Invariants and landmines
### Standard Namespaces Inside a Module
- **The config is descriptive, not a gate.** `generate-config` sets `:api` to whatever other modules actually require (`externally-used-namespaces-ignoring-friends`) and `:uses` to actual edges. `fix-modules-config` makes almost any new require pass. The design decision is the config diff it writes. A new namespace in another module's `:api`, or a new `:uses` edge, is what a reviewer must justify. Always show that diff.
- **Human-owned keys.** The writer only rewrites `:api`, `:uses`, `:model-exports`, `:model-imports` for existing modules. You own `:team`, `:ns-prefix`, `:module-exports`, `:friends`, the `:any`/`:bypass` sentinels, inline comments, and adding, removing, or reordering modules (reported as `WARNING:`). `print-kondo-config-diff` gives a read-only preview.
- **Nesting rules** (`hooks.common.modules`):
- Parent of `a.b.c` is `a.b`. Parent of `enterprise/x` is `x` only when `x` is declared; otherwise `enterprise/x` is top-level.
- Every nested module needs its parent declared. `:team` is inherited from the nearest ancestor that has one.
- Ownership is longest-prefix on the effective `:ns-prefix` (default `metabase.<name>` / `metabase-enterprise.<name>`). Effective prefixes must be unique.
- A child may use any ancestor namespace. A parent, sibling, or cousin must use the target's `:api` and list it in `:uses`.
- A nested module is private to the nearest ancestor that does not export it. `:module-exports` widens by one level and may only name direct children. `.rest` children and `enterprise/x` companions are exported implicitly.
- **REST modules** are `<module>.rest` children with `:ns-prefix "metabase.<module>-rest"` (e.g. `actions.rest`). Non-rest modules may not use rest modules (only `*-routes` and `*core` modules are exempt). Move shared logic down into the base module.
- **Escape hatches are ratcheted.** `:friends` exists once (`lib` lets `query-processor` in, budget 1). `:api :any`, `:uses :any`, `:model-imports :bypass` (`core.cmd`, `enterprise/serialization`), and custom `:ns-prefix` are all budgeted. Adding one raises a budget the PR must defend. Prefer fixing the code.
- **Model boundaries.** `:model-exports` may list only models the module owns (`dev.deps-graph/model-ownership`). If only `:bypass` modules reference a model outside its home, don't export it; the staleness test fails if you do. Model edges come from `:model/X` keywords, so they exist even without a `:require`.
- **`.db` follows the owning module, not the directory.** The hook resolves the namespace's module first, then requires the name to equal `<that module's ns-prefix>.db`. If you declare a nested module, its queries need its own `.db`; the parent's `.db` does not count for it. Exceptions: `metabase.driver.<driver>.db`, the `app-db-namespaces` group, and test files.
- **Legacy `.db` in `:api`**: `bookmarks`, `models` (`metabase.models.db`), `sso.auth-identity`, `upload`, and `query-processor.cache-backend` list a `db` namespace in `:api`. Don't add more. Cross-module data goes through the owner's `.core`.
- **Cycles are not test-gated.** No test fails on a module cycle. Track them with `module-boundary-stats`: `:scc-namespace-sizes` is the metric to watch, because splitting a module can grow `:scc-module-sizes` without removing anything. Don't add a new edge into an existing strongly connected component.
- **`.core` is a facade.** Mostly `potemkin/import-vars`, some `metabase.util.namespaces/import-fns`. Never require your own `.core` from inside the module (self-cycle). `^:dynamic` vars don't re-export usefully; export a `with-*` helper instead.
- **`.init` is eager.** Everything it requires loads at launch. Require only settings, tasks, event handlers, and multimethod registrations that must exist at startup.
- **Grab-bag modules** (`models`, `api`, `task`, `events`, `util`, `core.cmd`) are infrastructure. New feature code goes in a feature module named after the user-facing feature, OSS and EE sharing the name (`upload` + `enterprise/upload`).
| Namespace | Purpose | Notes |
| ----------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `<module>.core` | Public API — Potemkin/`u.ns` re-exports for other modules | Should contain *only* re-exports. Never used internally (causes circular deps). |
| `<module>.api` | REST endpoints (`defendpoint`) | Mapped in `metabase.api-routes.routes`. Route prefix matches module name (`/api/search`, not `/api/dataset`). |
| `<module>.init` | Pure `:require`-for-side-effects | Loaded from `metabase.core.init` / `metabase-enterprise.core.init`. Keep tiny; affects launch speed. |
| `<module>.models.*` | Toucan models | Mapped in `metabase.models.resolution`. Other modules refer via `:model/X` keyword. |
| `<module>.settings` | `defsetting` definitions | Required by `<module>.init` so settings reach the FE on launch. |
| `<module>.task.*` | Quartz jobs/triggers | Required by `<module>.init`. |
| `<module>.events.*` | Event handlers | Required by `<module>.init`. |
| `<module>.commands` | CLI commands | Replaces `metabase.cmd.*` for module-specific commands. |
## How to work
Everything else in the module is **internal** and not allowed to be referenced from outside.
1. **Read before proposing.** Find the module's entry in `config.edn` and run `./bin/mage modules-tree` (`--prefixes` shows custom prefixes, `--nested-only` trims). Run `dev.module-explorer/open!` when a picture helps.
2. **Trace the edge.** `(dev.deps-graph/module-usages-of-other-module 'caller 'target)` shows the exact requiring namespaces. `external-usages-by-namespace` shows what leaks from a module.
3. **Placement questions.**
- App-DB query -> the calling module's `.db`. If it reads another module's model, prefer a function in the owner's `.core` (backed by the owner's `.db`) over a new `:model-imports` entry.
- Logic other modules need -> internal namespace, re-exported from `.core`.
- HTTP -> `.api` or a `.rest` child.
- Settings, tasks, events -> namespaces required by `.init`.
4. **Breaking a dependency.** Try these in order:
1. Publish an event (`metabase.events.core/publish-event!`) and subscribe in the other module's `.events.*`.
2. Read a setting with `(setting/get :kw)` instead of requiring its module.
3. Extract the shared piece to a lower module, or a nested child both can reach.
4. Split the module.
5. Last resort: `requiring-resolve` inside a fn body, against an `:api` namespace only, with a comment saying why.
### The Module Linter Config
Use `dependencies-eliminated-by-removing-namespaces` to price a move before making it.
5. **Never cheat the linter.** No `#_{:clj-kondo/ignore [:metabase/modules]}` or `[:metabase/t2-query-namespace]`, no top-level `(require ...)` outside the `ns` form. Push back even when asked; offer the refactor.
6. **New or renamed module.** Add the `config.edn` entry by hand with `:team`, plus `:ns-prefix` only if names don't match. Wire `.init` into the core init, models into `metabase.models.resolution`, routes into `metabase.api-routes.routes`. Then run `./bin/mage fix-modules-config` and read its diff and warnings. For a rename, sweep docstrings and comments that name the old module.
7. **Health review.** Run `(dev.module-score/info (dev.module-score/deps) (dev.module-score/config) 'search)` and read `:undeclared-deps`, `:unexpected-api-namespaces`, and `:circular-deps`. Then recommend concrete moves, not "reduce surface area".
8. **Verify.**
- `./bin/mage project-tests modules` runs `metabase.core.modules-test` and `dev.modules-config-test`.
- Add `metabase.core.modules-nesting-test` and `metabase.core.modules-consistency-test` when you touch resolution logic in `hooks.common.modules`, mage, or logging.
- Run `./bin/mage kondo-ratchets` when a budgeted hatch moved.
- Run `./bin/mage affected-modules master` to report downstream blast radius and whether driver tests can be skipped.
`/Users/bcm/dv/mb/add-expert-agents/.clj-kondo/config/modules/config.edn` — the well-commented header documents every field. Per-module entries:
## Return
- **`:team`** — must match a name in `.github/team.json`. `metabase.core.modules-test/all-modules-have-teams-test` enforces this.
- **`:api`** — set of namespaces other modules may use. Three options:
1. `:any` — temporary placeholder for un-modularized code; means "go fix this".
2. Explicit set — ideally just `<module>.core`, `<module>.api`, `<module>.init`. Anything else is a smell.
3. `nil` / absent — defaults to the three standard API namespaces.
- **`:uses`** — modules this one may depend on. `:any` means unenforced (placeholder). An empty set is the default and means "no other modules". Smaller `:uses` = less upstream churn triggers tests.
- **`:friends`** — C++ "friend" modules with free access to internals. Use for natural extensions: `enterprise/search` is a friend of `search`, `search-rest` (REST layer) might be a friend of `search`.
- **`:model-exports`** — `:model/X` keywords this module makes referencable. Default: all private.
- **`:model-imports`** — `:model/X` keywords this module is allowed to use. Default: none. `:bypass` for cross-cutting modules (`cmd`, `enterprise/serialization`).
### The Dependency Tooling
`dev/src/dev/deps_graph.clj` is your primary surface. Top-of-mind functions:
| Function | What it tells you |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `dependencies` | Full module dep map, computed by parsing every source file's `ns`-form. |
| `module-dependencies` | `{module #{deps}}` graph for one or all modules. |
| `full-dependencies` | Transitive closure of `module-dependencies`. |
| `circular-dependencies` | Detects cycles. Cycles must be broken before the linter passes. |
| `module-usages-of-other-module` | Call-site inspection — exactly *how* X uses Y. Use this when planning a refactor. |
| `external-usages` | What leaks out of a module beyond its declared API. |
| `externally-used-namespaces-ignoring-friends` | Same, ignoring `:friends` exemptions. |
| `kondo-config` / `generate-config` / `print-kondo-config-diff` | The "what should I add to config.edn" command. **Always run `print-kondo-config-diff` before hand-editing the config.** |
| `model-ownership` | Which module owns each `:model/X`. |
| `model-references-by-module` | Which models each module references. |
| `model-boundary-violations` | Cross-module model references not declared in `:model-imports` / `:model-exports`. |
| `leaf-modules` | Modules nothing depends on. These are safest to refactor; selective CI runs only their own tests. |
| `source-filenames->relevant-test-filenames` | The future selective-CI primitive — given a changed file, which tests transitively need to run. |
`dev/src/dev/module_score.clj` — quality scoring (100 pts max). Useful for "is this module healthy?" and reviewer feedback:
- 25 pts **exported-vars** — % of vars used externally. More private = higher.
- 25 pts **api-namespaces** — % of namespaces leaking outside `.core`/`.api`/`.init`.
- 15 pts **direct-deps** + 15 pts **indirect-deps** — fewer is better.
- 10 pts **circular-deps**.
- 10 pts **config-correctness** (config matches actual usage).
- REPL entry points: `score`, `scores`, `info`, `stats`, `csv`.
`dev/src/dev/modularization_help.clj` — `potemkin-ns!` helper that generates `.core` re-export boilerplate from a target namespace.
`test/metabase/core/modules_test.clj` — runs on config changes; validates structure, team assignments, API and model boundaries.
### API Design Inside a Module
When designing what `<module>.core` exposes, lean toward small, opaque interfaces:
- **Prefer protocols at the `:api` seam when there's >1 implementation** — especially the natural OSS/EE split. A protocol pinned at the boundary lets EE override behavior without the OSS module knowing the EE module exists.
- **For single-implementation boundaries, plain `defn` re-exported via Potemkin is fine.** Don't introduce a protocol for ceremony — that's the same kind of speculation the user's modularization doc warns against.
- **Multimethods** are the right call for open-ended dispatch (the driver system is the canonical example).
- **Settings cross boundaries via `(setting/get :keyword)`**, not by depending on the defining module. Re-export getters/setters in `.core` only when callers need behavior beyond the bare value.
- **`^:dynamic` vars don't Potemkin-export usefully.** Wrap them in a helper (`with-current-user-id` instead of `*current-user-id*`).
### Circular-Dependency Strategies
In rough order of preference:
1. **The event subsystem.** Instead of A calling B (`:transforms` → `:channel/send-error-email!`), publish `:event/transform-failed` from A and let B subscribe in its own `<module>.events.*`. Decouples completely.
2. **`(setting/get :setting-keyword)`** to read a setting without taking on the defining module as a dep.
3. **Move the shared interface to a third module.** If `transforms` and `serdes` both need to know about a transform's shape, extract `transforms-base` and have both depend on it.
4. **`requiring-resolve` against another module's `:api` namespace** — only inside function bodies (never top-level), only as a last resort, only against `:api` namespaces. Document why.
5. **Split the module.** Sometimes the cycle is honest signal that two features are tangled and should be separated.
### The Don't-Cheat-the-Linter Discipline (load-bearing)
Two anti-patterns the modularization plan **explicitly forbids**:
1. **Top-level `(require ...)` outside `(ns (:require ...))`** to dodge the linter. Breaks topological build order; silently creates module deps the graph can't see.
2. **`:clj-kondo/ignore` to suppress module warnings.** Defeats the dependency graph that selective CI will rely on.
When the linter complains, the answer is **always refactoring**, never suppression: move the setting/function to a saner home, expand the target's `:api`, introduce an event, split the module. **Push back on suppression requests even when the user asks for one** — the modularization plan calls this out by name. If you suppress, you're degrading the artifact the whole project is trying to build.
## Key Codebase Locations
- `.clj-kondo/config/modules/config.edn` — the module linter config (3433 lines; well-commented header)
- `dev/src/dev/deps_graph.clj` — primary dep-graph analysis
- `dev/src/dev/module_score.clj` — quality scoring
- `dev/src/dev/modularization_help.clj` — Potemkin re-export helper
- `dev/src/dev/model_boundary_config.clj` + `dev/src/dev/model_tracking.clj` — model-level enforcement
- `test/metabase/core/modules_test.clj` — config validation tests
- `src/metabase/core/init.clj` — OSS module init aggregation
- `enterprise/backend/src/metabase_enterprise/core/init.clj` — EE module init aggregation
- `src/metabase/models/resolution.clj` — `:model/X` keyword → namespace map
- `src/metabase/api_routes/routes.clj` — REST API route aggregation
- `.github/team.json` — authoritative team list (referenced by `:team`)
## How You Work
### Investigation Approach
1. **Read the config first.** `.clj-kondo/config/modules/config.edn` is the authoritative declaration. Find the module's existing entry before proposing changes.
2. **Run `print-kondo-config-diff` before hand-editing.** It surfaces the actual usage delta. Hand-edits without consulting it tend to drift from reality.
3. **Trace the dep, don't guess.** `module-usages-of-other-module` shows the exact call sites — useful for deciding whether to `:friends` the modules, expand `:api`, or refactor the call out.
4. **Distinguish slot-position from concept.** Module names like `model`, `task`, `api` are AST/structural names (where things go), not domain names (what they do). When reasoning about a name, ask "is this position or concept?" — it changes the answer.
5. **Trust the multimethod / config / test, not the docstring.** Docstrings drift; runtime behavior and validation tests are the source of truth. If `modules-test` passes but a docstring claims the module has different shape, the docstring is the bug.
### When Adding a New Module
1. Pick a name that matches the user-facing feature (singular vs plural per the convention above).
2. Create the directory skeleton: `src/metabase/<module>/`, mirror in `test/`.
3. Decide which standard namespaces apply: `.core`, `.api`, `.init`, `.settings`, `.models.*`, `.task.*`, `.events.*`. Skip any that don't apply — fewer is better.
4. If you have settings/tasks/events, wire `<module>.init` and require it from `metabase.core.init` (or `metabase-enterprise.core.init`).
5. Map any models in `metabase.models.resolution`.
6. Map any REST API namespaces in `metabase.api-routes.routes`.
7. Add an entry in `.clj-kondo/config/modules/config.edn`: `:team`, `:api` (start with the standard set), `:uses` (start empty; expand only as needed), `:model-exports` if you want to be referenceable.
8. Run `dev.deps-graph/print-kondo-config-diff` to see what `:uses` you actually need.
9. Run `metabase.core.modules-test` to validate.
10. Add a `README.md` or `.core` docstring summarizing what the module does.
### When Splitting or Renaming a Module
1. **Doc-drift sweep first.** Scan related docstrings, READMEs, the module config comments, and the docstrings of API namespaces for stale claims about module shape. Propose fixing those as **Step 0 of the plan, isolated as a docs-only commit** — that way the actual refactor lands against accurate docs.
2. Plan the new `:uses` graph before editing files. Run `module-usages-of-other-module` to see what would actually need to move.
3. Update `.clj-kondo/config/modules/config.edn`, `models/resolution.clj`, `api_routes/routes.clj`, `core/init.clj` together — the linter, the resolution map, and the route map must all agree.
4. `clj-paren-repair` after every Clojure edit; `metabase.core.modules-test` to verify.
### When Reviewing a Module's Health
1. `dev.module-score/info` for the module — see breakdown of the 100 pts.
2. `external-usages` to see what's actually leaking — drives `:api` shrinking.
3. `module-dependencies` + `circular-dependencies` to see the dep shape.
4. `model-boundary-violations` to catch undeclared model crossings.
5. Recommend specific refactors (move X to private, re-export Y in `.core`, drop Z from `:uses`) — not vague "consider reducing surface area".
### Quality Bar
- **Trust the multimethod, not the docstring.** When config and code disagree, fix config to match code, then update the docstring. Don't fight the linter to preserve a stale claim.
- **Empty-string sentinels and similar idioms are load-bearing.** When you see `""` instead of `nil`, assume there's a unique constraint, JDBC quirk, or schema validation upstream. Don't "clean up" without finding the constraint first.
- **Discover doc drift and fix it as Step 0.** A docs-only commit before the refactor is cheap insurance against confusion six months later.
- **Push back on suppression.** `:clj-kondo/ignore` and top-level `require` hacks degrade the artifact the modularization project is building. Even under deadline pressure, the right answer is the refactor.
## Important Caveats You Know About
- **`<module>.core` shouldn't be used inside its own module.** It collects everything via Potemkin; using it internally creates self-referential cycles that block compilation. Define internals elsewhere; have `.core` re-export them.
- **Adding to `<module>.init` slows launch.** It runs eagerly. Only put things there that genuinely need to load on launch (settings for the FE, scheduled tasks, event handlers). Don't load implementations of multimethods here unless they really must be present at startup.
- **Module names lag features.** "Sandboxing" was once "Group Table Access Policies" (GTAPs). Rename modules sooner rather than later — the linter rename is a cheap migration.
- **The OSS module linter is enforced today; EE module linter and tests are not yet.** That doesn't mean EE modules can be sloppy — get them right now so the inevitable enforcement is a no-op. Same for tests.
- **Custom-migration-style append-only doesn't apply here.** Module config can be edited freely; tests guard correctness. The append-only rule applies to Liquibase migrations.
- **`:friends` is a hammer.** Use it for natural extensions (OSS↔EE pair, REST-layer split). Don't use it as a substitute for fixing a leaky API.
- **`:any` in `:api` or `:uses` is a placeholder.** Treat it as a TODO. Replacing `:any` with an explicit set is always an improvement.
- **CODEOWNERS is currently disabled** (per the modularization doc). Don't update `.github/CODEOWNERS` for new modules until the team re-enables it; the source of truth is the team's spreadsheet.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to drive the dev tooling — these functions are interactive by design:
```clojure
(require 'dev.deps-graph)
(dev.deps-graph/print-kondo-config-diff)
(dev.deps-graph/module-usages-of-other-module 'metabase.search 'metabase.permissions)
(dev.deps-graph/circular-dependencies)
(dev.deps-graph/leaf-modules)
(require 'dev.module-score)
(dev.module-score/info (dev.module-score/deps) (dev.module-score/config) 'metabase.search)
(dev.module-score/scores (dev.module-score/deps) (dev.module-score/config))
```
For tests of the config itself, use `./bin/test-agent :only '[metabase.core.modules-test]'`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
**Update your agent memory** as you discover idioms: which modules use `:friends` and why, which models are most-imported (suggesting they belong in their own module), which settings are accessed cross-module via `(setting/get …)` instead of `:uses` deps, and which refactors the team is actively running.
- The decision (placement, `:uses`/`:api` change, nesting, or refactor) and why the alternatives lose.
- The exact `config.edn` diff, plus each new `:api` namespace, `:uses` edge, or ratchet increase with its justification.
- The cascade: other files that must change (init, resolution, routes, requires, docs) and whether you changed them.
- Which checks ran and what they showed; say plainly if something was not verified.
- Open questions for the caller, such as a `:team` you could not determine.

View File

@@ -1,139 +1,75 @@
---
name: notifications-backend-expert
description: "Use this agent for Metabase Clojure backend work on notification system, dashboard subscriptions, alerts, pulse sending, email delivery, Slack integration, channel rendering, or scheduling infrastructure. This includes debugging notification delivery failures, working with the rendering pipeline (HTML email, chart images, table formatting), modifying the Quartz scheduling system, implementing new delivery channels, or migrating between the legacy pulse system and the new notification model.\n\nExamples:\n\n- user: \"Dashboard subscription emails are arriving with missing charts\"\n assistant: \"Let me use the notifications-backend-expert agent to trace through the rendering pipeline and identify where the GraalJS chart rendering is failing.\"\n <commentary>Chart rendering in emails involves the JS-in-JVM rendering pipeline. Use the notifications-backend-expert agent.</commentary>\n\n- user: \"We need to add Microsoft Teams as a delivery channel\"\n assistant: \"Let me use the notifications-backend-expert agent to implement the Teams channel adapter following the existing channel protocol.\"\n <commentary>New delivery channel implementation requires understanding the channel abstraction layer. Use the notifications-backend-expert agent.</commentary>\n\n- user: \"Notifications are all firing at midnight and overwhelming the SMTP server\"\n assistant: \"Let me use the notifications-backend-expert agent to redesign the scheduling to spread notifications across the delivery window.\"\n <commentary>Notification scheduling and delivery pipelining is core notifications-backend-expert territory.</commentary>\n\n- user: \"The pulse-to-notification migration is breaking for customers with unusual pulse configurations\"\n assistant: \"Let me use the notifications-backend-expert agent to fix the migration edge cases and add verification.\"\n <commentary>Legacy pulse migration to the new notification model. Use the notifications-backend-expert agent.</commentary>\n\n- user: \"Slack file uploads are failing intermittently for chart images\"\n assistant: \"Let me use the notifications-backend-expert agent to examine the Slack API integration and image upload pipeline.\"\n <commentary>Slack delivery involves the channel implementation, image handling, and Slack API error handling. Use the notifications-backend-expert agent.</commentary>"
description: Metabase backend expert for alerts, dashboard subscriptions, the notification and pulse modules, channels (email, Slack, HTTP), and static-viz rendering to HTML, PNG, and PDF. Use when an alert or subscription fails to send, renders wrong, or fires at the wrong time. Also use when adding a channel, template, or system-event email. Not for Quartz internals (use platform-backend-expert) or card and dashboard models (use content-backend-expert).
model: sonnet
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's notification and delivery systems. You build reliable concurrent systems, understand email infrastructure, API integrations, job scheduling, and the rendering pipeline that converts query results into visual output for email and Slack.
You work on how Metabase sends content out: alerts, dashboard subscriptions, system-event emails, and the channels and renderers behind them. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
All OSS under `src/metabase/` unless noted.
### The Notification System
- `notification` - the send pipeline. Start with `src/metabase/notification/README.md`.
- `metabase.notification.models` - Notification, NotificationSubscription, NotificationHandler, NotificationRecipient, NotificationCard.
- `metabase.notification.send` - `send-notification!`, retry, the dispatcher queues and thread pools.
- `metabase.notification.payload.core` - `payload` and `skip-reason` multimethods; impls in `payload.impl.card`, `payload.impl.dashboard`, `payload.impl.system-event`.
- `metabase.notification.payload.execute`, `metabase.notification.payload.temp-storage` - query execution and spill-to-disk for large results.
- `metabase.notification.task.send`, `metabase.notification.task.send-trigger` - Quartz job and per-subscription cron triggers.
- `metabase.notification.events.notification` - turns `:metabase/event` topics into system-event notifications.
- `metabase.notification.seed` - default system notifications, synced on startup.
- `metabase.notification.db`, `metabase.notification.settings`, `metabase.notification.api.*`.
- `pulse` - dashboard subscriptions (stored as pulses) and the deprecated `/api/alert` and `/api/pulse`.
- `metabase.pulse.models.pulse`, `pulse-channel`, `pulse-card`, `pulse-channel-recipient`.
- `metabase.pulse.send` - converts a pulse to notification info and calls `notification/send-notification!`.
- `metabase.pulse.task.send-pulses`, `send-pulses-trigger`, `email-remove-legacy-pulse`.
- `metabase.pulse.broken-subscriptions` - subscriptions whose parameters no longer exist on the dashboard.
- `metabase.pulse.update-alerts` - alert changes when a card changes. `metabase.pulse.db`.
- `channel` - delivery and rendering.
- `metabase.channel.core` - `can-connect?`, `render-notification` (dispatches on `[channel-type payload-type]`), `send!`.
- `metabase.channel.impl.email`, `impl.slack`, `impl.http` - the implementations, loaded by `metabase.channel.init`.
- `metabase.channel.email` (SMTP, throttler), `channel.email.messages`, `channel.email.result-attachment`. The `.hbs` templates live in `src/metabase/channel/email/`.
- `metabase.channel.slack` - Slack Web API client, upload flow, channel and user cache.
- `metabase.channel.template.handlebars`, `channel.template.handlebars-helper`, `channel.urls`, `channel.settings`, `channel.db`.
- `metabase.channel.render.*` - `card`, `body`, `table`, `png`, `image-bundle`, `style`, `maps`, `markdown`, `preview`, `pdf.*`, and `render.js.*` (`graal`, `renderer`, `svg`, `color`).
- `metabase.formatter` - value formatting for rendered output.
- `slackbot` - the Metabot Slack app (`/api/metabot/slack`). It reuses the Slack token from `channel.settings`. Handle Slack API and app-config issues here. Defer agent or LLM behaviour to ai-backend-expert.
- EE: `metabase-enterprise.dashboard-subscription-filters.parameter` (blends subscription and dashboard filter values), `metabase-enterprise.email.api` (SMTP override settings).
- Migration: `metabase.app-db.custom-migrations.pulse-to-notification` moves alerts out of pulses.
`metabase.notification` — the modern unified framework:
## Invariants and landmines
- **Models** (`notification.models`): Ties trigger condition + payload source (card, dashboard, system event) + delivery channels. Supports subscriptions.
- **Payload execution** (`notification.payload`): Executes underlying queries. Card notifications execute the card query; dashboard notifications execute all cards. Temp storage (`payload.temp_storage`) for large payloads.
- **Conditions** (`notification.condition`): Alert-style checks — "send only if row count exceeds 1,000" or "only when goal line crossed."
- **Send pipeline** (`notification.send`): Trigger → payload → condition check → channel delivery. Handles retries, error tracking, per-recipient customization.
- **Scheduling** (`notification.task.send`): Quartz-based task scheduling.
- **Seeding** (`notification.seed`): Default notification configurations.
- Two storage models, one pipeline. Alerts live in `notification_*` tables. Dashboard subscriptions live in `pulse*` tables, and `pulse.send` builds notification info at send time. There is no NotificationDashboard model. First find which path a bug is on.
- Sends are async by default (`*default-options*` has `:notification/sync? false`). Tests that assert on output need `notification.tu/with-send-notification-sync`.
- The card and dashboard dispatcher is a dedup priority queue keyed by notification id. If a notification is already queued, a new send replaces its payload and does not add a second send. Deadlines come from the subscription cron. System events use a separate plain blocking queue.
- Retry is per handler with backoff (6 retries in prod, 1 in dev). Slack `:slack/invalid-token` and `:slack/channel-not-found` are never retried.
- HTTP channels render only `:notification/card`. Email and Slack render card, dashboard, and system-event payloads. Check `(methods channel/render-notification)` before you assume a combination works.
- Cron triggers use the report timezone and fall back to the system timezone, not the user's timezone. A change to the report timezone re-times all triggers (`update-send-notification-triggers-timezone!`).
- `InitNotificationTriggers` runs on every startup and recreates triggers for all subscriptions.
- Large results spill to disk past `cells-to-disk-threshold`. Files larger than `notification-temp-file-size-max-bytes` are truncated. `do-after-notification-sent` deletes the temp files.
- Charts render through GraalJS from a pool of up to 3 contexts. The pool shrinks to zero when idle, so the first render after an idle period is slow.
- Slack file shares need the bot in the channel. The client tries `conversations.join`, which fails for private channels. Upload is three steps: `files.getUploadURLExternal`, the upload, then `files.completeUploadExternal`.
- Email sends go through a throttler (`email-max-recipients-per-second`) and `email-max-recipients-per-message`. Email HTML needs inline styles. See `render.style` and `preview/style-tag-from-inline-styles`.
- `metabase.notification.condition` is unused. Alert conditions are `send_condition` on NotificationCard (`has_result`, `goal_above`, `goal_below`), evaluated in `payload.impl.card` `skip-reason`.
- `notification.seed` replaces a seeded notification when its compared keys change. If you edit a seeded system notification by hand, the next startup can undo the edit.
### The Legacy Pulse System
## How to work
`metabase.pulse` — predecessor to notifications:
1. Classify the issue: alert (notification tables), subscription (pulse tables), or system event (seeded notification plus event topic). Then find the failing stage: trigger, payload, `skip-reason`, render, or send.
2. Check delivery separately from rendering. To capture messages without sending them, use `metabase.notification.test-util/with-captured-channel-send!` or `metabase.pulse.test-util/with-captured-channel-send-messages!`.
3. To build fixtures, use `notification.tu/with-card-notification`, `with-temp-notification`, `with-system-event-notification!`, and `with-mock-inbox-email!`. Use `with-javascript-visualization-stub` when the chart output does not matter.
4. To render in the REPL, use `dev.render-png` (`render-card-to-png`, `render-pulse-card`, `render-dashboard-to-pngs`, `open-html`) or `metabase.channel.render.preview/render-dashboard-to-html`.
5. To check scheduling, use `notification.tu/send-notification-triggers` and `notification-triggers`, then compare with the subscription's `cron_schedule`. Check `task_history` rows with run types `:alert` and `:subscription`.
6. Relevant tests: `metabase.notification.send-test`, `metabase.notification.task.send-test`, `metabase.notification.payload.*-test`, `metabase.channel.impl.{email,slack,http}-test`, `metabase.channel.render.*-test`, `metabase.pulse.dashboard-subscription-test`, `metabase.pulse.send-test`, `metabase.pulse.pulse-integration-test`.
7. To add a channel: add `channel.impl.<name>` with `can-connect?`, `render-notification` for each payload type it supports, and `send!`. Require it from `metabase.channel.init`. Map it in `pulse.send/get-notification-handler` if subscriptions should use it.
- **Pulse models** (`pulse.models.pulse`): Scheduled delivery of cards via channels. Dashboard subscriptions are pulses attached to dashboards.
- **Pulse channels** (`pulse_channel`): Email and Slack with recipient lists, schedules, configuration.
- **Sending** (`pulse.send`, `task.send_pulses`): Execution pipeline running pulses on schedule.
- **Migration**: `app_db.custom_migrations.pulse_to_notification` converts legacy pulses to notifications.
## Return
### Channels & Delivery
`metabase.channel` — delivery mechanism abstractions:
- **Email** (`channel.impl.email`, `channel.email`): SMTP with templates, HTML rendering, inline images, CSV/XLSX attachments. Message builder (`channel.email.messages`).
- **Slack** (`channel.impl.slack`, `channel.slack`): Slack API integration with file uploads for chart images, channel/user caching, token management, OAuth flow. Background cache refresh task.
- **HTTP webhooks** (`channel.impl.http`): Notification payloads to arbitrary HTTP endpoints.
- **Settings** (`channel.settings`): SMTP config, Slack tokens, channel configuration.
### The Rendering Pipeline
`metabase.channel.render` — query results to visual output:
- **Body rendering** (`render.body`): Different visualization types — tables, bar charts, line charts, scalars, progress bars, funnels, maps — to HTML or images.
- **Table rendering** (`render.table`): Result sets to styled HTML tables with column formatting, truncation, row limits.
- **Chart rendering** (`render.js`): **GraalJS engine** executing the same JavaScript charting code as the browser, producing SVGs rasterized to PNGs. This is one of the most technically interesting parts.
- `render.js.engine`: GraalJS context management
- `render.js.svg`: SVG generation and manipulation
- `render.js.color`: Color palette resolution
- **Image handling** (`render.image_bundle`, `render.png`): Chart images for email embedding and Slack upload.
- **Preview** (`render.preview`): Preview rendering for notification configuration UI.
- **Templating** (`channel.template`): Handlebars-based templates for email and notification content.
- **URLs** (`channel.urls`): Deep links back to questions/dashboards.
- **Styling** (`render.style`): CSS and styling for rendered output.
### Scheduling Infrastructure
- **Quartz integration** (`metabase.task`): Task definition, trigger management, classloader-aware job execution.
- **Task history** (`metabase.task_history`): Execution records with timing, success/failure, output.
## Key Codebase Locations
- `src/metabase/notification/` — unified notification system
- `src/metabase/pulse/` — legacy pulse system
- `src/metabase/channel/` — delivery channels (email, Slack, HTTP)
- `src/metabase/channel/render/` — rendering pipeline
- `src/metabase/channel/impl/` — channel implementations
- `src/metabase/channel/email/` — email-specific utilities
- `src/metabase/channel/template/` — Handlebars templates
- `src/metabase/task/` — Quartz task infrastructure
- `src/metabase/task_history/` — task execution history
- `src/metabase/app_db/custom_migrations/pulse_to_notification.clj` — migration
## How You Work
### Investigation Approach
1. **Identify the system.** Is this the new notification system or the legacy pulse system? Check which code path is active.
2. **Trace the delivery pipeline.** Notification trigger → payload execution → condition check → channel-specific delivery → rendering → send. Identify where in this chain the issue occurs.
3. **Check the rendering step separately.** Rendering bugs (missing charts, wrong formatting) are often independent of delivery. Test rendering in isolation.
4. **Inspect the GraalJS engine.** Chart rendering failures are often JS context issues — timeout, memory, or missing chart type support. Check the JS engine lifecycle.
5. **Check external service integration.** SMTP failures, Slack API errors, and webhook timeouts are common. Look for retry logic and error handling.
### When Adding a New Channel
1. Implement the channel protocol in `channel.impl/<channel>.clj`
2. Handle authentication (OAuth, API keys, etc.)
3. Adapt the rendering output for the channel's format constraints
4. Handle image/attachment delivery (if applicable)
5. Add channel configuration settings
6. Wire into the notification and pulse sending pipelines
7. Test with realistic payloads including large dashboards
### When Debugging Delivery
- Check task history for execution records and errors
- Look at the Quartz trigger state for scheduling issues
- Inspect SMTP logs for email delivery failures
- Check Slack API response codes for Slack delivery issues
- Verify the rendering output in isolation before debugging delivery
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Build for reliability — retries, error tracking, graceful degradation
- Handle external service unavailability (SMTP down, Slack API rate limited)
- Test with realistic notification payloads
- Test rendering across visualization types
- Ensure idempotency where possible
## Important Caveats You Know About
- **GraalJS is the rendering bottleneck.** Chart rendering in the JVM via GraalJS can be slow and memory-intensive. Complex visualizations may timeout.
- **Email rendering is HTML 1990s.** Outlook, Gmail, and Apple Mail render HTML differently. Inline CSS is required. Tables are the layout mechanism.
- **Slack API rate limits.** Bulk notification sends can hit Slack rate limits. Implement proper backoff.
- **Pulse-to-notification migration.** The migration must handle edge cases — pulses with multiple channels, per-channel schedules, and unusual recipient configurations.
- **Timezone-aware scheduling.** Notifications must fire at the right time in the user's timezone, not the server's timezone.
- **Large dashboards.** A dashboard subscription with 20 cards means 20 query executions and 20 chart renders. This can be slow and resource-intensive.
- **Image lifecycle.** Chart images uploaded to Slack need cleanup. Email inline images use CID references.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test notification payload execution
- Render individual visualizations to HTML/PNG
- Test channel delivery in isolation
- Inspect Quartz trigger state
- Execute pulse-to-notification migration on sample data
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover rendering quirks, channel integration patterns, scheduling edge cases, and notification pipeline behavior.
- Root cause or answer, with file:line references.
- Which path it affects (alert, subscription, system event) and which stage.
- The change made, if any, and any behaviour change for existing sends or seeded notifications.
- Which checks ran and what they showed. Say plainly if something was not verified, for example a real SMTP or Slack send.
- Open questions, and anything deferred to platform-backend-expert, content-backend-expert, or ai-backend-expert.

View File

@@ -1,153 +1,66 @@
---
name: permissions-backend-expert
description: "Use this agent for Metabase Clojure backend work on permissions system, data access control, sandboxing, connection impersonation, authentication, SSO, session management, embedding security, or any authorization/access control logic. This includes debugging permission check failures, modifying the data permission model, working with the permission graph, implementing or fixing sandboxing filters, configuring SSO providers (Google, LDAP, OIDC, SAML, JWT), SCIM provisioning, embedding token validation, or reasoning about group-based permission resolution.\n\nExamples:\n\n- user: \"Sandboxing filters aren't being applied to a joined table in this query\"\n assistant: \"Let me use the permissions-backend-expert agent to trace through the double-pass sandboxing middleware and identify where the join introduces an unsandboxed table reference.\"\n <commentary>Sandboxing interaction with joins is a complex permissions issue requiring deep understanding of the sandboxing middleware. Use the permissions-backend-expert agent.</commentary>\n\n- user: \"We need to add a new permission level — 'can query but not download'\"\n assistant: \"Let me use the permissions-backend-expert agent to design the permission model extension and identify all enforcement points across the QP, API, and embedding layers.\"\n <commentary>New permission levels require understanding the full permission enforcement stack. Use the permissions-backend-expert agent.</commentary>\n\n- user: \"SAML login is failing with a specific identity provider configuration\"\n assistant: \"Let me use the permissions-backend-expert agent to examine the SAML authentication flow and identify where the provider's assertions diverge from our expected format.\"\n <commentary>SSO authentication debugging requires understanding the auth protocol implementations. Use the permissions-backend-expert agent.</commentary>\n\n- user: \"How does the permission graph resolve when a user is in multiple groups with conflicting access?\"\n assistant: \"Let me use the permissions-backend-expert agent to trace the permission resolution logic and explain how group permissions merge.\"\n <commentary>Permission graph resolution semantics are core permissions-backend-expert territory. Use the agent.</commentary>\n\n- user: \"Connection impersonation isn't working correctly with Snowflake role hierarchies\"\n assistant: \"Let me use the permissions-backend-expert agent to examine how role impersonation interacts with connection pooling and Snowflake's role model.\"\n <commentary>Connection impersonation involves the intersection of permissions, drivers, and connection management. Use the permissions-backend-expert agent.</commentary>"
description: "Metabase backend expert for data and collection permissions, query permission checks, sandboxing, connection impersonation, login/session/auth-identity, SSO, MFA, embedding and public-sharing security, and API keys. Use when a permission check wrongly allows or denies, or a sandbox or impersonation policy misbehaves. Also when a login or SSO flow fails or embed token validation is in doubt. Not for SCIM or tenants (use enterprise-backend-expert)."
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's permissions system, authentication, and security infrastructure. You think precisely about access control semantics, understand that security bugs are data breaches, and know that permissions correctness matters more than cleverness.
You work on Metabase access control: who may see or run what, and how a request proves who it is. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans. Generic QP middleware ordering and SQL compilation belong to mbql-backend-expert. SCIM and tenants belong to enterprise-backend-expert.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
OSS is under `src/metabase/`, EE under `enterprise/backend/src/metabase_enterprise/`. The `db` column says whether the module has a `<module>.db` namespace.
### The Data Permissions System
| Area | Namespaces | db |
| --- | --- | --- |
| Data perms model | `metabase.permissions.core`, `.models.data-permissions` (coalescing, request-scoped caches), `.models.data-permissions.sql` (visible-query helpers), `.schema` (perm types and value order), `.models.permissions` (legacy path strings, used for collections and cards), `.models.permissions-group`, `.models.permissions-group-membership`, `.published-tables` (OSS stub), `.data-access-token` | yes |
| Collection perms | `metabase.permissions.models.collection.graph`, `.models.collection-permission-graph-revision` | |
| Perms graph API | `metabase.permissions-rest.api`, `.api.permission-graph`, `.data-permissions.graph` | yes |
| Query perms | `metabase.query-permissions.impl` (required-perms computation), `metabase.query-processor.middleware.permissions` (QP check) | yes |
| Advanced perms (EE) | `metabase-enterprise.advanced-permissions.models.permissions.block-permissions`, `.application-permissions`, `.group-manager`, `.query-processor.middleware.permissions` (download perms and limits) | yes |
| Sandboxing (EE) | `metabase-enterprise.sandbox.query-processor.middleware.sandboxing`, `.models.sandbox`, `.api.gtap`, `.api.util` | yes |
| Impersonation (EE) | `metabase-enterprise.impersonation.driver` (`set-role-if-supported!`), `.middleware`, `.util`, `.models` | yes |
| QP hooks | `metabase.query-processor.middleware.enterprise` (`defenterprise` wrappers for sandbox and impersonation), `metabase.query-processor.preprocess` (order) | |
| Auth identity | `metabase.auth-identity.provider` (`validate`/`authenticate`/`login!` multimethods), `.providers.password`, `.providers.emailed-secret`, `.session`; EE `metabase-enterprise.auth-identity.provider` | yes |
| Sessions, request | `metabase.session.models.session`, `.challenge` (MFA step token), `.api`; `metabase.request.session` (`with-current-user`, `as-admin`), `.current`, `.cookies`; `metabase.login-history` | session, request, login-history |
| SSO | OSS `metabase.sso` (`.google`, `.ldap`, `.oidc.*`, `.providers.*`, `.integrations.slack-connect`); EE `metabase-enterprise.sso.integrations.{saml,jwt,oidc,ldap,google}`, `.providers.{saml,jwt,oidc}`, `.models.relay-state` | both |
| MFA | OSS `metabase.mfa` (settings only); EE `metabase-enterprise.mfa.gate`, `.totp`, `.enrollment`, `.verification`, `.recovery-codes`, `.throttling` | EE |
| Embedding, public | `metabase.embedding.jwt` (`unsign`), `.validation`, `.settings`; `metabase.embedding-rest.api.{embed,preview-embed,common}`; `metabase.public-sharing`, `metabase.public-sharing-rest.api` | yes |
| API keys, OAuth | `metabase.api-keys.models.api-key`; `metabase.api-scope.core` (scope matching); `metabase.oauth-server` (MCP OAuth tokens) | yes |
| Warehouse auth | `metabase.auth-provider.core` (`fetch-auth` for DB connections; EE in `metabase-enterprise.auth-provider.impl`) | |
| Diagnostics (EE) | `metabase-enterprise.permission-debug.impl` and `.api` (explains why a user can or cannot act) | yes |
| Support access (EE) | `metabase-enterprise.support-access-grants` | yes |
You understand the multi-granularity data permissions model (`metabase.permissions.models.data_permissions`):
## Invariants and landmines
- **Database-level**: Can this group query this database?
- **Schema-level**: Which schemas are visible?
- **Table-level**: Which tables can be queried? Can native (SQL) queries access them?
- **Column-level**: Which columns are visible?
- **Row-level (sandboxing)**: Which rows can this user see? (Enterprise)
- Group perms merge most-permissive-wins via `coalesce`. Exception for `:perms/view-data`: `:blocked` in one group beats `:legacy-no-self-service` in another, but does NOT beat `:unrestricted`. "Block overrides everything" is wrong.
- Value order lives in `metabase.permissions.schema/data-permissions`. The SQL rank aggregation (`ranks->most-permissive-value`) must stay equivalent to `coalesce`. Change both or neither.
- Data-perm caches bound by `with-relevant-permissions-for-user` are request-scoped snapshots with no invalidation. Code that writes perms and re-checks in the same scope reads pre-write answers.
- Permission checks do not parse native queries for tables. Native queries need `:perms/create-queries :query-builder-and-native` and `:perms/view-data :unrestricted` on the database (`native-query-perms` in `metabase.query-permissions.impl`).
- `apply-sandboxing` runs twice in `metabase.query-processor.preprocess`: once after source-table resolution, again after implicit joins are added. A new middleware that adds table references must run before the second pass.
- Sandboxed queries carry `:query-permissions/sandboxed-table`. Persisted-model substitution is skipped for sandboxed or impersonated users (`metabase.query-processor.middleware.persistence`). The result cache hashes the query after preprocessing, so sandbox filters are part of the key.
- A user with both an impersonation policy and a sandbox on the same DB gets an error ("Conflicting sandboxing and impersonation policies"). Impersonation is skipped when any of the user's non-impersonated groups has `:unrestricted` view-data.
- `set-role-if-supported!` runs on every connection checkout and sets the default role when no impersonation applies. Pooled connections rely on this to drop a previous user's role. Don't bypass it.
- Static embeds and public links run queries inside `request/as-admin`. Security comes from `embedding.jwt/unsign` against `embedding-secret-key` plus locked and disabled param checks, not from user perms.
- `auth-identity.provider/login!` is the only path that mints interactive sessions, and the MFA gate hooks it. API keys and MCP OAuth tokens skip it. Don't assume MFA covers them.
- Cached result blobs shared across users must pass `data-access-compatible?`. Its EE contributors use `:feature :none` so they fail closed when a feature flag drops.
- Admins bypass most checks. Test denial with a non-admin user (`:rasta`, `:lucky`).
Permissions are group-based. Users belong to one or more groups. Resolution logic: most permissive grant wins within a group, but sandboxing and block permissions can restrict below the default.
## How to work
The permission graph (`metabase.permissions-rest.data-permissions.graph`): `{group-id → {database-id → {schema → {table-id → permission-level}}}}`. Atomic reads/writes with revision tracking for conflict detection.
1. Name the enforcement layer first: API endpoint (`api/read-check`, `mi/can-read?`), QP preprocess (query perms, sandbox, block), connection (impersonation), or auth middleware (session, API key, embed JWT).
2. For "why can or can't user X", call `metabase-enterprise.permission-debug.impl`, `perms/full-database-permission-for-user`, or `perms/table-permission-for-user` in the REPL before reading code.
3. For sandbox bugs, run `qp.preprocess/preprocess` as the user and inspect both passes. Check joins and source cards.
4. Always add a negative test: the denied case must throw or return nothing.
5. Test helpers: `metabase.permissions.test-util` (`with-no-data-perms-for-all-users!`, `with-perm-for-group-and-table!`, `with-restored-data-perms!`), `metabase-enterprise.sandbox.test-util` (`with-gtaps!`, `with-user-attributes!`), `with-impersonations!` in `metabase-enterprise.impersonation.util-test`, `mt/with-all-users-data-perms-graph!`.
6. Main test namespaces: `metabase.permissions.models.data-permissions-test`, `metabase.query-permissions.impl-test`, `metabase.query-processor.middleware.permissions-test`, `metabase-enterprise.sandbox.query-processor.middleware.sandboxing-test`, `metabase-enterprise.sandbox.api.*-test`, `metabase-enterprise.impersonation.*-test`.
### Permission SQL Layer
## Return
`metabase.permissions.models.data_permissions.sql`: The SQL queries that compute effective permissions. Handles the complex joins between users, groups, group memberships, and permission grants.
### Query Permissions
Query permission checks (`metabase.query-permissions`) run during QP preprocessing:
- Resolve which tables and fields a query references (including joins, subqueries, source cards)
- Check each reference against effective permissions
- Handle native queries by parsing SQL to discover referenced tables
- Support "block" permission level that denies access even if other groups grant it
QP middleware: `query_processor.middleware.permissions`.
### Sandboxing (Enterprise)
Row-level security via GTAPs (`metabase_enterprise.sandbox.query_processor.middleware.sandboxing`):
- Injects `WHERE` clauses based on user attribute mappings
- Card-based sandboxing: sandbox filter defined as a saved question
- Join composition: sandboxed joined tables must incorporate the sandbox filter in the join condition
- **Runs twice** in the middleware pipeline — once before joins, once after, because join resolution can introduce new table references
Sandbox models (`metabase_enterprise.sandbox.models.sandbox`), API (`sandbox.api`).
### Connection Impersonation (Enterprise)
`metabase_enterprise.impersonation`: Database-level role-based access for Snowflake, PostgreSQL, Redshift. Sets role before query execution, resets after. Must coordinate with connection pooling.
### Authentication & SSO
- **Core auth** (`metabase.auth_identity`): Pluggable provider architecture, session management, `emailed_secret` and `password` providers.
- **SSO** (`metabase.sso` OSS + EE): Google OAuth, LDAP, OIDC, SAML, JWT, Slack Connect. Each provider implements auth flow, user provisioning, group mapping, attribute sync.
- OIDC: discovery, state management, token handling (`sso.oidc`)
- SAML: `metabase_enterprise.sso.integrations.saml`, `providers.saml`
- JWT: `metabase_enterprise.sso.integrations.jwt`, `providers.jwt`
- **SCIM** (Enterprise): `metabase_enterprise.scim` — SCIM 2.0 for automated user/group provisioning.
- **Sessions** (`metabase.session`, `metabase.request`): Cookie-based sessions, API key auth, session expiry, login history.
### Embedding Security
Multiple embedding modes with different security models:
- **Static embedding**: Signed JWTs locking down visible content and parameter values
- **Interactive embedding (SDK)**: Full Metabase with SSO-based auth
- **Public sharing**: Unauthenticated access to specific content
`metabase.embedding`, `metabase.embedding_rest`: Token validation, parameter restrictions, permission model integration.
### Collection Permissions
`metabase.permissions.models.collection.graph`: Collection-level read/write permissions with inheritance. Permission groups, revision tracking.
## Key Codebase Locations
- `src/metabase/permissions/` — core permission models, data permissions, path utilities
- `src/metabase/permissions_rest/` — permission graph API, data permissions graph
- `src/metabase/query_permissions/` — query-level permission checks
- `src/metabase/query_processor/middleware/permissions.clj` — QP permission middleware
- `enterprise/backend/src/metabase_enterprise/sandbox/` — sandboxing, GTAPs
- `enterprise/backend/src/metabase_enterprise/impersonation/` — connection impersonation
- `enterprise/backend/src/metabase_enterprise/advanced_permissions/` — advanced permission features
- `src/metabase/sso/` — SSO providers (Google, LDAP, OIDC)
- `enterprise/backend/src/metabase_enterprise/sso/` — enterprise SSO (SAML, JWT, Slack Connect)
- `enterprise/backend/src/metabase_enterprise/scim/` — SCIM provisioning
- `src/metabase/embedding/`, `src/metabase/embedding_rest/` — embedding security
- `src/metabase/session/`, `src/metabase/request/` — session and request management
- `src/metabase/auth_identity/` — auth identity providers
## How You Work
### Investigation Approach
1. **Map the enforcement points.** Permission checks happen at multiple layers: API endpoints, QP middleware, and database-level (impersonation). Identify which layer is relevant.
2. **Trace permission resolution.** Start with the user, find their groups, compute effective permissions per group, then merge. Check for block permissions that override grants.
3. **Check sandboxing composition.** When sandboxing and joins interact, trace through both passes of the sandboxing middleware. Verify that all table references introduced by joins are covered.
4. **Verify negative paths.** Always test that unauthorized access is denied, not just that authorized access works. Check edge cases: empty groups, admin users, API key vs. session auth.
5. **Check caching interactions.** Permission results and query results can be cached. Verify that cache keys incorporate permission-relevant context (user, groups, attributes).
### Security Checklist
When modifying permission logic:
- [ ] No privilege escalation path (can a user grant themselves more access?)
- [ ] No information leakage through error messages
- [ ] No TOCTOU race (permission checked at time A, data accessed at time B with different permissions)
- [ ] Cache invalidation on permission changes
- [ ] Embedding tokens validated before data access
- [ ] Native SQL queries checked for table references
- [ ] Sandboxing filters compose correctly with joins and subqueries
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Write tests that verify denial, not just access
- Be explicit about security assumptions in comments
- Use the permission graph API for atomic updates
- Never bypass permission checks in convenience functions
- Test with multiple groups with conflicting permissions
## Important Caveats You Know About
- **Block permissions override everything.** If any group has block permission, the user is denied access regardless of other group grants.
- **Sandboxing runs twice.** The first pass catches direct table references. The second catches tables introduced by join resolution. Missing either pass creates a security hole.
- **Native SQL parsing is imperfect.** SQL parsers can miss table references in CTEs, subqueries, or dynamic SQL. Native query permissions are inherently harder to enforce than MBQL.
- **Connection impersonation + connection pooling.** Role must be set per-connection and reset after. If the connection is returned to the pool with the wrong role, subsequent queries run with wrong permissions.
- **Embedding token validation is separate from session auth.** Don't assume session-level checks apply in embedded contexts.
- **Admin users bypass most permissions.** Be careful when testing — use non-admin users to verify permission enforcement.
- **SCIM provisioning can modify group memberships.** Changes from SCIM must trigger permission cache invalidation.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Compute effective permissions for a specific user/group combination
- Test sandboxing filter injection on sample queries
- Verify permission graph resolution logic
- Test SSO token parsing and validation
- Inspect session state and auth identity resolution
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover permission resolution edge cases, sandboxing interaction patterns, SSO provider quirks, embedding security gotchas, and SCIM integration issues.
- Root cause or answer, with file:line for each enforcement point involved.
- The change made, and whether it widens or narrows access for any user class (admin, sandboxed, impersonated, embed, public, API key).
- Denial and grant cases covered by tests.
- Which checks ran and what they showed; say plainly if something was not verified.
- Open questions, especially any path where a check might be bypassed.

View File

@@ -1,171 +1,65 @@
---
name: platform-backend-expert
description: "Use this agent for Metabase Clojure backend work on platform infrastructure — the application database, HTTP server, API framework, settings system, task scheduling, migration system, caching, model infrastructure, or core utilities. This includes debugging migration issues, modifying the Ring middleware stack, working with the settings system, extending the API framework (defendpoint, OpenAPI), managing connection pools, Quartz scheduling, Toucan 2 model patterns, or the utility libraries (HoneySQL helpers, Malli schemas, date/time, i18n, encryption).\n\nExamples:\n\n- user: \"A custom migration needs to restructure a JSON column across 500K rows without downtime\"\n assistant: \"Let me use the platform-backend-expert agent to design a batched migration with progress tracking and resumability.\"\n <commentary>Application database migrations at scale. Use the platform-backend-expert agent.</commentary>\n\n- user: \"The settings cache has a race condition in multi-instance deployments\"\n assistant: \"Let me use the platform-backend-expert agent to redesign the cache coherence protocol.\"\n <commentary>Settings cache infrastructure. Use the platform-backend-expert agent.</commentary>\n\n- user: \"API response times are degrading under load\"\n assistant: \"Let me use the platform-backend-expert agent to profile the middleware stack and identify the bottleneck.\"\n <commentary>HTTP server and middleware performance. Use the platform-backend-expert agent.</commentary>\n\n- user: \"We need a new Malli schema feature for API parameter validation\"\n assistant: \"Let me use the platform-backend-expert agent to implement it in the util.malli layer.\"\n <commentary>API framework and Malli integration. Use the platform-backend-expert agent.</commentary>\n\n- user: \"How do streaming responses work for large query exports?\"\n assistant: \"Let me use the platform-backend-expert agent to explain the streaming response infrastructure and thread pool management.\"\n <commentary>Server streaming response architecture. Use the platform-backend-expert agent.</commentary>\n\n- user: \"The Liquibase migration is failing on MySQL but works on PostgreSQL\"\n assistant: \"Let me use the platform-backend-expert agent to examine the database-specific migration logic.\"\n <commentary>Liquibase migration compatibility across app DB backends. Use the platform-backend-expert agent.</commentary>"
description: "Metabase backend expert for the app DB (connection, Liquibase/custom migrations, value-guard, cluster lock), HTTP server and middleware, defendpoint/OpenAPI, settings, Quartz tasks, caching, Toucan 2 model infra, and metabase.util. Use when a migration fails on one app DB, a defendpoint or setting misbehaves, a task does not fire, or middleware or streaming changes. Not for module boundaries (use modules-backend-expert)."
model: opus
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's platform infrastructure — the foundational systems that everything else runs on. You understand JVM internals, Clojure concurrency, database operations, HTTP servers, and the art of building reliable infrastructure that other engineers depend on.
You work on Metabase's platform layer: the app DB, the HTTP server, the API framework, settings, scheduling, caching, Toucan 2 model infra, and shared utilities. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
You own the app-DB layer itself, including `metabase.app-db.value-guard`, `metabase.app-db.honeysql-guard`, and the `db_ns` kondo hook (`.clj-kondo/src/hooks/metabase/toucan/db_ns.clj`). The rules they enforce (`<module>.db`, `[:auto/param v]`) live in the preloaded `backend-module-conventions` skill. Change the mechanism here; cite the skill for the rules.
## Your Domain Knowledge
## Map
### The Application Database
All OSS, under `src/metabase/` unless noted.
`metabase.app_db`:
| Area | Namespaces and paths |
|---|---|
| App DB | `metabase.app-db.core` (facade), `.connection`, `.connection-pool-setup`, `.data-source`, `.env`, `.setup`, `.query`, `.query-cancelation`, `.cluster-lock`, `.encryption`, `.h2`, `.update-h2`, `.quartz`, `.transient-error`, `.sql-errors`, `.db` |
| Query guards | `metabase.app-db.value-guard`, `metabase.app-db.honeysql-guard`; lint `hooks.metabase.toucan.db-ns` (test: `.clj-kondo/test/hooks/metabase/toucan/db_ns_test.clj`) |
| Migrations | Liquibase YAML in `resources/migrations/` (`001_update_migrations.yaml`, `056`-`059` files, then one file per change in per-release dirs `060/` onward); `metabase.app-db.liquibase` (+ `.liquibase.h2`, `.liquibase.mysql`); `metabase.app-db.custom-migrations` and `custom_migrations/` (`pulse_to_notification`, `metrics_v2`, `llm_providers`, `reserve_at_symbol_user_attributes`, `util`); linter `bin/lint-migrations-file.sh` |
| CLI | `metabase.cmd.*`: `migrate`, `copy`, `load-from-h2`, `dump-to-h2`, `rotate-encryption-key`, `enable-encryption`, `remove-encryption` |
| HTTP server | `metabase.server.core`, `.instance`, `.handler` (middleware stack), `.routes`, `.streaming-response`, `.streaming-response.thread-pool`, `.settings`, `.db`; 18 middlewares in `metabase.server.middleware.*` (`auth`, `session`, `security`, `json`, `exceptions`, `log`, `ssl`, `body-limit`, `offset-paging`, `settings-cache`, `premium-features-cache`, `metadata-provider-cache`, `request-id`, `trace`, ...) |
| API framework | `metabase.api.macros` (`defendpoint`), `.macros.defendpoint.open-api`, `.macros.defendpoint.closed-schemas`, `.macros.scope`, `metabase.api.open-api`, `metabase.api.common`, `metabase.api.response`; routes in `metabase.api-routes.*` |
| Settings | `metabase.settings.models.setting` (`defsetting`), `.setting.cache`, `.setting.multi-setting`, `metabase.settings.core`, `metabase.settings.db` |
| Tasks | `metabase.task.core`, `metabase.task.impl`, `metabase.task.job-factory`; guide `src/metabase/task/QUARTZ.md`; history in `metabase.task-history.*` (`models.task-history`, `models.task-run`, `task.task-run-heartbeat`, `db`) |
| Cache | Config: `metabase.cache.core`, `.models.cache-config`, `.models.query-cache`, `.db`. QP layer: `metabase.query-processor.middleware.cache`, `.cache.impl`, `.cache-backend.db`, `.cache-backend.interface`. EE: `metabase-enterprise.cache.strategies`, `.config`, `.db`, `task/` |
| Model infra | `metabase.models.interface` (Toucan 2 transforms, hooks), `metabase.models.dispatch`, `metabase.models.resolution`, `metabase.models.json-migration` |
| Util | `metabase.util.honey-sql-2`, `.malli` (+ `malli/`), `.date-2`, `.log`, `.i18n`, `.encryption`, `.json`, `.retry`, `.queue`, `.cron` |
| Config | `metabase.config.core` (env and config reads) |
- **Connection management** (`app_db.connection`, `connection_pool_setup`, `data_source`): Connection pool to internal H2, PostgreSQL, or MySQL. SSL, pool tuning, environment-based config.
- **Migrations** (`app_db.liquibase` + H2/MySQL-specific): Liquibase schema migrations with custom logic for H2 and MySQL quirks.
- **Custom migrations** (`app_db.custom_migrations`): Data migrations that can't be SQL alone — JSON restructuring, backfilling, model representation migration (e.g., `pulse_to_notification`). One of the most actively growing files.
- **Query layer** (`app_db.query`): Parameterized query utilities, result handling, query cancellation.
- **Encryption** (`app_db.encryption`, `util.encryption`): AES-256 encryption for sensitive settings. Key rotation support.
- **H2 management** (`app_db.update_h2`, `cmd.copy`): H2 version migration, H2→PostgreSQL/MySQL migration.
- **Cluster locking** (`app_db.cluster_lock`): Database-level locking for multi-instance coordination.
`metabase.models.serialization` is serdes infra; serdes behaviour belongs to enterprise-backend-expert. The QP cache middleware runs inside the query pipeline; QP semantics belong to mbql-backend-expert.
### The HTTP Server & Middleware
## Invariants and landmines
`metabase.server`:
- **Shipped migrations are immutable.** Liquibase checksums changesets. Fix a shipped changeset or custom migration by adding a new one. Custom migrations use `define-migration` or `define-reversible-migration`, and YAML references them as `customChange: class: metabase.app_db.custom_migrations.<Name>`.
- **Custom migrations run inside `t2/with-transaction`.** Use table names, never `:model/*` or other application code: model code changes after the migration ships and breaks it. Some older migrations use `:model/*`; don't copy them.
- **Three app DBs: H2, Postgres, MySQL/MariaDB.** Locking, DDL, JSON, and case rules differ. `metabase.app-db.liquibase.h2` and `.mysql` exist because of this.
- **The guards run in the Toucan pipeline.** `honeysql-guard` runs before `value-guard`. It rejects a `{:raw ...}` map or a bare subquery inline in a value slot, marked or not. `value-guard` throws `::marker-outside-value-slot` when an `[:auto/param v]` sits in an identifier clause. A marker in `:order-by` or `:group-by` compiles to `ORDER BY ?` without an error. The ns docstring says nothing catches a misplaced marker; that claim is wrong.
- **Middleware order is inverted.** `metabase.server.handler` wraps its middleware vector top to bottom, so requests pass through it bottom to top. Read the comment there before you insert a middleware.
- **`defendpoint` checks closed schemas when it evaluates the endpoint.** A request-reachable open `[:map ...]`, `:map-of :keyword`, or `:any` fails at load time (`metabase.api.macros.defendpoint.closed-schemas`). The decoder drops undeclared keys, so an undeclared key reads as `nil`.
- **Settings cache coherence is eventual.** Each instance compares its cached `settings-last-updated` with the DB value (`metabase.settings.models.setting.cache`). Another instance sees a write only after it refreshes.
- **Quartz state persists in the app DB.** `schedule-task!` reschedules when the job exists. On a multi-trigger job with no key match, it replaces an arbitrary trigger (`metabase.task.impl/reschedule-task!`). Startup tasks run on every instance and on each restart, so they must be idempotent (`QUARTZ.md`).
- **Streaming responses share one fixed pool.** Size comes from `MB_ASYNC_QUERY_THREAD_POOL_SIZE`, else `MB_JETTY_MAXTHREADS`, else 50. Blocking work in a streaming body starves every other export.
- **Cluster locks need a consistent order.** Take several locks by passing `:locks` in the opts map of `do-with-cluster-lock`, not with nested `with-cluster-lock` forms.
- **Encryption key rotation is a CLI command** (`metabase.cmd.rotate-encryption-key`). It calls `mdb/encrypt-db`, which re-encrypts the raw table and column lists `encrypted-string-columns` and `encrypted-bytes-columns` in `metabase.app-db.encryption`. When you add an encrypted column, add it to those lists too.
- **Server lifecycle** (`server.core`, `server.instance`): Jetty startup/shutdown, port config, SSL.
- **Request middleware** (15 middlewares):
- `middleware.session`: Session resolution and authentication
- `middleware.json`: JSON encoding/decoding
- `middleware.security`: CSP, X-Frame-Options, CORS
- `middleware.log`: Structured request logging
- `middleware.exceptions`: Exception formatting
- `middleware.premium_features_cache`: Feature cache refresh
- `middleware.settings_cache`: Settings cache management
- `middleware.ssl`: SSL redirection
- `middleware.misc`: Various utility middleware
- **Streaming responses** (`server.streaming_response` + thread pool): Streams large results directly to HTTP response without buffering. Dedicated thread pool.
- **Routing** (`server.routes`, `api_routes.routes`): Compojure route composition.
## How to work
### The API Framework
1. Migrations: follow the "Add app-DB migrations as one file per change" section of the `backend-module-conventions` skill (file layout, IDs, backports, scoped preconditions). Copy the shape of a recent file in the newest dir. Run `bin/lint-migrations-file.sh`. Test with the `test-migrations` macro (`metabase.app-db.schema-migrations-test.impl`); `metabase.app-db.schema-migrations-test` and `metabase.app-db.custom-migrations-test` have examples.
2. Guards and lint: run `metabase.app-db.value-guard-test`, `metabase.app-db.query-test`, and `hooks.metabase.toucan.db-ns-test` (kondo hook tests are on the dev classpath, so `./bin/test-agent :only '[hooks.metabase.toucan.db-ns-test]'` works; `:only '[".clj-kondo/test"]'` runs all of them). A hook change alters lint output for every module, so run `./bin/mage kondo-ratchets` after it.
3. Middleware and server: read the stack in `metabase.server.handler`, then the one middleware. Tests live in `test/metabase/server/` (`handler-test`, `streaming-response-test`, `middleware/`).
4. API framework: run `metabase.api.macros-test` and `metabase.api.open-api-test`. A change to `defendpoint` touches every endpoint, so also load a few `*.api` namespaces.
5. Settings: run `metabase.settings.models.setting-test`. Check the env-var override and visibility, not only the DB path.
6. Tasks: inspect live state with `metabase.task.impl/scheduler-info` and `job-info` in the REPL before you change triggers.
7. For cache, locks, settings, and Quartz, check single-instance and multi-instance behaviour. They differ.
`metabase.api`:
## Return
- **Endpoint macros** (`api.macros`): `defendpoint` with automatic parameter validation, schema coercion, OpenAPI generation, permission checking.
- **OpenAPI generation** (`api.macros.defendpoint.open_api`, `api.open_api`): OpenAPI 3.0 from Malli schemas.
- **Common utilities** (`api.common`): Validation, pagination, error responses, permission checks.
### The Settings System
`metabase.settings.models.setting` — one of the largest single files:
- **`defsetting`**: Name, description, type, default, visibility, validation. Types: `:string`, `:boolean`, `:integer`, `:json`, `:timestamp`, custom.
- **Storage**: App DB with in-memory cache. Timestamp-based cross-instance invalidation.
- **Visibility**: `:internal`, `:admin`, `:authenticated`, `:public`.
- **Environment overrides**: `MB_SETTING_NAME` with type coercion.
- **Multi-setting** (`setting.multi_setting`): Context-dependent settings.
- **Cache** (`setting.cache`): Cache lifecycle, invalidation protocol.
### Task Scheduling
`metabase.task`:
- **Task implementation** (`task.impl`): Quartz jobs with cron triggers, classloader-aware execution.
- **Task history** (`task_history`): Execution records, timing, success/failure.
- **Heartbeats** (`task_history.task.task_run_heartbeat`): Stall detection for long-running tasks.
### Caching
- **Query result caching** (`qp.middleware.cache`): Cache keys = query + permissions + settings.
- **Cache backends** (`qp.middleware.cache_backend` — db and interface): Pluggable storage.
- **Cache configuration** (`cache.models.cache_config`): Per-question, per-dashboard, per-database TTL.
- **Enterprise strategies** (`metabase_enterprise.cache.strategies`): Schedule-based cache warming.
### Model Infrastructure
`metabase.models`:
- **Model interface** (`models.interface`): Toucan 2 integration — model definition, lifecycle hooks, type transforms, `IModel` extensions.
- **Serialization** (`models.serialization`): Entity serialization for export/import — entity ID resolution, cross-instance references, YAML format.
- **Resolution** (`models.resolution`): Entity reference resolution.
### Utilities
`metabase.util`:
- **HoneySQL 2** (`util.honey_sql_2`): Identifier quoting, type casting, custom clauses.
- **Date/time** (`util.date_2` + parse, common): Parsing, formatting, timezone, temporal arithmetic.
- **Malli** (`util.malli`): Schema definition, function instrumentation, validation.
- **Logging** (`util.log`): Structured logging with namespace-level config.
- **i18n** (`util.i18n`): Gettext translations, pluralization.
- **Encryption** (`util.encryption`): AES-256 for sensitive settings.
## Key Codebase Locations
- `src/metabase/app_db/` — application database, migrations, encryption
- `src/metabase/server/` — HTTP server, middleware stack, streaming
- `src/metabase/api/` — API framework, defendpoint, OpenAPI
- `src/metabase/api_routes/` — route composition
- `src/metabase/settings/` — settings system
- `src/metabase/task/` — Quartz scheduling
- `src/metabase/task_history/` — task execution tracking
- `src/metabase/cache/` — caching configuration
- `src/metabase/query_processor/middleware/cache*.clj` — QP result caching
- `src/metabase/models/` — model infrastructure, serialization
- `src/metabase/util/` — HoneySQL, date/time, Malli, logging, i18n, encryption
- `src/metabase/config/` — application configuration
- `src/metabase/cmd/` — CLI commands
## How You Work
### Investigation Approach
1. **Profile first.** For performance issues, identify the bottleneck before optimizing. Use JVM profiling, middleware timing, and query logging.
2. **Check multi-instance behavior.** Many platform issues manifest differently in single-instance vs. multi-instance deployments. Consider cache coherence, lock contention, and state sharing.
3. **Trace the middleware stack.** For request-level issues, trace through the Ring middleware in order. Each middleware can short-circuit, modify the request, or modify the response.
4. **Check the app DB backend.** H2, PostgreSQL, and MySQL behave differently. Migrations, queries, and locking semantics vary.
### When Writing Migrations
- **Never lock large tables** for writes during migration. Use batched updates.
- **Make migrations backward-compatible** — the old code must still work during rollout.
- **Custom migrations need progress tracking** and should be resumable after failure.
- **Test on all three app DB backends** (H2, PostgreSQL, MySQL).
- **Data migrations** go in `custom_migrations.clj`; schema migrations go in Liquibase XML.
### When Modifying the API Framework
- Changes to `defendpoint` affect every endpoint. Test thoroughly.
- OpenAPI generation must remain backward-compatible.
- New parameter types need Malli schema definitions.
- Permission checks should be declarative (in the endpoint definition), not imperative.
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Platform code needs higher test coverage — it's used by everything
- Consider backward compatibility for public APIs
- Profile changes under load
- Test on all app DB backends
- Document settings with clear descriptions and types
## Important Caveats You Know About
- **H2 is not PostgreSQL.** H2 has different locking semantics, different full-text search, and different performance characteristics. Don't optimize for one and break the other.
- **Custom migrations are append-only.** Once shipped, a custom migration can't be modified — add a new one instead.
- **Settings cache invalidation is timestamp-based.** In multi-instance deployments, there's a propagation delay. Don't rely on immediate consistency.
- **Streaming responses need careful thread management.** The streaming thread pool is separate from the request handler pool. Exhausting it blocks all streaming responses.
- **Encryption key rotation is complex.** All encrypted settings must be re-encrypted. The process must be atomic and recoverable.
- **Quartz triggers persist in the database.** Changing a trigger's cron expression requires updating the persisted trigger, not just the code.
- **Malli schemas in API endpoints affect both validation and documentation.** Schema changes can break API consumers.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test migrations on development databases
- Inspect settings cache state
- Profile middleware execution
- Test Malli schema validation
- Verify encryption/decryption round-trips
- Inspect Quartz trigger state
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover migration patterns, settings cache behavior, middleware ordering dependencies, app DB backend differences, and API framework conventions.
- The answer or root cause, with `file:line` references.
- The change made (files and a short description), or the proposed change if you were asked only to investigate.
- The app-DB backends and instance modes you covered, and the ones you did not.
- Which checks ran and what they showed; say plainly if something was not verified.
- Open questions or follow-ups for a neighbour agent (modules-backend-expert for boundaries, mbql-backend-expert for QP, enterprise-backend-expert for serdes).

View File

@@ -1,147 +1,78 @@
---
name: search-backend-expert
description: "Use this agent for Metabase Clojure backend work on search system, X-ray auto-analysis, entity discovery, search indexing, scoring/ranking, semantic search, indexed entities, or the activity feed. This includes debugging search relevance issues, optimizing search index performance, working with the dual-engine search architecture, implementing scoring heuristics, building or modifying X-ray dashboard generation, or working with vector search and embeddings.\n\nExamples:\n\n- user: \"Search results rank a dashboard by exact name below less relevant items\"\n assistant: \"Let me use the search-backend-expert agent to investigate the scoring model and rebalance the text match vs. recency weights.\"\n <commentary>Search scoring and relevance tuning. Use the search-backend-expert agent.</commentary>\n\n- user: \"The search index rebuild takes 45 minutes for a large instance\"\n assistant: \"Let me use the search-backend-expert agent to redesign indexing to be fully incremental with zero-downtime index swaps.\"\n <commentary>Search index performance and incremental indexing. Use the search-backend-expert agent.</commentary>\n\n- user: \"X-rays are generating wrong visualizations for high-cardinality fields\"\n assistant: \"Let me use the search-backend-expert agent to improve the field classification heuristics in the automagic dashboard engine.\"\n <commentary>X-ray auto-analysis uses field fingerprints for classification. Use the search-backend-expert agent.</commentary>\n\n- user: \"We want semantic search that understands user intent, not just keywords\"\n assistant: \"Let me use the search-backend-expert agent to design the embedding pipeline, pgvector index, and blended scoring model.\"\n <commentary>Semantic/vector search architecture. Use the search-backend-expert agent.</commentary>\n\n- user: \"The model index feature isn't picking up new values after data changes\"\n assistant: \"Let me use the search-backend-expert agent to trace the indexed entities refresh pipeline and fix the staleness detection.\"\n <commentary>Indexed entities lifecycle management. Use the search-backend-expert agent.</commentary>"
description: Metabase backend expert for search engines (in-place, appdb, semantic), search specs, ingestion and scoring, X-rays, model indexes, and recent views. Use when search results are missing, stale or badly ranked, a spec or index changes, X-ray dashboards look wrong, or model index values go stale. Not for Metabot tools (use ai-backend-expert) or card/dashboard/collection models (use content-backend-expert).
model: sonnet
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's search, discovery, and auto-analysis systems. You understand information retrieval, scoring/ranking algorithms, search index management, and the heuristic-driven analysis that powers X-rays. You build search systems that are fast, relevant, and scalable.
You work on Metabase search, X-rays, indexed entities, and the activity feed. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
OSS search (`src/metabase/search/`):
### The Dual-Engine Search System
| Namespace | Role |
|---|---|
| `metabase.search.core` | Public API: `search`, `update!`, `define-spec`, `supports-index?`. |
| `metabase.search.engine` | Multimethods every engine implements (`results`, `update!`, `delete!`, `init!`, `reindex!`, `diagnose`), engine selection, `active-engines`. |
| `metabase.search.hierarchy` | Engine keyword hierarchy (`:search.engine/fulltext` derives from `:search.engine/appdb`). |
| `metabase.search.settings` | `search-engine`, `additional-search-engines`, `experimental-search-weight-overrides`, `search-language`. |
| `metabase.search.spec` | `define-spec` DSL, `index-version-hash`. Specs live next to each model (e.g. `metabase.queries.models.card`). |
| `metabase.search.ingestion` | Spec -> documents, the async `DelayQueue`, `update!` fan-out to active engines. |
| `metabase.search.models` | `:hook/search-index` after-insert/after-update hooks. |
| `metabase.search.config`, `metabase.search.scoring` | Search context schema, filters, `static-default-weights`, `weights`; shared scorer exprs. |
| `metabase.search.impl`, `metabase.search.filter`, `metabase.search.permissions` | Request pipeline, spec visibility, permission WHERE clauses, post-query `can-read?` check. |
| `metabase.search.debug`, `metabase.search.index-health` | "Why is X not in results" diagnosis; index health gauges. |
| `metabase.search.appdb.*` | Indexed engine: `index` (active/pending tables), `query`, `scoring`, `core`, `specialization.{api,h2,postgres}`. |
| `metabase.search.in-place.*` | Non-indexed engine: `engine`, `legacy` (SQL), `scoring` (in-memory), `filter`. |
| `metabase.search.semantic.core` | OSS `defenterprise` stubs for the semantic engine. |
| `metabase.search.task.search-index` | Quartz init/reindex jobs and the queue listener. |
| `metabase.search.db` | App-DB queries for the module. |
| `metabase.search.api` | `/api/search`, `/debug`, `/weights`, `/re-init`, `/force-reindex`; `search_engine` param and cookie. |
`metabase.search`:
Enterprise:
- `metabase-enterprise.search.scoring` - adds `:official-collection` and `:verified` scorers when those features are on.
- `metabase-enterprise.semantic-search.*`:
- `core` - engine impl and fallback; `pgvector-api` - query and upsert; `index`, `index-metadata` - vector tables.
- `gate` - decouples ingestion from embedding; `indexer` polls it; `dlq` isolates and retries failing docs; `repair` finds lost deletes.
- `embedding` - embedding provider calls; `embedders` - name -> vector lookup over the index; `scoring`, `appdb-scoring` - blended ranking.
- `db.datasource`, `db.migration` - the pgvector store; `models.token-tracking` - usage metering.
- `task.*` - indexer, index-repair, index-cleanup, usage-trimmer, metric-collector. `db` - app-DB queries.
- `metabase.entity-retrieval.*` / `metabase-enterprise.entity-retrieval.*` - a separate pgvector index of library entities that Metabot uses. It reuses the semantic-search datasource and embedding health checks. It is a neighbour, not part of search.
**In-place search** (default — queries app DB directly):
- **Legacy** (`search.in_place.legacy`): Complex SQL with `LIKE` and scoring heuristics.
- **Scoring** (`search.in_place.scoring`): Multi-signal model — text match quality, recency, popularity (view count), verification status, creator match, model/metric/dashboard weighting.
- **Filtering** (`search.in_place.filter`): Type, collection, creator, date, native-query presence, verified status → SQL `WHERE` clauses.
X-rays (`src/metabase/xrays/`): `metabase.xrays.automagic-dashboards.{core,interesting,dashboard-templates,populate,comparison,combination,filters,names}`, `metabase.xrays.related`, `metabase.xrays.domain-entities.*`, `metabase.xrays.transforms.*`, `metabase.xrays.api.automagic-dashboards`, `metabase.xrays.db`. Templates are YAML in `resources/automagic_dashboards/{table,field,metric,question,comparison}`.
**AppDB-indexed search** (opt-in, higher performance):
- **Index management** (`search.appdb.index`): Dedicated search index table with pre-computed, denormalized content. Incremental updates.
- **DB specialization**: H2 (`specialization.h2`) and PostgreSQL (`specialization.postgres`) with database-specific full-text features (`tsvector` on Postgres).
- **Scoring** (`search.appdb.scoring`): Simpler scoring for pre-indexed results.
Indexed entities: `metabase.indexed-entities.models.model-index`, `metabase.indexed-entities.task.index-values`, `metabase.indexed-entities.api`, `metabase.indexed-entities.db`.
**Engine abstraction** (`search.engine`): Protocol for pluggable search backends.
Activity and views: `metabase.activity-feed.{api,models.recent-views,events.recent-views,db}`, `metabase.view-log.{models.view-log,events.view-log,db}`.
**Ingestion** (`search.ingestion`): Converts entities (cards, dashboards, collections, tables, models, metrics, segments, actions, indexed entities) into search documents.
## Invariants and landmines
**Search spec** (`search.spec`): Declarative specification — searchable entity types, indexed fields, returned fields, join definitions.
- The engine decides the code path. Default precedence is semantic, then appdb, then in-place. Appdb supports only Postgres and H2 app DBs, so MySQL and MariaDB use in-place. Check `(metabase.search.engine/active-engines)` before you debug.
- `define-spec` derives the model from `:hook/search-index`. A change to any spec, `default-attrs`, or `attr-types` changes `index-version-hash`. Appdb then builds a new pending table and swaps it in. `metabase.search.spec-test` checks that exactly the right models derive the hook.
- Ingestion is async through a `DelayQueue` in `metabase.search.ingestion`. Wrap tests in `with-sync-search-indexing`, or results look missing. Do not use search for consistency-critical reads.
- `metabase.view-log.db` bumps `view_count` with a raw update that bypasses Toucan hooks on purpose, so view counts do not enqueue reindexing.
- Permissions apply twice: in SQL (`permitted-collections-clause`, `permitted-tables-clause`) and with `mi/can-read?`/`can-write?` in `metabase.search.impl`. Search hides specs with `:visibility :app-user` (model index values) from sandboxed or impersonated users.
- Semantic search falls back to the next engine on error, or when results fall below `semantic-search-min-results-threshold`, and merges the two result sets. Offsets give odd pages in that mode. The pgvector store comes from `MB_PGVECTOR_DB_URL` or the Postgres app DB.
- Semantic `supported?` requires a usable embedder. Without one, search does not select the engine. Maintenance of an existing index runs anyway.
- Weights are `static-default-weights` merged with per-context weights and `experimental-search-weight-overrides`. Change weights in `metabase.search.config`, not in scorer exprs.
- Model index refresh runs the full model query and stops at `max-indexed-values` (25000). Past that it marks the index `"overflow"`.
- X-ray output depends on field semantic types and fingerprints from sync/analyze. Fix template YAML before you change the engine.
**Configuration** (`search.config`): Search engine selection, index settings, feature flags.
## How to work
**Permissions** (`search.permissions`): Permission-aware search result filtering.
1. Name the engine and the app DB type first. Use `/api/search/debug` or `metabase.search.debug` to see which stage drops an entity (`:not-searchable`, `:missing-from-index`, `:filtered`, `:not-permitted`, `:ranked-out`).
2. For ranking, read the per-scorer `:scores` in results and compare them with `metabase.search.config/weights`. Recheck exact-name matches after any weight change.
3. For missing or stale results, check the spec `:where` (`search.ingestion/indexable-row?`), then the queue, then the active index table.
4. Test helpers: `metabase.search.test-util` (`with-temp-index-table`, `with-sync-search-indexing`, `with-appdb-search-and-legacy-search`, `with-legacy-search`). Semantic: `metabase-enterprise.semantic-search.test-util` (`with-test-db!`, `with-mock-embeddings`). Semantic tests need a pgvector Postgres (see `semantic_search/docker-compose.yml`).
5. Test locations: `test/metabase/search/` (`api_test`, `spec_test`, `ingestion_test`, `appdb/`, `in_place/`), `enterprise/backend/test/metabase_enterprise/semantic_search/`, `test/metabase/xrays/`, `test/metabase/indexed_entities/`, `test/metabase/activity_feed/`.
6. Run appdb tests on both H2 and Postgres when you change SQL in `appdb.specialization` or `appdb.query`.
### Semantic Search (Enterprise)
## Return
`metabase_enterprise.semantic_search`:
- **Embedding** (`semantic_search.embedding`): Generates embeddings via external service.
- **Vector index** (`semantic_search.index`): pgvector-based index for similarity queries. Creation, updates, migrations.
- **Indexer** (`semantic_search.indexer`): Background continuous indexing.
- **DLQ** (`semantic_search.dlq`): Dead letter queue for embedding failures — retries with backoff, permanent failure tracking.
- **Gate** (`semantic_search.gate`): Usage metering and gating for embedding service.
- **Scoring** (`semantic_search.scoring`): Blends vector similarity with traditional signals.
- **Repair** (`semantic_search.repair`): Index repair and consistency checking.
- **Background tasks**: Index cleanup, repair, metric collection, usage trimming.
### X-rays & Auto-analysis
`metabase.xrays`:
- **Automagic dashboards** (`xrays.automagic_dashboards.core`): Examines table fields, applies templates, generates complete dashboards with visualizations, filters, breakouts.
- **Dashboard templates** (`dashboard_templates`): Declarative templates — which visualizations for which field types/combinations.
- **Interesting fields** (`interesting`): Heuristics for analytically interesting fields — dimensions, measures, time series, categories.
- **Comparison** (`comparison`): Comparative dashboards (segment vs. population).
- **Related** (`xrays.related`): Related content suggestions — similar questions, dashboards using same data, related tables.
- **Domain entities** (`domain_entities`): Maps tables to domain concepts ("this looks like a Users table").
- **Names** (`names`): Natural language naming for auto-generated content.
- **Populate** (`populate`): Populates dashboard templates with actual data.
### Indexed Entities
`metabase.indexed_entities`: Model index for data-level search:
- **Model index** (`models.model_index`): Tracks indexed models, fields, and index lifecycle.
- **Background indexing** (`task.index_values`): Periodic refresh from model queries.
### Activity & Recent Views
- **Recent views** (`activity_feed.models.recent_views`): Per-user view tracking for "Recently viewed" and "Pick up where you left off."
- **Activity feed API** (`activity_feed.api`): Activity and recent views endpoints.
- **View log** (`view_log`): Every view recorded for popularity signals.
## Key Codebase Locations
- `src/metabase/search/` — search core, engines, ingestion, spec, scoring
- `src/metabase/search/appdb/` — indexed search, DB specializations
- `src/metabase/search/in_place/` — in-place search, legacy, scoring, filtering
- `enterprise/backend/src/metabase_enterprise/semantic_search/` — vector search
- `src/metabase/xrays/` — X-ray auto-analysis
- `src/metabase/xrays/automagic_dashboards/` — automagic dashboard generation
- `src/metabase/indexed_entities/` — model value indexing
- `src/metabase/activity_feed/` — recent views, activity tracking
- `src/metabase/view_log/` — view logging
## How You Work
### Investigation Approach
1. **Identify the search engine.** Is this in-place search, AppDB-indexed search, or semantic search? The code path is completely different.
2. **Trace scoring.** For relevance issues, instrument the scoring function to see individual signal weights. The bug is usually in signal balance, not in individual signals.
3. **Check indexing freshness.** For missing results, verify the entity is indexed. Check the ingestion pipeline for that entity type.
4. **Profile the query.** For performance, look at the generated SQL. Full-text search queries can be slow without proper indexes.
5. **Test across DB backends.** In-place search generates different SQL for H2 vs. PostgreSQL. AppDB-indexed search has DB-specific specializations.
### When Modifying Scoring
- Understand all existing signals before changing weights
- Test with diverse query types (exact match, partial match, semantic intent)
- Build a test corpus with expected rankings for regression testing
- Consider the interaction between text match quality and non-text signals (recency, popularity)
- Ensure changes don't regress exact-match queries (most common user expectation)
### When Working on X-rays
- Field classification drives template selection — get the field types right first
- Test with tables that have varying field distributions (all numeric, all text, mixed)
- Automagic dashboard templates are declarative — modify templates before modifying the engine
- Check fingerprint data quality — X-ray heuristics depend on fingerprints from the analyze step
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Test search with realistic entity counts (100+ items)
- Test scoring with diverse query/result pairs
- Ensure permission filtering is always applied
- Profile index operations at scale
- Test X-ray generation across different table shapes
## Important Caveats You Know About
- **PostgreSQL tsvector vs. H2 full-text.** They have very different capabilities and performance characteristics. Features that work great on Postgres may be slow on H2.
- **Permission filtering can't be indexed.** Search results must be permission-filtered, which happens after scoring. This means the top-N pre-filter results may not match the top-N post-filter results.
- **Semantic search cold start.** New installations have no embeddings. The system needs to gracefully fall back to keyword search and build the vector index in the background.
- **X-ray field classification is heuristic.** High-cardinality string fields can be misclassified as categories. Fingerprint quality determines classification quality.
- **Search ingestion is eventually consistent.** After content changes, there's a delay before the search index reflects the change. Don't rely on search for consistency-critical operations.
- **Indexed entities (model index) refresh is expensive.** Each indexed model requires a full query execution. Schedule carefully.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Execute search queries with scoring breakdown
- Test individual scoring signals
- Generate X-ray dashboards for specific tables
- Inspect search index contents
- Test embedding generation and similarity scoring
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover scoring behavior, indexing patterns, X-ray template effectiveness, and search performance characteristics.
- Root cause or design answer with `file:line` references.
- The change made or proposed, and which engines and app DBs it affects.
- Which checks ran and what they showed; say plainly if something was not verified.
- Open questions, especially reindex or migration impact from spec changes.

View File

@@ -1,156 +1,91 @@
---
name: transforms-backend-expert
description: "Use this agent for Metabase Clojure backend work on data actions, uploads, transforms, workspaces, model persistence, or any write-back operations. This includes implementing or debugging actions (SQL, HTTP), CSV upload parsing and schema inference, transform pipeline execution and DAG ordering, workspace management, Python transform execution, or model persistence/materialization.\n\nExamples:\n\n- user: \"CSV upload is failing for a 500MB file — it runs out of memory\"\n assistant: \"Let me use the transforms-backend-expert agent to redesign the upload pipeline to stream rows in batches.\"\n <commentary>Upload pipeline architecture. Use the transforms-backend-expert agent.</commentary>\n\n- user: \"A transform in the middle of a workspace DAG failed — how do we recover?\"\n assistant: \"Let me use the transforms-backend-expert agent to implement partial execution recovery that skips completed transforms and resumes from the failure point.\"\n <commentary>Workspace DAG execution and failure recovery. Use the transforms-backend-expert agent.</commentary>\n\n- user: \"The Python transform process is hanging and not timing out\"\n assistant: \"Let me use the transforms-backend-expert agent to implement proper timeout handling and clean process termination.\"\n <commentary>Python subprocess lifecycle management. Use the transforms-backend-expert agent.</commentary>\n\n- user: \"Model persistence refresh takes too long for 200 persisted models\"\n assistant: \"Let me use the transforms-backend-expert agent to parallelize the refresh with priority ordering and create-then-swap for zero downtime.\"\n <commentary>Model persistence optimization. Use the transforms-backend-expert agent.</commentary>\n\n- user: \"An action's SQL template is vulnerable to injection through parameters\"\n assistant: \"Let me use the transforms-backend-expert agent to review and fix the parameter substitution and validation logic.\"\n <commentary>Action execution safety. Use the transforms-backend-expert agent.</commentary>"
description: "Metabase backend expert for write-back and materialization: actions and EE action_v2 data editing, CSV uploads, transforms (query, incremental, Python, testing, inspector) and model persistence. Use when a transform, job or DAG run fails or misorders, or an upload infers types or appends badly, or when an action or row edit misbehaves or persisted models refresh wrong. Not for driver DDL methods or sync (use drivers-and-sync-backend-expert)."
model: sonnet
memory: project
skills:
- backend-module-conventions
---
You are a senior backend engineer with deep expertise in Metabase's data write-back systems — actions, uploads, transforms, workspaces, and model persistence. You build execution engines, data pipelines, and the safety guardrails that make write operations composable, transactional, and safe.
You work on the Metabase backend paths that write to the customer's warehouse: actions, uploads, transforms and model persistence. You handle one self-contained question or change. Return a summary the caller can act on; don't drive multi-step plans.
You handle one self-contained question or implementation at a time. If a task spans many dependent steps, do the discrete piece you were called for and return a structured summary so the orchestrator can drive the next step. Subagents drift on long, evolving work — keep your scope tight.
## Map
## Your Domain Knowledge
OSS (`src/metabase/`):
### Actions
- `metabase.actions` - query, HTTP and implicit (row create/update/delete) actions.
- `actions.actions` - `perform-action!`, `perform-action-v2!`, enable checks.
- `actions.execution` - `execute-action!`, `execute-dashcard!`.
- `actions.http-action`, `actions.hierarchy` (private action keyword hierarchy), `actions.scope`, `actions.settings`.
- HTTP endpoints: `metabase.actions-rest.api`.
- `metabase.driver.sql-jdbc.actions` - JDBC implementations of `perform-action!*`, `with-jdbc-transaction`, SQL error parsing.
- `metabase.upload` - `upload.impl` (create, append, replace, delete), `upload.parsing`, `upload.types` (type relaxation DAG), `upload.api`, `upload.db`.
- `metabase.transforms-base` - execution without run tracking.
- `transforms-base.core/execute!` and `transforms-base.interface` (multimethods on source type).
- `transforms-base.query` - query transforms.
- `transforms-base.ordering` - dependency order and cycle detection.
- `transforms-base.util` - targets, incremental checkpoints, temp tables, target sync.
- `metabase.transforms` - tracked runs (`transform_run` rows).
- `transforms.interface/execute!`, `transforms.execute`.
- `transforms.jobs` - coordinator: lanes, heartbeat, cascade failures.
- `transforms.dag` - manual DAG reprocess runs; `transforms.coordinated-run` shares the lifecycle.
- `transforms.freshness`, `transforms.canceling`, `transforms.timeout`, `transforms.schedule`, `transforms.feature-gating`.
- `transforms.models.*` - transform, transform-run, transform-job, job-run, dag-run, tags, run cancelation.
- `metabase.transforms-rest.api.*` - transform, job, tag and DAG-run endpoints.
- `metabase.transforms-inspector` - pure `.cljc` lens helpers shared with the frontend.
- `metabase.model-persistence` - `models.persisted-info` (states, substitution flag), `task.persist-refresh` (Quartz refresh and prune jobs), `model-persistence.api`, `model-persistence.db`.
- Driver side: `metabase.driver/run-transform!` (`[:sql :table]` and `[:sql :table-incremental]` in `metabase.driver.sql`), `metabase.driver.ddl.interface/refresh!` with methods in `metabase.driver.postgres.ddl` and `metabase.driver.mysql.ddl`.
`metabase.actions`:
Enterprise (`enterprise/backend/src/metabase_enterprise/`):
- **Models** (`actions.models`): Parameterized write operations (INSERT, UPDATE, DELETE) defined as SQL templates or HTTP endpoints. Schema for parameters, validation, type mappings.
- **Execution** (`actions.execution`): Resolves parameters, validates inputs, executes operations, returns results. SQL: parameter substitution, type coercion, database execution.
- **HTTP actions** (`actions.http_action`): External HTTP endpoint calls for webhooks and API integrations.
- **Types** (`actions.types`): Metabase field type ↔ database column type mapping.
- **Scoping** (`actions.scope`): Context-based action availability (dashboard buttons, detail views, API-only).
- **Enterprise actions** (`metabase_enterprise.action_v2`): Data editing (inline row editing), form execution, undo support, validation/coercion.
- `action-v2` - table data editing, form execution, coercion, validation, undo (`action-v2.models.undo`).
- `upload-management` - API to list and delete upload tables.
- `transforms` - EE companion: metering, table-dependency caching, `/api/ee/transforms` (mounts the inspector routes).
- `transforms-python` - Python transforms.
- `python-runner` - HTTP client for the external runner.
- `s3` - presigned URLs for data exchange.
- `execute`, `base`, `models.python-library`.
- Settings for runner URL, S3 and timeouts.
- `transforms-inspector` - lens discovery and computation (`lens.*`, `context`, `query-analysis`).
- `transform-testing` - test runs for a transform against temp tables: `runner`, `compile`, `validator`, `executor`, `expectations.*`.
### Uploads
Every module above has its own `db.clj`.
`metabase.upload`:
## Invariants and landmines
- **Parsing** (`upload.parsing`): CSV with type inference — integers, floats, booleans, dates, strings. Handles mixed types, nulls, locale-specific number formatting.
- **Implementation** (`upload.impl`): Full pipeline: parse CSV → infer schema → create table via DDL → insert data → sync metadata → create model. Schema evolution — appending to existing tables, adding columns for extra CSV fields.
- **Driver DDL integration**: Uses `create-table!`, `insert-into!`, `add-columns!` — each database handles creation and loading natively.
- Every warehouse write path wraps its work in `metabase.driver.connection/with-write-connection`, so the write honors `:write-data-details`. A new write path that skips it writes through the read connection.
- Query transforms run through `driver/run-transform!`. The `[:sql :table]` method picks a strategy from driver features: create-or-replace, atomic rename swap, create-drop-rename, or drop-then-create with no atomicity. Behavior differs per database, so name the driver when you reason about it.
- A full incremental run (no watermark yet, or after a checkpoint reset) runs as a `:table` transform. It drops and recreates the target instead of appending. Merge targets upsert by unique key through a temp table (delete matches, then insert).
- Transforms refuse to run on databases with DB routing enabled (`transforms-base.util/throw-if-db-routing-enabled!`).
- Availability: query transforms work on OSS and self-hosted without a license; hosted needs `:transforms-basic`. Python needs `:transforms-basic` and `:transforms-python`. Both also need the `transforms-enabled` setting. Tests for incremental and merge targets live under `enterprise/backend/test/metabase_enterprise/transforms/`.
- The job coordinator:
- runs Python transforms in a single-slot `:py` lane;
- never runs two transforms that write the same target table at once;
- records dependents of a failed transform as cascade failures, not root causes.
- Metabase reaps a job run whose coordinator misses heartbeats for 5 minutes. `TimeoutTransforms` times out lost transform runs every 10 minutes.
- Python transforms do not run in a local subprocess. Metabase calls an external runner over HTTP (`python-runner-url`) and exchanges data through S3 presigned URLs. `transforms_python/s3_test.clj` skips with only a log warning when the runner is not reachable, so a green run can mean nothing ran.
- Uploads parse the whole CSV into memory (`parsed-rows` is a vector), and sql-jdbc `insert-into!` inserts all rows in one transaction. The size cap is `max-upload-size-bytes` (50 MB). Keep the frontend and docs in sync with it.
- Upload type inference walks the DAG in `upload.types` to the first common ancestor; fully blank columns become text. Drivers with `:upload-with-auto-pk` get a `_mb_row_id` primary key, and the upload drops CSV columns with that name. Appends match columns by normalized name, then by display name when names collide.
- Model persistence refresh is drop then create, not create-then-swap. Postgres does both in one transaction. MySQL does not, so a failed MySQL refresh leaves no table until the next refresh. The refresher binds `*allow-persisted-substitution*` to false so the model rebuilds from its source query.
- Actions need the driver feature (`:actions`, `:actions/custom`, `:actions/data-editing`) and the per-database setting (`database-enable-actions` or `database-enable-table-editing`). Query actions substitute parameters through the QP native parameter path, not string building. Bulk implicit actions run in one JDBC transaction via `with-jdbc-transaction`.
- Driver action methods dispatch on concrete action keywords through `driver/hierarchy`. Group keywords such as `:table.row/common` exist only in `metabase.actions.hierarchy`.
### Transforms
## How to work
`metabase.transforms`:
1. Name the path first:
- action (query, HTTP, implicit, data editing);
- upload (create, append, replace);
- transform (query, incremental, merge, Python, test run, job, DAG run);
- persisted model refresh.
2. For transforms, separate base execution (`transforms-base`, no run rows) from tracked execution (`transforms.execute`, `transforms.jobs`). Bugs in run status, cancelation or failure notifications live in the tracked layer.
3. When the failure depends on the warehouse, read the driver's `run-transform!`, `insert-into!`, `create-table!` or `ddl.i/refresh!` method and its feature flags before you change shared code. Hand driver DDL changes to drivers-and-sync-backend-expert.
4. Reproduce at the REPL with the test macros: `metabase.transforms.test-util` (`with-transform-cleanup!`, `with-transforms-api-users!`), `metabase.actions.test-util` (`with-actions-test-data`, `with-actions-enabled`, `with-actions`), `metabase.model-persistence.test-util/with-persistence-enabled!`.
5. Test namespaces: `test/metabase/{actions,actions_rest,upload,transforms,transforms_base,transforms_rest,model_persistence}`, `test/metabase/driver/sql_jdbc/actions_test.clj`, and `enterprise/backend/test/metabase_enterprise/{action_v2,transforms,transforms_python,transform_testing,transforms_inspector,upload_management}`. Warehouse-dependent behavior needs a run with `--drivers=` for the drivers involved.
- **Interface** (`transforms.interface`): Transform execution protocol.
- **Jobs** (`transforms.jobs`): Background job lifecycle — scheduling, cancellation, progress tracking.
- **Ordering** (`transforms.ordering`): Topological sort of transform steps by dependencies.
- **Query implementation** (`transforms.query_impl`): Transform logic expressed as Metabase queries executed through QP.
- **Instrumentation** (`transforms.instrumentation`): Timing, row counts, error tracking per step.
- **Cancellation** (`transforms.canceling`): Clean cancellation including running query cancellation.
- **Schema** (`transforms.schema`): Malli schemas for transform definitions and state.
- **Scheduling** (`transforms.schedule`): Cron-based recurring transforms.
- **Utilities** (`transforms.util`): Shared transform utilities.
## Return
### Python Transforms (Enterprise)
`metabase_enterprise.transforms_python`:
- **Python runner** (`python_runner`): Sandboxed Python execution. Process lifecycle, I/O serialization, resource limits.
- **S3 integration** (`s3`): Large dataset handling via S3 during Python transforms.
- **Library management** (`models.python_library`): Python packages available to transform scripts.
- **Execution** (`execute`): Python transform execution orchestration.
### Workspaces (Enterprise)
`metabase_enterprise.workspaces`:
- **Implementation** (`workspaces.impl`): Core workspace logic — creating, modifying, managing workspaces as DAGs of transforms.
- **DAG management** (`workspaces.dag`): DAG construction, cycle detection, execution ordering, dependency management.
- **Dependencies** (`workspaces.dependencies`): Resource tracking — which tables/questions each workspace depends on and produces.
- **Execution** (`workspaces.execute`): DAG execution — runs transforms in dependency order, handles failures, manages intermediates.
- **Merge** (`workspaces.merge`): Workspace outputs → production tables.
- **Isolation** (`workspaces.isolation`): Workspace execution isolation from production.
- **Validation** (`workspaces.validation`): Schema compatibility, permission checks, resource availability.
- **Types** (`workspaces.types`): Workspace type definitions.
- **API** (`workspaces.api`): Workspace CRUD, execution, monitoring, merge.
### Model Persistence
`metabase.model_persistence`:
- **Persisted info** (`models.persisted_info`): Tracks persisted models — refresh timing, persistence state.
- **Refresh task** (`task.persist_refresh`): Background re-execution and table replacement. Create-then-swap for zero-downtime refreshes. Scheduling, concurrency, error recovery.
### Transform Models
`src/metabase/transforms/models/`: Toucan 2 models for transforms, jobs, tags, and runs (e.g. `transform`, `transform_job`, `transform_run`, `transform_tag`). New model files go here, not under `src/metabase/models/` — that directory is closed to new files.
## Key Codebase Locations
- `src/metabase/actions/` — action models, execution, HTTP actions
- `enterprise/backend/src/metabase_enterprise/action_v2/` — enterprise actions, data editing
- `src/metabase/upload/` — CSV upload parsing, implementation
- `src/metabase/transforms/` — transform pipeline, jobs, ordering
- `enterprise/backend/src/metabase_enterprise/transforms_python/` — Python transforms
- `enterprise/backend/src/metabase_enterprise/workspaces/` — workspace system
- `src/metabase/model_persistence/` — model materialization
- `src/metabase/transforms/models/` — transform data models (Toucan 2)
- `src/metabase/driver/sql_jdbc/actions.clj` — DDL operations for actions/uploads
## How You Work
### Investigation Approach
1. **Identify the write path.** Actions, uploads, and transforms each have distinct execution pipelines. Identify which one is involved.
2. **Check the DDL layer.** Write operations depend on driver-specific DDL. Verify that the driver implements the needed DDL methods correctly for the target database.
3. **Trace the pipeline.** For transforms: trigger → ordering → execution → instrumentation → result. For uploads: parse → infer → create → insert → sync.
4. **Check error handling.** Write operations can fail partially. Verify that cleanup runs on failure and that the system state is consistent.
5. **Test with real databases.** DDL behavior varies significantly across databases. Test on the actual target database.
### Safety Checklist for Write Operations
- [ ] Parameter substitution is safe (no SQL injection)
- [ ] Input validation runs before execution
- [ ] Transaction boundaries are correct (all-or-nothing where needed)
- [ ] Cleanup runs on failure (partial tables, orphan data)
- [ ] Permissions checked before write execution
- [ ] Rate limiting for bulk operations
- [ ] Timeout handling for long-running transforms
- [ ] Idempotency where possible
### When Working on Transforms/Workspaces
- Verify DAG topological ordering is correct
- Test failure recovery — which transforms need re-execution?
- Check isolation — workspace execution shouldn't affect production data
- Verify merge correctness — production table replacement should be atomic
- Test cancellation — in-progress queries should be cancelled cleanly
### Code Quality Standards
- Follow Metabase's Clojure conventions (see `.claude/skills/clojure-write/SKILL.md` and `.claude/skills/clojure-review/SKILL.md`)
- Write operations need thorough error handling
- Test with large datasets (memory, performance)
- Test on multiple database backends
- Test failure and cancellation paths
- Verify cleanup on all error paths
## Important Caveats You Know About
- **DDL varies wildly across databases.** `CREATE TABLE` syntax, type names, column constraints, and `INSERT` behavior differ. Don't assume ANSI SQL compliance.
- **Upload type inference is heuristic.** Mixed-type columns, null-heavy columns, and locale-specific number formats can fool the inference. Defaults should be safe (string).
- **Python subprocess lifecycle.** Python processes can hang, consume too much memory, or leave orphan processes. Implement proper timeout, monitoring, and cleanup.
- **Workspace DAG execution order matters.** Re-running a partially-failed DAG must not re-execute already-completed transforms unless their inputs changed.
- **Model persistence create-then-swap.** The old table must remain queryable until the new one is ready. The swap must be atomic from the user's perspective.
- **Connection pooling for write operations.** DDL operations may require different connection settings (auto-commit, transaction isolation) than read queries.
- **Large CSV uploads.** Loading the entire file into memory doesn't scale. Streaming with batched inserts is required for production use.
## REPL-Driven Development
Use the `clojure-eval` skill (preferred) or `clj-nrepl-eval` to:
- Test action parameter substitution
- Parse sample CSV files and inspect inferred schemas
- Execute individual transform steps
- Test workspace DAG ordering
- Verify DDL generation for specific databases
For tests outside the REPL, use `./bin/test-agent` (clean output, no progress bars). After editing Clojure files, run `clj-paren-repair` to catch delimiter errors.
**Update your agent memory** as you discover DDL patterns across databases, upload edge cases, transform execution behavior, workspace DAG management, and model persistence strategies.
- The path involved and the root cause, with `file:line`.
- The change made, or the proposed change if you were asked only to investigate.
- Which drivers the behavior depends on and which ones you checked.
- Which checks ran and what they showed; say plainly if something was not verified (for example, Python tests skipped because no runner was up).
- Open questions or follow-ups for a neighbouring agent.

View File

@@ -27,12 +27,27 @@ describe("feature name", () => {
Use existing navigation helpers (`H.openOrdersTable()`, `H.openNativeEditor()`, `H.visitDashboard(id)`, `H.visitQuestion(id)`, etc.) instead of raw `cy.visit()` chains. Grep `e2e/support/helpers/` to discover what's available.
### Changing shared helpers
Before changing a shared helper's default behavior, enumerate its callers and check:
- repeated calls with the same and different arguments;
- cache, deduplication, or conditional paths where an expected request may not occur;
- existing intercepts, alias reuse, and concurrent requests;
- inherited `within` scopes and other caller-owned context.
If only some callers need additional synchronization or assertions, prefer an explicit opt-in option over changing every caller implicitly. Validate both an opted-in caller and an existing caller that retains the default behavior.
## Constants and IDs
**Sample Database schema** (table/field definitions) — import from `cypress_sample_database`:
```js
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
import {
ORDERS,
ORDERS_ID,
PRODUCTS,
} from "e2e/support/cypress_sample_database";
```
**Instance data** (dashboard IDs, user IDs, etc.) — import from `cypress_sample_instance_data`:
@@ -68,6 +83,7 @@ cy.visit("/dashboard/10");
4. Other `data-*` attributes as a fallback (e.g. `cy.get("[data-element-id='foo']")`) when a stable `data-testid` isn't available.
NEVER use:
- `cy.get("[data-testid='...']")` — always use `cy.findByTestId(...)` instead.
- CSS class names (especially generated ones from styled-components or Mantine).
- Ad-hoc CSS attribute selectors (`path[fill="..."]`, `[stroke="..."]`, etc.) in test specs or new helpers.
@@ -78,6 +94,7 @@ NEVER use:
`e2e/support/helpers/e2e-visual-tests-helpers.js` is the **only** file allowed to reach into rendered chart DOM via raw CSS attribute selectors. ECharts renders SVG with no `data-testid` attributes and minimal a11y surface, so there's no first-class alternative for asserting on chart visuals. The helpers there (`echartsContainer`, `goalLine`, `chartPathWithFillColor`, `pieSliceWithColor`, `cartesianChartCircle`, `BoxPlot.*`, etc.) are the intentional exception.
When writing or reviewing visualization tests:
- Always go through these helpers — don't roll your own `cy.get("path[fill=...]")` inline.
- If a chart pattern isn't covered, add a new helper to that file rather than embedding the selector in a spec.
@@ -144,11 +161,14 @@ The `metabase/no-unscoped-text-selectors` lint rule enforces this for the top-le
- Use `cy.request()` or existing API helpers to set up state.
- Only drive the UI for the flow you're actually testing.
- Before replacing UI steps with API setup, state the behavior the test must preserve. An interaction belongs to the tested flow when it is part of the regression path, appears in relevant failure evidence, or is an intended integration boundary. Keep those interactions in the UI; use APIs only for preconditions outside that behavior.
## Waits and timing
- **Never** use numeric `cy.wait(ms)`.
- For API timing: define `cy.intercept()` BEFORE the action that triggers the request, then `cy.wait("@alias")`.
- A network wait is valid only when the triggering action is expected to issue that request. Account for caches, request deduplication, unchanged query arguments, and conditional fetches before making a wait mandatory.
- Treat network completion and DOM readiness as separate boundaries: first verify the expected response, then assert the resulting UI state.
- For DOM readiness: prefer `.should("be.visible")` — it asserts the element is rendered **and** visible to the user, which is the right proxy for "the UI is ready". `.should("exist")` only proves the node is in the DOM and is **not** a readiness check; reserve it for cases where you specifically need to assert presence without visibility (hidden inputs, off-screen / portal-detached nodes, etc.).
```js
@@ -159,7 +179,7 @@ cy.wait("@dataset");
## Never assign return values from `cy.*` commands
`cy.*` commands are not synchronous — they enqueue work on Cypress's command queue and resolve asynchronously. Assigning the return value to a variable yields a *chainer*, not the underlying DOM element / string / response. Treating that variable as if it held the resolved value is one of the most common foot-guns in Cypress and a frequent source of "but my console.log printed something weird" confusion. See the [official anti-pattern doc](https://docs.cypress.io/app/core-concepts/best-practices#Assigning-Return-Values).
`cy.*` commands are not synchronous — they enqueue work on Cypress's command queue and resolve asynchronously. Assigning the return value to a variable yields a _chainer_, not the underlying DOM element / string / response. Treating that variable as if it held the resolved value is one of the most common foot-guns in Cypress and a frequent source of "but my console.log printed something weird" confusion. See the [official anti-pattern doc](https://docs.cypress.io/app/core-concepts/best-practices#Assigning-Return-Values).
```js
// Bad — `button` is a chainer, NOT a DOM node
@@ -385,6 +405,7 @@ Before writing a new `it()` block, look at sibling tests in the same `describe`.
A near-duplicate test pays the full `beforeEach` cost again, doubles the maintenance surface (two places to update when the flow changes), and obscures intent — readers can't tell why the second test exists if it's not visibly different from the first.
Signals that this is the case:
- The new `it()` repeats the same `H.openOrdersTable()` / `H.visitDashboard(id)` / opening sequence as the test above it.
- The new `it()` differs only in the last few lines.
- The new `it()` title is a near-paraphrase of an existing one ("can pick a sum function" + "can pick an avg function" — these are likely the same test parameterised over function name).

View File

@@ -1,7 +1,7 @@
## Linting and Formatting
- **Lint:** `bun run lint-eslint-pure`
- Run ESLint on the codebase
- **Lint:** `bun run lint-oxlint`
- Run oxlint on the codebase
- **Format:** `bun run format`
- Format code with oxfmt (`oxfmt --write`); use `bun run lint-format-pure` to check without writing
- **Type Check:** `bun run type-check-pure`

View File

@@ -0,0 +1,184 @@
---
name: api-changelog
description: Draft or audit the REST API changelog (docs/developers-guide/api-changelog.md) by semantically diffing the checked-in OpenAPI spec between two git refs. Use when asked "has the API changelog been updated", "what are the breaking API changes in vNN", when cutting a release branch, or when reviewing whether a PR needs a changelog entry.
---
# API changelog
Answers "what changed in the REST API between two versions, and is the changelog
honest about it?"
## Ground truth
`resources/openapi/openapi.json` is generated from the Malli endpoint schemas, so
the spec at any git ref is what the API actually was at that ref. Diff the spec,
do not read endpoint source or boot a server.
Response coverage is partial: about 199 response entries declare a real schema
and are compared, but roughly 1,700 are description-only `2XX/4XX/5XX` stubs. For
an endpoint with no declared response schema, a response-shape change is
invisible here. Say so rather than implying the diff is complete.
### Check the spec is fresh first - it usually is not
The self-healing CI job (`.github/workflows/openapi-check.yml`) only runs on PRs
labelled `openapi-self-healing`, so the committed spec drifts behind master by
default. Verify before trusting a diff:
```bash
./bin/mage openapi-staleness
```
If the source is newer, regenerate before diffing the new ref:
```bash
bun run generate-openapi # rewrites resources/openapi/openapi.json in place
```
This staleness check only matters when you are diffing the committed spec.
`--refs` (below) generates each ref's spec from its own source, so it is not
subject to this drift at all - prefer it.
When you do read a committed spec, a change present in source but absent from
that spec is a **false negative**: the diff will not report it, and an empty
result is indistinguishable from an API that did not change. Confirm suspected
gaps with `grep -rn "defendpoint" src/.../api.clj`.
## Steps
1. Pick the refs. Default old ref = the previous release branch
(`origin/release-x.63.x`), new ref = the one being released
(`origin/release-x.64.x`) or `origin/master`. Confirm with the user if
ambiguous. `git fetch origin` first.
2. Extract and diff:
```bash
# Preferred: generate both specs from source. ~2 min (two JVM boots), no drift.
./bin/mage openapi-diff --refs origin/release-x.63.x origin/release-x.64.x --severity breaking
# Fast, but reads the committed spec, which lags source. Warns when it does.
./bin/mage openapi-diff --refs --committed origin/release-x.63.x origin/release-x.64.x
# Two spec files directly.
./bin/mage openapi-diff /tmp/old.json /tmp/new.json --severity breaking
```
`--refs` checks each ref out into a throwaway worktree and runs that ref's own
`generate-openapi-spec`, so it is not limited to refs that carry a committed spec
and never touches your checkout. Very old refs may not build with the current
toolchain; it falls back to the committed spec and says so when that happens.
Prefer it. `--committed` compares whatever blob each ref happens to carry, and
when a release branch and master share a stale one it reports far fewer findings
than the API actually changed - an artifact of the spec, not of the API.
### Start from the grouped view
A single upstream change can produce hundreds of findings. `--grouped` collapses
identical findings and sorts by blast radius, which is the shape you draft from:
```bash
./bin/mage openapi-diff --refs <old> <new> --severity breaking --grouped
```
On the real v63 -> master delta that turns 239 breaking findings into 113
distinct changes. The top entry spans 217 endpoints and is one PR (#82447,
closing `mu/defn` argument schemas) - **one changelog entry, not 217**.
Drop `--grouped` when you need the per-endpoint view to check a specific route.
A change spanning many endpoints is usually one upstream PR: write it once, name
the cause, and say which endpoints it spans. The long tail of single-endpoint
changes is where the individually-interesting entries are - the v63 -> master run
surfaced a `pinned_state` -> `pinned-state` query param rename there.
Findings are classified and sorted breaking-first.
**A change is breaking when an existing caller, sending exactly what it sent
before, can now fail or get a different result.** That covers three cases: the
API requires more of the request, provides less in the response, or behaves
differently for the same request. The first two invert between request and
response:
| | Breaking | Not breaking |
|---|---|---|
| **Request** | field or param becomes required; type, enum, or bound (`minimum`, `minLength`, `pattern`, ...) narrowed; `additionalProperties: false` added; default changed or dropped | new *optional* field or param; type, enum, or bound widened; field made nullable; schema opened; default added |
| **Response** | field removed or no longer always returned; field may now be `null`; new enum value | new field returned; field that was nullable never is |
| **Endpoint** | removed | added |
When the tool cannot tell, it ranks the change breaking: a false alarm costs
you one look, a miss ships undocumented. Two rules follow from that, not from
the definition, so check them before drafting an entry:
- **A removed request field** is ranked breaking because the spec does not say
whether the server rejects or acts on keys it does not declare. If the
endpoint ignores unknown keys, the removal is not breaking.
- **A change to a schema keyword the tool does not model** is ranked breaking
so that it is never hidden. Read the finding to decide.
A reworded description, title, or example is DOC_ONLY.
Adding an optional parameter is not breaking. Returning extra data is not
breaking. Existing callers keep working in both cases.
Start from `--severity breaking`. The long tail of API change is additive
(a public Stripe-spec diff found 663 of 679 changes additive); reading the
full diff to find the breaking few is how entries get missed.
**Identical or near-identical committed specs do not mean the API did not
change.** Two refs often carry the same stale blob, so `--committed` can report
zero findings for a pair that generating from source shows has removed endpoints.
Before reporting "no API surface changes", re-run without `--committed`. Only a
clean generated diff supports that conclusion.
3. Review the classification. The tool decides severity structurally, but two
cases still need your judgement:
- **A removed + added pair is often one endpoint moving** (method change, or a
param moving between query/path/body). The tool reports two findings; read
the docstrings to pair them and write ONE changelog entry describing the move.
- **An `ADDITIVE` finding can still be worth an entry** when it is a new
endpoint clients should know about. Additive means "will not break existing
callers", not "not worth mentioning".
4. For every breaking change, decide whether you can state **what it does for a
client** from the spec's `description` alone. If you cannot, do not guess and
do not paper over it - list it under "Needs author input" with the endpoint
name and what is unclear. Thin endpoint docstrings are the actual finding;
report them loudly.
5. Cross-check `docs/developers-guide/api-changelog.md` for the target version.
Report three buckets:
- **Undocumented**: breaking changes with no changelog entry -> draft entries
- **Stale**: changelog entries with no matching spec change -> verify by hand
(may be a response-shape or behavior change the spec cannot see - do not
delete without asking)
- **Covered**: matched, no action
6. Draft entries in the existing house style: `## Metabase 0.64.0` heading,
one `-` bullet per change, endpoint as `` `POST /api/foo/:id` `` with
**`:id` colon-style params, not OpenAPI `{id}` braces**. Say what changed, what
clients must do, and whether there was a deprecation period. Apply the
`docs-write` skill. Show the diff before writing to the file.
## Attribution
Find who to ask about an unclear change. Search the endpoint SOURCE, not `openapi.json`: the committed spec only changes on
PRs carrying the `openapi-self-healing` label, which is usually a later, unrelated
PR, so its history names the wrong author.
```bash
git log <old-ref>..<new-ref> --oneline -- \
'src/metabase/**/api.clj' 'src/metabase/**/api/*.clj' 'src/metabase/**/routes.clj' \
'enterprise/backend/src/**/api.clj' 'enterprise/backend/src/**/api/*.clj' 'enterprise/backend/src/**/routes.clj'
```
Attribution by route search is unreliable in both directions - `git log -S` reports
where a string's count *changed*, which for a long-lived route is its creation years
ago rather than its recent removal. Treat any commit you find as a candidate to
verify, never as the established cause.
## Backports
A change landing in both `0.64.0` and older lines gets a full entry under the
newest version and a one-line pointer under each backport version
("See the 0.64.0 entry."). Match the existing `POST /api/slack/bug-report` pattern.

View File

@@ -0,0 +1,94 @@
---
name: backend-module-conventions
description: Where Metabase backend code goes and how it reaches the app DB. Covers module layout, the `<module>.db` rule, `[:auto/param]` value binding, module config, and pre-handoff checks. Use when adding or moving backend namespaces, writing app-DB queries, or touching module boundaries. Preloaded by every `*-backend-expert` agent.
---
# Backend module conventions
This skill decides where a line of backend code lives and how it reaches the app DB. Project `CLAUDE.md`
covers the module config keys, nested modules, test commands, and ratchets. When the two disagree, the
source tree wins. Read the linter or test that enforces a rule before you trust any prose about it,
including this file.
## Split a module into standard namespaces
A module is `metabase.<module>` (OSS, `src/`) or `metabase-enterprise.<module>` (EE,
`enterprise/backend/src/`). Most modules split into these namespaces:
| Namespace | Holds |
|---|---|
| `<module>.core` | The public API. Other modules call only this (plus anything else listed in the module's `:api`). Often a `potemkin/import-vars` facade. |
| `<module>.db` | Every app-DB query the module makes. See the next section. |
| `<module>.models.*` | Toucan 2 model definitions: `methodical/defmethod t2/table-name`, hooks, transforms. |
| `<module>.settings` | `defsetting`s. |
| `<module>.init` | Requires the namespaces that register things as a side effect (settings, tasks, event handlers). `metabase.core.init` or `metabase-enterprise.core.init` requires it. |
| `<module>.api`, or the nested `<module>.rest` module (`:ns-prefix "metabase.<module>-rest"`, dir `<module>_rest/`) | HTTP endpoints. Domain logic stays in the base module. The `.rest` child calls the parent's `.core`. |
| `enterprise/<module>` | The EE companion of an OSS module. It nests under the OSS module, which exports it automatically. |
If a setting, task, or event handler never takes effect, look for a missing link in the `.init` chain.
## Keep app-DB access in `<module>.db`
Call Toucan 2 query functions (`t2/select*`, `t2/insert!`, `t2/update!`, `t2/delete!`, `t2/count`,
`t2/exists?`, `t2/query`, ...) and the `metabase.app-db.core` wrappers (`mdb/query`,
`mdb/update-or-insert!`, ...) only from `metabase[-enterprise].<module>.db`. Driver code uses
`metabase.driver.<driver>.db`. Every other namespace calls functions from its own module's `db`.
The kondo hook `hooks.metabase.toucan.db-ns` reports violations as `:metabase/t2-query-namespace`.
Test files are exempt.
Write `db` functions in the style of `src/metabase/settings/db.clj`:
- Make each function a thin `mu/defn` that wraps one query and adds no logic. Give it a docstring that says what it returns.
- Name functions after the domain question (`setting-value`, `delete-setting!`), not after SQL.
- Keep branching, permission checks, and data shaping in the caller. A `db` function that grows an `if` belongs somewhere else.
- Get cross-module data through the other module's `.core`, not its `db`. A few modules list their `db` in `:api` as legacy exceptions. Don't add more.
### Bind values with `[:auto/param v]`
`metabase.app-db.value-guard` binds a value written as `[:auto/param v]` as a SQL parameter. The
`:metabase/unsafe-app-db-query` lint is off globally. `.clj-kondo/config.edn` turns it on per namespace
under `:config-in-ns`, one converted `db.clj` at a time. Follow these rules in every `db.clj`, whether or
not the lint covers it yet:
1. Mark a value that comes from a variable when it sits in a **value slot**. Value slots are the right-hand side of `:=`, `:in`, `:like`, ..., and kv-arg values used as filters.
2. Never mark a literal. A literal cannot carry a request value.
3. Never mark a value in an **identifier slot** (`:select`, `:from`, joins, aliases, and similar). `value-guard` throws `::marker-outside-value-slot` at compile time. In `:order-by` and `:group-by` a marker compiles to `ORDER BY ?`, which is useless but does not throw. Don't write one there either.
4. Leave a possibly-empty collection unmarked. Toucan rewrites `[:in col []]` to `false` before the marker step.
5. Leave values that `t2/update!` *writes* unmarked. Model hooks such as encryption must still see them.
6. Keep kv-arg style (`:engine engine`) for a column with a Toucan type transform. If you move a keyword value into a raw `{:where ...}` map, the transform is skipped. HoneySQL then formats the keyword as an identifier, and the query returns wrong rows without failing.
The docstring of `metabase.app-db.value-guard` explains what the guard refuses without any marker.
## Add app-DB migrations as one file per change
`./bin/lint-migrations-file.sh` enforces these rules. Run it after every change.
- Starting with v60, each change is its own file at `resources/migrations/<NNN>/<yyyyMMdd>_<snake_name>.yaml`. `NNN` is the earliest release the change ships in. New work goes in the newest directory. Don't add to `001_update_migrations.yaml`; its footer describes the legacy format.
- Changeset IDs are `v<NN>.<unique suffix>`, where `NN` matches the directory. The suffix can be a timestamp from `(dev/migration-timestamp)` or any unique string. IDs are not ordered within a directory.
- Don't edit a shipped changeset. Add a new one. (Liquibase does recompute checksums and some edits are technically harmless, but the line between "cosmetic" and "ran instances now diverge from fresh installs" is easy to misjudge, so just add a new changeset.) Never delete a shipped changeset either: instances keep the row in `DATABASECHANGELOG` forever; removing the id makes Liquibase log "not rolled back" and any replacement that recreates the same object fails with a duplicate-object error. To retire one when you must, add a NEW changeset that does `DELETE FROM DATABASECHANGELOG WHERE id IN (...)` the retired ids. Put it in the same file as the retired changeset when it ships in the same release (`063/20260714_field_fk_target_field_id_fk.yaml::v63.2026-07-22T12:00:00`), or in the current release's file when it retires an older release's changeset (`059_update_migrations.yaml::v59.2026-01-09T11:30:00` retires `v58.2026-01-09T12:00:00`).
- To backport, copy the changesets into the older directory with `v<older>` IDs. Guard each copy with `preConditions: onFail: MARK_RAN` plus `not: changeSetExecuted` that points at the newer ID, so databases that already ran it skip it. `resources/migrations/064/20260911_glossary_entity_id.yaml` shows the pattern.
- Give every `indexExists`, `primaryKeyExists`, and `uniqueConstraintExists` precondition a `tableName`, and every `foreignKeyConstraintExists` a `foreignKeyTableName`. Without it, Liquibase snapshots the whole schema, which takes seconds per check.
- Prefer SQL changesets. A custom migration (`metabase.app-db.custom-migrations`, `define-migration` / `define-reversible-migration`) must use table names, never `:model/*` or other application code. Application code changes later, and then the migration breaks. Decrypt encrypted columns such as `metabase_database.details` and `setting.value` before reading them.
- The whole `metabase.app-db.*` module, custom migrations included, is exempt from the `<module>.db` rule (the `app-db-namespaces` group in `.clj-kondo/config.edn`).
- Test with the `test-migrations` macro (`metabase.app-db.schema-migrations-test.impl`) on H2 and Postgres. Add MySQL when the change touches DDL or JSON columns.
## Cross module boundaries through `:api`
- `.clj-kondo/config/modules/config.edn` declares `:api`, `:uses`, `:model-exports`, and `:model-imports` per module. Run `./bin/mage fix-modules-config` after you add or remove a namespace, a cross-module require, or a cross-module `:model/X` reference. If nothing drifted, it does nothing.
- `./bin/mage modules-tree` prints the hierarchy. The `dev.deps-graph` and `dev.module-score` namespaces show who uses a module externally.
- If a module needs something outside another module's `:api`, choose the first fix that works:
1. Call something that is already public.
2. Add the var to that module's `.core`.
3. Invert the dependency with an event (`metabase.events`).
Don't add a `:clj-kondo/ignore`.
- If you change a suppression or a module escape hatch, run `./bin/mage kondo-ratchets`.
## Verify before handing work back
1. Load each changed namespace in the REPL (`clojure-eval` skill). If no REPL is available, run `./bin/test-agent :only '[the.ns-test]'`.
2. Run the narrowest tests that cover the change. Use `./bin/test-agent :module <module>` to check blast radius.
3. Run `./bin/mage kondo-updated` on the changed files.
4. If a boundary moved, run `./bin/mage fix-modules-config`.
Report which of these steps ran, and what each one showed.

View File

@@ -47,6 +47,16 @@ The checklist mirrors the order of the conventions file. Items marked **(lint)**
- [ ] **No hardcoded numeric IDs anywhere** — including for entities the test creates itself. Capture from the create response or alias the intercept.
- [ ] Existing navigation helpers used (`H.openOrdersTable`, `H.visitDashboard(id)`, etc.) instead of raw `cy.visit()` chains
### Shared helper changes
When a diff changes a shared helper, inspect its callers rather than reviewing the helper in isolation:
- [ ] Default behavior changes are justified for every caller; otherwise the new behavior is explicit and opt-in.
- [ ] Repeated calls with identical and different arguments are safe.
- [ ] Mandatory network waits account for cache hits, request deduplication, unchanged query arguments, and conditional fetches where no request occurs.
- [ ] Intercepts and aliases cannot accumulate, collide, or resolve against the wrong request.
- [ ] Verification covers both an affected caller and a representative caller using unchanged defaults.
### Selectors
- [ ] Prefers a11y queries (`findByRole`, `findByLabelText`) over `findByText`

View File

@@ -14,12 +14,14 @@ Only use `MB_EDITION=ee` when the user explicitly asks to write or run an enterp
**For `MB_EDITION=ee`:**
These tokens must be exported in the shell before starting Claude Code:
- `CYPRESS_MB_ALL_FEATURES_TOKEN` — all features (most EE tests need this)
- `CYPRESS_MB_PRO_SELF_HOSTED_TOKEN` — pro self-hosted (permissions, SSO, sandboxing)
- `CYPRESS_MB_STARTER_CLOUD_TOKEN` — starter plan (rarely needed)
- `CYPRESS_MB_PRO_CLOUD_TOKEN` — pro cloud (rarely needed)
**NEVER echo, print, or log token values. Only check if they are set:**
```bash
echo "ALL_FEATURES: ${CYPRESS_MB_ALL_FEATURES_TOKEN:+set}" && echo "PRO_SELF_HOSTED: ${CYPRESS_MB_PRO_SELF_HOSTED_TOKEN:+set}" && echo "STARTER: ${CYPRESS_MB_STARTER_CLOUD_TOKEN:+set}" && echo "PRO_CLOUD: ${CYPRESS_MB_PRO_CLOUD_TOKEN:+set}"
```
@@ -30,9 +32,19 @@ If tokens are missing, tell the user: "EE tokens are not set. Either export them
If the user asks "how long does this test take" / "what's the timing" — **do not run it**. Read `e2e/support/timings.json`, which holds the latest CI duration (in ms) per spec. Match on the spec path (entries are stored as `../test/scenarios/...`). Per-`it()` granularity is not recorded there; for that you'd need the Cypress JSON reporter, which requires running the spec.
## Diagnosing flakiness
Before changing a flaky test:
1. Gather the available longitudinal CI history, failure messages, screenshots, and retries. Classify distinct failures by the UI or network boundary where they occur instead of treating every occurrence as one race.
2. State which user interactions the test is intended to cover. Preserve interactions that are part of the regression path or relevant failure evidence; do not move them to API setup merely to make the test pass.
3. Fix the narrowest boundary supported by evidence. Keep backend errors, failed requests, and application crashes visible unless the task explicitly targets their recovery behavior.
4. Treat a local stress run as evidence about a candidate change, not proof that historical flakiness never existed. A small passing sample does not outweigh repeated CI failures.
## Running
First check if snapshots exist:
```bash
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
```
@@ -40,11 +52,13 @@ ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snap
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 --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 --spec path/to/spec.cy.spec.js
```
@@ -54,12 +68,15 @@ When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in
If `$ARGUMENTS` is empty, ask the user which spec to run.
If the user provides a test name or fuzzy name instead of a spec path, find the spec file:
```bash
find e2e/test/scenarios -name "*FUZZY_NAME*.cy.spec.*" -type f
```
Then run with `--spec "path/to/matched.cy.spec.js"`.
To filter by test name, use the `GREP` env var (not `--env grep=`, which breaks on commas):
```bash
GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
```

View File

@@ -39,7 +39,7 @@ what and who may depend on whom.**
| 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` |
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint` |
| 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()` |
@@ -199,7 +199,7 @@ prefix; never generic `-shared` / `-feature` suffixes.
```
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 lint-oxlint # 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

View File

@@ -172,7 +172,7 @@ export const OverviewStory = {
- **Scope controls per story.** A static matrix should hide knobs it ignores: `parameters.controls = { include: [...] }` (or `{ disable: true }`). Wire any kept knob (e.g. `children`) through to the cells so it does something.
- `loki.config.js` has a `storiesFilter`; check it before renaming a story that's already covered.
Validate: `bun run lint-eslint-pure -- <files>` and `bun run type-check-pure`.
Validate: `bun run lint-oxlint -- <files>` and `bun run type-check-pure`.
### Optional: verify in the browser yourself
@@ -217,7 +217,7 @@ General gotchas when extending Mantine components — adapt per component:
- **Built-in icons use `currentColor`**, so they inherit the slot's text color.
- For text on a brand fill, follow the existing convention (`text-primary-inverse`) unless the user wants pure white; flag the dark-theme implication.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-eslint-pure -- <files>`, `bun run type-check-pure`.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint -- <files>`, `bun run type-check-pure`.
## Phase 5 — Iterate with the user

View File

@@ -0,0 +1,79 @@
---
name: name-module-cycle
description: Suggest a space-themed name for a cyclic cluster of backend modules when metabase.core.module-cycles-test reports a cluster without a name, usually after a split. Use when someone needs to name a module cycle or add a line to .clj-kondo/config/modules/cycle-clusters.edn.
allowed-tools: Read, Grep, Glob, Bash
---
# Name a module cycle
`.clj-kondo/config/modules/cycle-clusters.edn` maps each cyclic cluster of the module require graph to an anchor
module. When a change splits a cluster, the new half has no name, and the person who split it gets to choose one.
Help them choose; do not pick for them.
## 1. See the cluster
The test failure lists the cluster's modules and, for a small cluster, the requires between them that make it a
cycle. For the full picture, including teams, run:
```bash
clojure -X:dev dev.module-cycles/print-clusters
```
Unnamed clusters print first.
## 2. Split, or a new cycle?
Look at everything the branch changes, committed or not: `git diff $(git merge-base origin/master HEAD)`, plus new
files from `git ls-files --others --exclude-standard`.
If it removed requires and these modules were part of a named cluster on master, the cluster split: name the new
half. If it added a require between these modules, it created a new cycle: help break it instead, and stop here.
## 3. Learn what the cluster does
A module's namespaces are `metabase.<module>.*`, with dashes as underscores in the path, so `cloud-migration` lives
in `src/metabase/cloud_migration/`, and `enterprise/foo` in `enterprise/backend/src/metabase_enterprise/foo/`. Some
modules declare an `:ns-prefix` in `.clj-kondo/config/modules/config.edn` instead: `core.cmd` lives in
`src/metabase/cmd/`, and `mcp.oauth-server` in `src/metabase/oauth_server/`.
`core.clj` is often a bare facade. The `ns` docstrings of `api.clj`, `models/` and any `README.md` usually say more,
and so do the requires that close the cycle: they show what each module needs from the others.
## 4. Suggest names
Offer three to five names, each with one line on why it fits. A good name:
- comes from space: astronomy, spaceflight, or science fiction about space;
- has some link to what the cluster does, its role in the codebase, or how it came to be;
- is a lowercase kebab-case simple symbol, not already in the file;
- stays true as the cluster changes, so avoid sizes and counts (`binary-star` stops fitting when a third module joins).
The current names show the range:
| Name | Anchor | Why it fits |
|---|---|---|
| `galactic-center` | `query-processor` | The dense core everything orbits |
| `foundation` | `app-db` | The base layer, and Asimov's Foundation |
| `tardis` | `lib` | A plain box from the outside; internally complex, and it can take you places |
| `stargate` | `api-routes` | The gateway every request comes through |
| `first-contact` | `mcp` | The protocol outside agents use to talk to Metabase |
| `deep-thought` | `metabot` | The computer built to answer the big question |
The `<anchor>-knot` placeholder from the test failure is a valid fallback when nothing better comes up.
## 5. Pick the anchor
The anchor can be any module in the cluster, and the name follows it. Prefer one that is central and unlikely to
leave the cluster or be renamed. The test proposes the member with the most requires to and from the rest of the
cluster, ties broken alphabetically; in a small cluster everything often ties, and then the proposal carries no
signal.
## 6. Record it
Add `<name> <anchor>` to the map in `.clj-kondo/config/modules/cycle-clusters.edn`, keeping the entries sorted and
the anchors aligned one space past the longest name, then run:
```bash
./bin/test-agent :only '[metabase.core.module-cycles-test]'
```
Mention the new name in the PR, so reviewers see the split.

View File

@@ -65,6 +65,7 @@
:metabase/check-testing-not-empty {:level :warning}
:metabase/defsetting-must-specify-export {:level :warning}
:metabase/defsetting-namespace {:level :warning}
:metabase/discourage-dynamic-vars {:level :warning}
:metabase/discourage-millis-duration {:level :warning}
:metabase/mbql-query-first-arg {:level :warning}
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
@@ -84,6 +85,7 @@
:metabase/validate-defendpoint-route-uses-kebab-case {:level :warning}
:metabase/validate-defendpoint-query-params-use-kebab-case {:level :warning}
:metabase/validate-logging {:level :warning}
:metabase/validate-mb-app-db-migrations-test {:level :warning}
:metabase/validate-mb-driver-tests {:level :warning}
:metabase/validate-mb-once {:level :warning}
@@ -319,6 +321,12 @@
{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"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsRows {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.equals/map->EqualsSql {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
metabase-enterprise.transform-testing.expectations.empty/map->Empty {:message "Build an expectation with metabase-enterprise.transform-testing.expectations.protocol/build, over a value the schema has passed — the generated constructors skip validation"}
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"}
@@ -416,6 +424,7 @@
metabase.analyze.classifiers.core classifiers
metabase.channel.core channel
metabase.channel.render.core channel.render
metabase.channel.render.markdown markdown
metabase.cloud-migration.core cloud-migration
metabase.eid-translation.core eid-translation
metabase.llm.test-util llm.tu
@@ -452,7 +461,6 @@
metabase.util.macros u.macros
metabase.util.malli mu
metabase.util.malli.schema ms
metabase.util.markdown markdown
metabase.util.password u.password
metabase.util.ui-logic ui-logic
metabase.xrays.core xrays
@@ -487,6 +495,7 @@
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.transforms.test-util/with-transforms-api-users! clojure.core/fn
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
@@ -581,7 +590,11 @@
:hooks
{:analyze-call
{cljs.test/deftest hooks.clojure.test/deftest
{cljs.core/def hooks.clojure.core.def/lint-dynamic
cljs.core/defn hooks.clojure.core.def/lint-dynamic
cljs.core/defn- hooks.clojure.core.def/lint-dynamic
cljs.core/defonce hooks.clojure.core.def/lint-dynamic
cljs.test/deftest hooks.clojure.test/deftest
cljs.test/is hooks.clojure.test/is
cljs.test/testing hooks.clojure.test/testing
cljs.test/use-fixtures hooks.clojure.test/use-fixtures
@@ -595,6 +608,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
@@ -610,13 +624,14 @@
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.remote-sync.metric-dimension-api-test/with-synced-metric hooks.common/with-one-binding
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
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
@@ -689,6 +704,15 @@
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
@@ -786,7 +810,11 @@
metabase.util.log/tracef hooks.metabase.util.log/infof
metabase.util.log/warn hooks.metabase.util.log/info
metabase.util.log/warnf hooks.metabase.util.log/infof
metabase.util.malli.defn/defn hooks.clojure.core.def/lint-dynamic
metabase.util.malli.defn/defn- hooks.clojure.core.def/lint-dynamic
metabase.util.malli.registry/def hooks.metabase.util.malli.registry/def
metabase.util.malli/defn hooks.clojure.core.def/lint-dynamic
metabase.util.malli/defn- hooks.clojure.core.def/lint-dynamic
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?
@@ -837,7 +865,13 @@
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world
potemkin/import-def macros.potemkin.namespaces/import-def
potemkin/import-fn macros.potemkin.namespaces/import-fn
potemkin/import-macro macros.potemkin.namespaces/import-macro
potemkin.namespaces/import-def macros.potemkin.namespaces/import-def
potemkin.namespaces/import-fn macros.potemkin.namespaces/import-fn
potemkin.namespaces/import-macro macros.potemkin.namespaces/import-macro}}
:config-in-comment
{:linters {:unresolved-symbol {:level :off}}}
@@ -901,7 +935,19 @@
;; run Toucan 2 queries anywhere.
;;
{:pattern "^metabase\\.app-db\\..*"
:name app-db-namespaces}]
: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
{metabase.app-db.custom-migrations
@@ -916,6 +962,10 @@
:private-call {:level :off}
:metabase/defsetting-must-specify-export {:level :off}
:metabase/defsetting-namespace {:level :off}
;; Test fixtures and per-test overrides use `binding`-scoped dynamic vars legitimately; this ratchet
;; isn't aimed at that pattern. Mocking functions that way is the narrower case
;; `:metabase/prefer-with-dynamic-fn-redefs` already covers.
:metabase/discourage-dynamic-vars {: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`
@@ -927,10 +977,30 @@
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}}}
metabase.settings.db
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
metabase.auth-identity.db
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
metabase.usage-metadata.db
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
metabase.user-key-value.db
{:linters {:metabase/unsafe-app-db-query {:level :warning}}}
metabase.public-sharing.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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
;; Named cyclic clusters of the module require graph: cluster name => anchor module.
;; The cluster holding the anchor carries the name; its members are computed, so it can grow and shrink freely.
;; The test metabase.core.module-cycles-test fails until every cluster holds exactly one anchor.
;; When a cluster splits, whoever split it names the new half.
;; Names follow a space theme with some link to what the cluster does; the name-module-cycle skill helps pick one.
{deep-thought metabot
first-contact mcp
foundation app-db
galactic-center query-processor
stargate api-routes
tardis lib}

View File

@@ -0,0 +1,21 @@
(ns macros.potemkin.namespaces)
(defn- import-var-expansion
"Expansion shared by all of the `import-*` replacements below. Synthetic \"docstring\" is needed to silence
`:missing-docstring` linter."
([sym]
(import-var-expansion sym (-> sym name symbol)))
([sym new-name]
`(def ~new-name "docstring" ~sym)))
(defmacro import-def
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))
(defmacro import-fn
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))
(defmacro import-macro
([sym] (import-var-expansion sym))
([sym new-name] (import-var-expansion sym new-name)))

View File

@@ -0,0 +1,56 @@
;; Budgets for kondo suppressions in test code: inline `:clj-kondo/ignore` forms per linter
;; (:ignore-counts). Production code has its own budgets in .clj-kondo/ratchets.edn, which also holds
;; every config-level waiver (:config-counts) -- config.edn's scoping doesn't cleanly split.
;; The :discouraged-var-counts and :discouraged-namespace-counts fields budget those linters' inline
;; ignores per symbol, keyed by the symbol with `metabase.` dropped and `metabase-enterprise.` shortened
;; to `ee.`.
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling; every other
;; budget is a non-negative integer.
;; 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;
;; a seed like `:discouraged-var/clojure.core/println` seeds one symbol, and `:discouraged-var` all of them.
;; 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 {:case-symbol-test 1
:clojure-lsp/unused-public-var 1
:deprecated-namespace 66
:deprecated-var 32
:equals-true 1
:metabase/disallow-hardcoded-driver-names-in-tests 34
:metabase/discourage-dynamic-vars 41
:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests 13
:metabase/namespace-name 1
:metabase/prefer-with-dynamic-fn-redefs :unlimited
:metabase/test-helpers-use-non-thread-safe-functions 18
:metabase/validate-deftest 9
:missing-protocol-method 3
:type-mismatch 6
:uninitialized-var 1
:unresolved-symbol 2
:unused-binding 6
:unused-private-var 2}
:discouraged-namespace-counts {:clojure.tools.logging 5
:toucan2.core 1}
:discouraged-var-counts {:clojure.core/pr 1
:clojure.core/print 1
:clojure.core/printf 2
:clojure.core/println 9
:clojure.core/prn 1
:clojure.core/time 1
:clojure.java.jdbc/get-connection 1
:clojure.java.jdbc/with-db-connection 1
:clojure.pprint/print-table 1
:clojure.string/lower-case 1
:compojure.core/routes 2
:ee.entity-retrieval.core/search-unfiltered 6
:entity-retrieval.core/search-unfiltered 1
:entity-retrieval.mirror/search-unfiltered 1
:lib.equality/find-column-indexes-for-refs 2
:test/with-temp 28}
:comment-exempt #{:deprecated-namespace
:discouraged-namespace
:discouraged-var
:metabase/discourage-dynamic-vars}}

View File

@@ -1,82 +1,118 @@
;; 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.
;; The :discouraged-var-counts and :discouraged-namespace-counts fields budget those linters' inline
;; ignores per symbol, keyed by the symbol with `metabase.` dropped and `metabase-enterprise.` shortened
;; to `ee.`.
;; Each :ignore-counts value is a non-negative integer budget, or :unlimited for no ceiling; every other
;; budget is a non-negative integer.
;; 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.
;; `./bin/mage kondo-ratchets-shrink` lowers budgets unless `--seed` explicitly adds or raises one;
;; a seed like `:discouraged-var/clojure.core/println` seeds one symbol, and `:discouraged-var` all of them.
;; 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}}
;;
;; Test code's inline ignores have their own budgets in .clj-kondo/ratchets-test.edn.
;; :config-counts stays here either way -- config.edn's scoping doesn't cleanly split.
{:ignore-counts {:aliased-namespace-symbol 3
:clojure-lsp/unused-public-var 7
:consistent-alias 2
:def-fn 1
:deprecated-namespace 29
:deprecated-var 53
:discouraged-java-method 4
:duplicate-require 1
:main-without-gen-class 1
:metabase/discourage-dynamic-vars 324
:metabase/discourage-millis-duration 1
:metabase/modules 23
:metabase/namespace-name 3
:metabase/validate-defendpoint-has-response-schema 427
:metabase/validate-defendpoint-query-params-use-kebab-case 48
:metabase/validate-defendpoint-route-uses-kebab-case 44
:missing-docstring 4
:non-arg-vec-return-type-hint 1
:reduce-without-init 3
:redundant-do 1
:syntax 2
:type-mismatch 2
:unquote-not-syntax-quoted 1
:unresolved-namespace 3
:unresolved-symbol 9
:unsorted-required-namespaces :unlimited
:unused-alias 2
:unused-binding 3
:unused-excluded-var 2
:unused-namespace 2
:unused-private-var 5
:unused-value 1}
:discouraged-namespace-counts {:cheshire.core 4
:clojure.tools.logging 3
:grouper.core 1
:legacy-mbql.normalize 14
:legacy-mbql.schema 14
:legacy-mbql.util 2
:query-processor.store 8
:toucan2.core 3
:util.malli.schema 2}
:discouraged-var-counts {:clojure.core/eval 6
:clojure.core/print 1
:clojure.core/println 9
:clojure.core/prn 3
:clojure.core/tap> 5
:clojure.core/update-keys 1
:clojure.java.jdbc/get-connection 2
:clojure.pprint/pprint 2
:clojure.string/lower-case 2
:clojure.string/upper-case 1
:clojurewerkz.quartzite.jobs/defjob 1
:compojure.core/context 2
:compojure.core/defroutes 4
:compojure.core/routes 2
:ee.transform-testing.expectations.empty/->Empty 1
:ee.transform-testing.expectations.equals/->EqualsRows 1
:ee.transform-testing.expectations.equals/->EqualsSql 1
:entity-retrieval.core/search-unfiltered 1
:entity-retrieval.mirror/search-unfiltered 1
:lib.core/->legacy-MBQL 10
:lib.equality/find-column-indexes-for-refs 1
:malli.core/explain 1
:malli.core/explainer 1
:malli.core/validate 1
:malli.core/validator 1
:me.flowthing.pp/pprint 2
:next.jdbc/active-tx? 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/discourage-dynamic-vars 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/discourage-dynamic-vars
: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

@@ -32,14 +32,60 @@
:message "Use kebab-case names for functions and variables. [:metabase/check-def-no-underscores]"
:type :metabase/check-def-no-underscores)))))
(defn- attr-map
"The attr-map of a `defn`-like form, which Clojure merges into the var metadata, or nil.
A map in a `def` or `defonce` is the value, not an attr-map."
[node]
(let [[head _name & args] (:children node)]
(when-not (#{"def" "defonce"} (name (hooks/sexpr head)))
(loop [[arg & more :as args] args]
(cond
(hooks/string-node? arg) (recur more)
;; a Malli `:- schema` return annotation
(and (hooks/keyword-node? arg) (= :- (hooks/sexpr arg))) (recur (rest more))
(hooks/map-node? arg) (hooks/sexpr arg)
;; a multi-arity body can end with a trailing attr-map
(hooks/list-node? arg) (let [trailing (last args)]
(when (hooks/map-node? trailing)
(hooks/sexpr trailing))))))))
(defn- dynamic?
"Whether the var defined by `def-node` is dynamic, through metadata on `symbol-node` or a `defn` attr-map."
[def-node symbol-node]
(or (some (fn [meta-node]
(let [m (hooks/sexpr meta-node)]
(or (= m :dynamic)
(and (map? m) (true? (:dynamic m))))))
(:meta symbol-node))
(true? (:dynamic (attr-map def-node)))))
(defn- check-symbol-is-not-dynamic [def-node symbol-node]
(when (dynamic? def-node symbol-node)
;; Report on the form, not the name: `kondo-insert-ignores` puts the ignore on the line above the finding,
;; and the line above the name can be inside the form, where an ignore has no effect.
(hooks/reg-finding!
(assoc (meta def-node)
:message "Avoid new dynamic vars; pass the value as an argument or hang it on a stateful component. [:metabase/discourage-dynamic-vars]"
:type :metabase/discourage-dynamic-vars))))
(defn- name-symbol [node]
(let [[_def & args] (:children node)]
(some (fn [arg]
(when (and (hooks/token-node? arg)
(symbol? (hooks/sexpr arg)))
arg))
args)))
(defn lint-def* [{:keys [node]}]
(let [[_def & args] (:children node)
name-symbol (some (fn [arg]
(when (and (hooks/token-node? arg)
(symbol? (hooks/sexpr arg)))
arg))
args)]
(check-symbol-is-kebab-case name-symbol)))
(let [symbol-node (name-symbol node)]
(check-symbol-is-kebab-case symbol-node)
(check-symbol-is-not-dynamic node symbol-node)))
(defn lint-dynamic
"Hook for def-like macros that should get only the dynamic-var check. Leaves the node unchanged."
[{:keys [node]}]
(check-symbol-is-not-dynamic node (name-symbol node))
nil)
(defn lint-def [x]
(lint-def* x)

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

@@ -1,8 +1,22 @@
(ns hooks.metabase.app-db.schema-migrations-test.impl
(:require [clj-kondo.hooks-api :as hooks]))
(:require
[clj-kondo.hooks-api :as hooks]))
(defn test-migrations
[{{[_ migration-range {[binding] :children} & body] :children} :node}]
(defn- validate-mb-app-db-migrations-test!
"CI runs the namespaces tagged `:mb/app-db-migrations-test` on a shard of their own, because a count-based split
can't balance them (see `.github/workflows/app-db.yml`). An untagged one still runs, just on a shard sized for
everything else."
[{:keys [ns node]}]
(when-not (:mb/app-db-migrations-test (meta ns))
(hooks/reg-finding!
(assoc (meta node)
:message (str "Namespaces that run migration tests (test-migrations) should be marked"
" ^:mb/app-db-migrations-test [:metabase/validate-mb-app-db-migrations-test]")
:type :metabase/validate-mb-app-db-migrations-test))))
(defn test-migrations!
[{{[_ migration-range {[binding] :children} & body] :children} :node, :as x}]
(validate-mb-app-db-migrations-test! x)
(let [node* (hooks/list-node
(list*
(hooks/token-node `let)

View File

@@ -31,7 +31,8 @@
(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})
'#{= not= != <> is-distinct-from is-not-distinct-from
< > <= >= like not-like ilike not-ilike in not-in between not-between})
(defn- marked?
"Whether `node` is an `[:auto/param v]` marker."
@@ -39,28 +40,138 @@
(and (hooks/vector-node? node)
(= :auto/param (some-> (first (:children node)) hooks/sexpr))))
(defn- distinct-by
"`coll` with only the first element for each distinct `(f element)`."
[f coll]
(->> coll (reduce (fn [[seen acc] x]
(let [k (f x)]
(if (contains? seen k) [seen acc] [(conj seen k) (conj acc x)])))
[#{} []])
second))
(def ^:private identifier-clauses
"Clause keys whose entries name a column or a table rather than carrying a value.
A symbol here is a name, and `metabase.app-db.value-guard` refuses a marker in one, so reporting
it would ask for a fix that throws at compile. `:order-by` and `:group-by` are included even
though the guard allows a marker there: it compiles to `ORDER BY ?` and loses the ordering, so
asking for one is still wrong."
#{:select :select-distinct :select-top :from :join :left-join :right-join :inner-join :full-join
:cross-join :update :insert-into :delete-from :returning :with :with-columns :using
:order-by :group-by :partition-by :window})
(def ^:private expression-entry-clauses
"Identifier clauses where index 0 of an `[expr alias]` entry is an expression, not a name.
HoneySQL binds a parameter there -- `SELECT ? AS a` -- so the guard accepts a marker, and mirrors
this set as `expression-entry-clauses` in `metabase.app-db.value-guard`. Everything after index 0
is an alias, and a bare entry is a name; the guard refuses a marker in both."
#{:select :select-distinct :select-top :returning :partition-by})
(defn- comparison-node?
"Whether `node` is a keyword-headed comparison -- the one shape inside an identifier clause that
does hold values, as in the computed projection `[[:= :engine v] :is_match]`."
[node]
(and (hooks/vector-node? node)
(let [op (some-> (first (:children node)) hooks/sexpr)]
(and (keyword? op) (contains? value-operators (symbol (name op)))))))
(declare value-nodes)
(defn- identifier-clause-value-nodes
"The value slots reachable inside an identifier clause: a subquery's own clauses, and the
arguments of a comparison. The names themselves are not candidates."
[node]
(cond
(hooks/map-node? node) (value-nodes node)
(comparison-node? node) (value-nodes node)
(hooks/vector-node? node) (mapcat identifier-clause-value-nodes (:children node))
(hooks/list-node? node) (mapcat identifier-clause-value-nodes (:children node))
:else nil))
(defn- expression-entry-value-nodes
"The value slots of an [[expression-entry-clauses]] clause: index 0 of each `[expr alias]` entry."
[node]
(when (hooks/vector-node? node)
(mapcat (fn [entry]
(when (hooks/vector-node? entry)
(when-let [expr (first (:children entry))]
;; Keep the expression AND descend, so a bare symbol is reported and a computed
;; projection or a subquery sitting there has its own values checked.
(cons expr (value-nodes expr)))))
(:children node))))
(defn- clause-value-nodes
"The value slots of the value under clause key `clause`."
[clause node]
(cond
(contains? expression-entry-clauses clause) (expression-entry-value-nodes node)
(contains? identifier-clauses clause) (identifier-clause-value-nodes node)
:else (value-nodes node)))
(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.
;; A query map -- walk its clause values, not its keys. A clause that names columns or tables
;; contributes only what is nested inside it.
(hooks/map-node? node)
(mapcat value-nodes (take-nth 2 (rest (:children node))))
(mapcat (fn [[k v]]
(if (hooks/keyword-node? k)
(clause-value-nodes (hooks/sexpr k) v)
(value-nodes v)))
(partition 2 (:children node)))
(hooks/vector-node? node)
(let [[head & args] (:children node)
op (some-> head hooks/sexpr)]
(cond
;; A marker IS the value slot -- never descend into its payload, or the marked value
;; gets reported as though it were bare.
(marked? node)
nil
(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.
;; column and the rest are values, whatever the arity. Keep each value AND descend into it,
;; so a subquery in a value slot -- `[:in :id {:select ... :where [:= :z z]}]` -- has its
;; own values checked too. Descending alone would drop the plain-token case.
(and (keyword? op) (contains? value-operators (symbol (name op))))
(rest args)
(mapcat #(cons % (value-nodes %)) (rest args))
;; A function-call form -- `[:lower v]` -- or a literal collection -- `[a b]` in an `:in`.
;; Both hold values, so yield the children themselves as candidates as well as
;; descending, since a token child returns nothing on its own.
:else
(mapcat value-nodes (:children node))))
(mapcat #(cons % (value-nodes %)) (:children node))))
;; A clause built conditionally -- `(when flag [:= :col v])`, `(if ... )`, `(cond-> ...)`. The
;; value slots are inside, so walk the children rather than stopping. Without this a value
;; wrapped in `when` is invisible to the check, which is a false negative in a security lint.
;;
;; An `assoc` call gets its clause context back: `(assoc q :order-by [[col dir]])` is the same
;; clause as `{:order-by [[col dir]]}`, but written outside a map literal, where the walk would
;; otherwise fall back to shape alone and report the column and the direction as values.
;; `cond->` is covered because the walk descends into the nested `assoc`.
(hooks/list-node? node)
(let [children (vec (:children node))
assoc? (= 'assoc (some-> (first children) hooks/sexpr))
;; index of a clause value -> its clause key
clauses (when assoc?
(into {} (keep-indexed (fn [i child]
(when (and (hooks/keyword-node? child)
(contains? identifier-clauses (hooks/sexpr child))
(< (inc i) (count children)))
[(inc i) (hooks/sexpr child)]))
children)))]
(mapcat (fn [i child]
(if-let [clause (get clauses i)]
(clause-value-nodes clause child)
(value-nodes child)))
(range)
children))
:else nil))
@@ -77,35 +188,106 @@
(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.
(def ^:private clause-keys
"Query-map clause keys. A map keyed by these is a query map rather than a map of conditions."
#{:select :select-distinct :from :where :join :left-join :right-join :inner-join :full-join
:cross-join :group-by :having :order-by :limit :offset :for :union :union-all :with
:with-columns :returning :values :set})
(defn- query-map-node?
"Whether `node` is a query map rather than a map of column/value conditions.
`(t2/delete! :model/X {:where [:= :k k]})` passes a query map, whose values [[value-nodes]]
already walks -- treating it as conditions too would report every value twice."
[node]
(boolean (some #(and (hooks/keyword-node? %) (contains? clause-keys (hooks/sexpr %)))
(take-nth 2 (:children node)))))
(defn- model-node?
"Whether `node` names a literal model: `:model/X`, or `[:model/X & columns]`."
[node]
(letfn [(model-keyword? [n]
(and (hooks/keyword-node? n)
(= "model" (namespace (hooks/sexpr n)))))]
(or (model-keyword? node)
(and (hooks/vector-node? node)
(some-> node :children first model-keyword?)))))
(def ^:private changes-map-fns
"Conditions-map fns whose arglist also ends in a changes map, so a lone trailing map is changes."
'#{update! update-or-insert!})
(def ^:private conditions-map-fns
"Calls whose first argument after the model is a map of CONDITIONS rather than a query map.
`(t2/update! :model/X {:key k} {:v 1})` filters on `k`, so it is a where-clause value -- but it
reaches the query as a plain map entry rather than a `:where` clause, so neither the query-map
walker nor the kv-arg walker sees it. It is a shape the sweep leaves in place, so it has to be
checked here."
'#{update! update-or-insert! delete!})
(defn- conditions-map-value-nodes
"The values of a conditions map passed to one of [[conditions-map-fns]]."
[f args]
(when (contains? conditions-map-fns (some-> f name symbol))
(let [after-model (rest (drop-while (complement model-node?) args))]
;; Only the map immediately after the model is conditions. A later map is the changes map,
;; whose values are written rather than filtered on, and a written value is never marked: a
;; column's `:in` transform runs on the marker itself and stores it as data.
;; `update!`'s arglist ENDS in the changes map, so its first map is conditions only when
;; another argument follows: `(t2/update! model {:v written})` is the two-arity call whose
;; single map is CHANGES, so flagging it would ask for a marker that corrupts the write.
;; `delete!` has no changes map, so its map is always conditions.
(when-let [m (when (or (not (contains? changes-map-fns (some-> f name symbol)))
(next after-model))
(first after-model))]
(when (and (hooks/map-node? m) (not (query-map-node? m)))
(mapcat (fn [v]
;; The column comes from the map key, so an operator form here holds only
;; values -- `{:key [:in ks]}` -- exactly as a kv-arg pair does.
(if (and (hooks/vector-node? v) (not (marked? v)))
(let [[head & op-args] (:children v)
op (some-> head hooks/sexpr)]
(if (and (keyword? op) (contains? value-operators (symbol (name op))))
(mapcat #(cons % (value-nodes %)) op-args)
(cons v (value-nodes v))))
(cons v (value-nodes v))))
(take-nth 2 (rest (:children m)))))))))
(defn- kv-arg-value-nodes
"The value nodes of a call written as `:column value` pairs.
`(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."
start after the model. A call that does not name a literal model is not checked.
A trailing query map is not a pair, and its values are reached by [[value-nodes]] instead. A
value that is itself an operator form -- `:id [:in ids]` -- has its own values walked, so the
collection inside is checked rather than the form."
[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)))
(let [after-model (rest (drop-while (complement model-node?) args))]
(->> after-model
;; `partition-all` rather than `partition` so a trailing odd argument (a query map, which
;; is not a pair) is still seen and skipped by the keyword-node? test below.
(partition-all 2)
(mapcat (fn [[k v]]
(when (and v (hooks/keyword-node? k))
(if-not (hooks/vector-node? v)
[v]
(let [[head & op-args] (:children v)
op (some-> head hooks/sexpr)]
(cond
;; Already marked -- the marker is the value slot.
(marked? v) [v]
(defn- lint-kv-args!
"Register a finding for a query written as `:column value` pairs.
;; `:id [:in ids]`. Toucan folds the column in from the pair key, so
;; EVERY argument here is a value -- unlike a query-map clause, where
;; the first argument is the column.
(and (keyword? op)
(contains? value-operators (symbol (name op))))
(mapcat #(cons % (value-nodes %)) op-args)
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))))
:else (cons v (value-nodes v)))))))))))
(defn- lint-unmarked-values!
"Register a finding for each argument of the enclosing function that reaches a value slot unmarked.
@@ -113,7 +295,15 @@
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)))
(doseq [value (let [args (rest (:children node))
f (some-> (first (:children node)) hooks/sexpr)]
;; The three walkers overlap on some shapes -- a multi-arg operator in a kv-arg
;; reaches both `value-nodes` and `kv-arg-value-nodes` -- so dedupe by source
;; position rather than trying to keep them disjoint.
(->> (concat (mapcat value-nodes args)
(kv-arg-value-nodes args)
(conditions-map-value-nodes f args))
(distinct-by #(select-keys (meta %) [:row :col]))))
:when (and (hooks/token-node? value)
(symbol? (hooks/sexpr value))
(not (marked? value)))]
@@ -141,6 +331,5 @@
(db-namespace? (modules/config input) ns)
(not (test-file? filename))
(read-call? node))
(lint-unmarked-values! node)
(lint-kv-args! node))
(lint-unmarked-values! node))
input)

View File

@@ -0,0 +1,82 @@
(ns hooks.clojure.core.def-test
(:require
[clj-kondo.core :as kondo]
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.test :refer :all]
[hooks.clojure.core.def]))
(defn- lint-def
"Lint the def form in string `s`. A string, not a quoted form, because `pr-str` drops `^:dynamic`."
[s]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/discourage-dynamic-vars {:level :warning}}}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(hooks.clojure.core.def/lint-def {:node (hooks/parse-string s)})
@(:findings clj-kondo.impl.utils/*ctx*)))
(defn- config-findings
"Lint string `s` as `lang` with the real `.clj-kondo` config and return the findings whose type is in `types`."
[types lang s]
(->> (with-in-str s (kondo/run! {:lint ["-"] :lang lang :config-dir ".clj-kondo" :cache false}))
:findings
(filter (comp types :type))))
(deftest ^:parallel discourage-dynamic-vars-test
(testing "BEGUILD-37: new dynamic vars are flagged so the ratchet budget stops them from growing"
(are [s] (=? [{:type :metabase/discourage-dynamic-vars}]
(lint-def s))
"(def ^:dynamic *x* nil)"
"(def ^:private ^:dynamic *x* nil)"
"(def ^:dynamic ^:private *x* nil)"
"(def ^{:dynamic true} *x* nil)"
"(def ^{:private true, :dynamic true} *x* nil)"
"(def ^:dynamic *x* \"docstring\" nil)"
"(defonce ^:dynamic *x* nil)"
"(defn f {:dynamic true} [] nil)"
"(defn f \"docstring\" {:dynamic true} [] nil)"
"(defn f \"docstring\" ([] 1) ([a] a) {:dynamic true})"
"(mu/defn f :- :int \"docstring\" {:dynamic true} [] 1)")))
(deftest ^:parallel discourage-dynamic-vars-location-test
(testing "the finding sits on the form, so `kondo-insert-ignores` puts the ignore above the form, not inside it"
(is (=? [{:row 1 :col 1}]
(lint-def "(def ^{:dynamic true\n :doc \"docs\"}\n *x*\n nil)")))))
(deftest ^:parallel discourage-dynamic-vars-ok-test
(testing "non-dynamic defs are not flagged"
(are [s] (= [] (lint-def s))
"(def x nil)"
"(def ^:private x nil)"
"(def ^{:dynamic false} x nil)"
"(def ^{:private true} x nil)"
"(def x {:dynamic true})"
"(defonce x {:dynamic true})"
"(clojure.core/def x {:dynamic true})"
"(clojure.core/defonce x {:dynamic true})"
"(cljs.core/defonce x {:dynamic true})"
"(defn f {:private true} [] nil)"
"(defn f [] {:dynamic true})"
"(defn f ([] 1) ([a] {:dynamic true}))")))
(deftest ^:synchronized discourage-dynamic-vars-config-test
(testing "BEGUILD-37: config.edn sends defn, the mu/defn variants, and cljs defs to the dynamic-var check"
(are [lang s] (=? [{:row 1}] (config-findings #{:metabase/discourage-dynamic-vars} lang s))
:clj "(ns x) (defn ^:dynamic *f* [] nil)"
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn ^:dynamic *f* :- :int [] 1)"
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn- ^:dynamic *f* :- :int [] 1)"
:clj "(ns x (:require [metabase.util.malli.defn :as mu.defn])) (mu.defn/defn ^:dynamic *f* :- :int [] 1)"
:clj "(ns x (:require [metabase.util.malli.defn :as mu.defn])) (mu.defn/defn- ^:dynamic *f* :- :int [] 1)"
:cljs "(ns x) (def ^:dynamic *x* 1)"
:cljs "(ns x) (defn ^:dynamic *f* [] nil)"
:cljs "(ns x) (defn- ^:dynamic *f* [] nil)"
:cljs "(ns x) (defonce ^:dynamic *x* 1)"
:cljc "(ns x) #?(:cljs (def ^:dynamic *x* 1))")))
(deftest ^:synchronized discourage-dynamic-vars-naming-checks-test
(testing "lint-dynamic runs only the dynamic check, not the def naming checks"
(are [lang s] (= [] (config-findings #{:metabase/check-def-no-underscores :metabase/check-def-check-not-uppercase-name}
lang s))
:clj "(ns x (:require [metabase.util.malli :as mu])) (mu/defn SOME_FN :- :string [x :- :int] (str x))"
:cljs "(ns x) (def SOME_VALUE 1)")))

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,25 @@
(ns hooks.metabase.app-db.schema-migrations-test.impl-test
(:require
[clj-kondo.hooks-api :as hooks]
[clj-kondo.impl.utils]
[clojure.test :refer :all]
[hooks.metabase.app-db.schema-migrations-test.impl]))
(defn- finding-types!
[ns-symb]
(binding [clj-kondo.impl.utils/*ctx* {:config {:linters {:metabase/validate-mb-app-db-migrations-test {:level :warning}}}
:ignores (atom nil)
:findings (atom [])
:namespaces (atom {})}]
(hooks.metabase.app-db.schema-migrations-test.impl/test-migrations!
{:ns ns-symb
:node (hooks/parse-string (pr-str '(impl/test-migrations ["v64.2026-09-01T00:00:00"] [migrate!] (migrate!))))})
(mapv :type @(:findings clj-kondo.impl.utils/*ctx*))))
(deftest ^:synchronized require-app-db-migrations-test-tag-test
(testing "a namespace that runs migration tests must be tagged, so CI shards it with the other migration tests"
(is (= [:metabase/validate-mb-app-db-migrations-test]
(finding-types! 'metabase.some-migrations-test))))
(testing "a tagged namespace is fine"
(is (= []
(finding-types! (with-meta 'metabase.some-migrations-test {:mb/app-db-migrations-test true}))))))

View File

@@ -103,24 +103,112 @@
(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.*"}]
(deftest ^:parallel kv-arg-style-is-not-flagged-test
(testing "call style is not linted: a query written as :column value pairs is left alone"
;; Extraction to a .sql file is semantic rather than a transcription of the HoneySQL shape, so a
;; query map is no closer to that target than kv-args are. Converting also skips Toucan's type
;; transforms, which only run in kv-arg position -- that bug shipped twice.
(are [form] (empty? (filter #(re-find #"Pass this query a map" (str (:message %)))
(lint-query-call form 'metabase.foo.db)))
'(t2/select :model/X :locale [:auto/param locale])
'(t2/select :model/X :archived false)
'(t2/select-one :model/X :id (long id))
'(t2/select-one-fn :value :model/Setting :key [:auto/param k])
'(t2/select :model/X {:where [:= :locale [:auto/param locale]]})))
(testing "an unmarked value in a kv-arg is still flagged -- only the style check went away"
(is (=? [{:type :metabase/unsafe-app-db-query, :message #".*reaches a SQL value slot unmarked.*"}]
(lint-query-call '(t2/select :model/X :locale locale) 'metabase.foo.db)))))
(deftest ^:parallel conditions-map-values-are-linted-test
(testing "a conditions map -- a shape the sweep leaves in place -- is checked"
;; `(t2/update! model {:col v} changes)` filters on v, but it is not a `:where` clause, so
;; neither the query-map walker nor the kv-arg walker used to see it. A namespace written this
;; way passed the lint vacuously.
(are [form] (=? [{:type :metabase/unsafe-app-db-query
:message #".*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/update! :model/X {:key k} {:v 1})
'(t2/delete! :model/X {:key k})
'(t2/update! :model/X {:key [:in ks]} {:v 1})))
(testing "a marked or coerced conditions value is not flagged"
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/update! :model/X {:key [:auto/param k]} {:v 1})
'(t2/update! :model/X {:id (long id)} {:v 1})))
(testing "only the map right after the model is conditions -- the changes map is exempt"
;; rule 1: a written value must arrive as itself, so it is never flagged.
(is (empty? (lint-query-call '(t2/update! :model/X {:id (long id)} {:v written})
'metabase.foo.db)))))
(deftest ^:parallel subquery-values-are-linted-test
(testing "a value inside a subquery in a value slot is reached"
(is (=? [{:type :metabase/unsafe-app-db-query
:message #".*`z`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call
'(t2/select :model/X {:where [:in :id {:select [:id] :from [:y] :where [:= :z z]}]})
'metabase.foo.db))))
(testing "the plain token case still works -- descending must not replace it"
(is (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call '(t2/select :model/X {:where [:= :k v]}) 'metabase.foo.db)))))
(deftest ^:parallel conditionally-built-clause-test
(testing "a value slot inside a conditional form is still reached"
;; `value-nodes` used to stop at a list node, so anything a `when`/`if`/`cond->` built was
;; invisible -- a false negative in a security lint. permissions/db.clj and collections/db.clj
;; both build clauses this way.
(are [form] (=? [{:type :metabase/unsafe-app-db-query
:message #".*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/select :model/X {:where [:and (when k [:= :key k])]})))
(testing "both branches of an if are reached"
(is (= 2 (count (lint-query-call
'(t2/select :model/X {:where [:and (if flag [:= :key k] [:= :key other-k])]})
'metabase.foo.db)))))
(testing "a marked value inside a conditional form is not flagged"
(is (empty? (lint-query-call
'(t2/select :model/X {:where [:and (when k [:= :key [:auto/param k]])]})
'metabase.foo.db)))))
(deftest ^:parallel kv-arg-values-are-linted-test
(testing "an unmarked value in a kv-arg pair is flagged, so dropping the style check left no gap"
(are [form] (=? [{:type :metabase/unsafe-app-db-query
:message #".*reaches a SQL value slot unmarked.*"}]
(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"
'(t2/select-one :model/X :id id)
;; the pairs do not start at a fixed offset -- :value comes before the model here
'(t2/select-one-fn :value :model/Setting :key k)
;; a value inside an operator form is reached, rather than the form being treated as a value
'(t2/select :model/X :id [:in ids])))
(testing "GHY-4668: a model written with columns still has its kv-arg values checked"
;; The walker found the model by looking for a `:model/...` keyword, so `[:model/X :col]` hid
;; every pair after it.
(are [form] (=? [{:type :metabase/unsafe-app-db-query
:message #".*`prefix`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/select [:model/X :id :public_uuid] :public_uuid_prefix prefix :archived false)
'(t2/select-one [:model/X :id] :public_uuid_prefix prefix)))
(testing "GHY-4668: a marked value after a model written with columns is not flagged"
(is (empty? (lint-query-call '(t2/select [:model/X :id] :public_uuid_prefix [:auto/param prefix])
'metabase.foo.db))))
(testing "a marked or coerced kv-arg value 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"}))))
'(t2/select :model/X :locale [:auto/param locale])
'(t2/select-one :model/X :id (long id))
'(t2/select :model/X :id [:in (mapv long ids)])
'(t2/select :model/X :id [:in [:auto/param ids]])))
(testing "a literal kv-arg value is not flagged"
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/select :model/X :archived false)
'(t2/select :model/X :status "pending")
'(t2/select :model/X :engine :appdb)))
(testing "one unmarked value per pair is reported"
(is (= 2 (count (lint-query-call '(t2/select :model/X :locale locale :msgid msgid)
'metabase.foo.db)))))
(testing "a call mixing kv-args with a trailing query map reports each value exactly once"
;; The two walkers run over the same arguments, so this pins that they do not double-report.
(is (= 2 (count (lint-query-call
'(t2/select :model/X :locale locale {:where [:= :other other]})
'metabase.foo.db))))))
(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"
@@ -136,3 +224,112 @@
(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)))))
(deftest ^:parallel operator-coverage-test
(testing "comparison operators HoneySQL accepts as aliases for not= are checked"
;; The set used to list only `not=`, so `[:!= :col v]` -- live in collections/db.clj -- was
;; silently unchecked even though HoneySQL binds it as `<>`.
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/select :model/X {:where [:!= :k v]})
'(t2/select :model/X {:where [:<> :k v]})
'(t2/select :model/X {:where [:is-distinct-from :k v]})))
(testing "a two-value operator reports both values"
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:not-between :k lo hi]})
'metabase.foo.db)))))
(testing "a value wrapped in a function-call form is reached"
;; `[:lower v]` compiles to `LOWER(?)`, so `v` is a real bind slot.
(are [form] (seq (lint-query-call form 'metabase.foo.db))
'(t2/select :model/X {:where [:= :k [:lower v]]})
'(t2/select :model/X {:where [:= :k [:cast v :text]]})
'(t2/select :model/X :k [:lower v])))
(testing "values in a literal collection are reached"
;; `[:in :k [a b]]` binds each element -- `IN (?, ?)`.
(is (= 2 (count (lint-query-call '(t2/select :model/X {:where [:in :k [a b]]})
'metabase.foo.db))))))
(deftest ^:parallel no-duplicate-findings-test
(testing "a multi-arg operator in a kv-arg reports each value exactly once"
;; value-nodes misreads `[:between lo hi]` as a clause (lo as column), while
;; kv-arg-value-nodes reads it correctly, so the walkers overlap on `hi`. Deduping by source
;; position makes that harmless.
(is (= 2 (count (lint-query-call '(t2/select :model/X :d [:between lo hi]) 'metabase.foo.db))))
(is (= 2 (count (lint-query-call '(t2/delete! :model/X {:d [:between lo hi]})
'metabase.foo.db)))))
(testing "a query map passed where conditions go reports each value once"
;; `{:where ...}` is a query map that value-nodes already walks; treating it as conditions too
;; reported everything twice. Live in mcp/db.clj, metabot/db.clj, testing_api/db.clj.
(is (= 1 (count (lint-query-call '(t2/delete! :model/X {:where [:= :k k]}) 'metabase.foo.db))))
(is (= 2 (count (lint-query-call '(t2/update! :model/X {:where [:and [:= :a a] [:= :b b]]} {:v 1})
'metabase.foo.db))))))
(deftest ^:parallel two-arity-update-changes-map-test
(testing "update!'s lone trailing map is CHANGES, not conditions, so its values are not flagged"
;; Toucan's arglist ends in the changes map. remote_sync/db.clj's mark-all-rsos-synced! is this
;; shape; flagging `written` would ask for a marker on a written value, which the column's
;; `:in` transform stores as data.
(is (empty? (lint-query-call '(t2/update! :model/X {:v written}) 'metabase.foo.db))))
(testing "delete! has no changes map, so its lone map is always conditions"
(is (=? [{:message #"`k`.*"}]
(lint-query-call '(t2/delete! :model/X {:k k}) 'metabase.foo.db))))
(testing "update!'s FIRST map is still conditions when a changes map follows"
(is (=? [{:message #"`k`.*"}]
(lint-query-call '(t2/update! :model/X {:k k} {:v 1}) 'metabase.foo.db)))))
(deftest ^:parallel identifier-clauses-are-not-value-slots-test
(testing "a symbol naming a column or a table is not reported"
;; `value-guard` refuses a marker in these clauses, so reporting one asks for a fix that throws
;; at compile. data_studio/db.clj, task_history/db.clj and permissions/db.clj are written this
;; way. `:select` is the exception: index 0 of its entries is an expression, so it has its own
;; test in expression-entry-clauses-test.
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/query {:select [:*] :from [[union-query :subquery]]})
'(t2/query {:select [:*] :from [:t] :order-by [[:sort_key sort-direction]]})
'(t2/query {:select [:*] :from [:t] :group-by [group-column]})))
(testing "a value nested inside an identifier clause is still reported"
(are [form] (=? [{:message #"`v`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
;; a computed projection is a real comparison
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
;; so is a CASE sort key
'(t2/query {:select [:*] :from [:t] :order-by [[[:case [:= :a v] 1 :else 2] :asc]]})
;; a subquery carries its own where clause
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
(deftest ^:parallel expression-entry-clauses-test
(testing "index 0 of an entry is a value slot, because HoneySQL binds a parameter there"
;; `{:select [[[:auto/param "a"] :k]]}` compiles to `SELECT ? AS "K"`, so `value-guard` accepts
;; a marker there. Treating the whole clause as names would hide a request value.
(are [form] (=? [{:type :metabase/unsafe-app-db-query
:message #"`v`.*reaches a SQL value slot unmarked.*"}]
(lint-query-call form 'metabase.foo.db))
'(t2/query {:select [[v :label]] :from [:t]})
'(t2/query {:select-distinct [[v :label]] :from [:t]})
'(t2/query {:insert-into [:t] :returning [[v :label]]})))
(testing "the alias, a bare entry and a table entry are names"
;; `value-guard` refuses a marker in each of these, so asking for one would break the query.
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/query {:select [[:key alias-sym]] :from [:t]})
'(t2/query {:select [cols] :from [:t]})
'(t2/query {:select [:*] :from [[tbl :x]]})))
(testing "a computed projection and a subquery entry still reach their values"
(are [form] (=? [{:message #"`v`.*"}] (lint-query-call form 'metabase.foo.db))
'(t2/query {:select [[[:= :engine v] :is_match]] :from [:t]})
'(t2/query {:select [:*] :from [[{:select [:*] :from [:u] :where [:= :k v]} :s]]}))))
(deftest ^:parallel clause-context-survives-assoc-test
(testing "a clause key in an assoc call gives its value the clause context back"
;; task_history/db.clj:174 and five other namespaces build `:order-by` this way. Without this
;; the lint asks for a marker on a column name and on a sort direction, and the fix the message
;; names loses the ordering with no error.
(are [form] (empty? (lint-query-call form 'metabase.foo.db))
'(t2/query (assoc q :order-by [[sort-column sort-direction] [:id :desc]]))
'(t2/query (cond-> q flag (assoc :order-by [[sort-column sort-direction]])))
'(t2/query (assoc q :group-by [group-column]))
'(t2/query (assoc q :select [[:key alias-sym]]))))
(testing "a value clause built the same way is still reported"
(is (=? [{:message #"`v`.*"}]
(lint-query-call '(t2/query (assoc q :where [:= :k v])) 'metabase.foo.db))))
(testing "an expression entry built the same way is still reported"
(is (=? [{:message #"`v`.*"}]
(lint-query-call '(t2/query (assoc q :select [[v :label]])) 'metabase.foo.db)))))

View File

@@ -121,6 +121,9 @@ MB_EDITION="ee"
# Frontend dev server port (for hot reload). Must match the port used by `yarn build-hot`.
# MB_FRONTEND_DEV_PORT=8080
# Frontend dev server bind address. Defaults to 127.0.0.1; set 0.0.0.0 to reach it from other devices.
# MB_FRONTEND_DEV_HOST=127.0.0.1
# 3.2 E2E Local Runner Setup
# ------------------------------------------------------------------------------
@@ -161,3 +164,46 @@ TZ="US/Pacific"
# DO NOT USE Docker Desktop. Use Orbstack instead. Metabase will fail to connect
# to the dockerized Mongo QA database otherwise.
EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU=1
# ==============================================================================
# 4. BACKEND DRIVER TESTS
# ==============================================================================
#
# Connection details for running backend tests against a driver, e.g.
# ./bin/test-agent --drivers=snowflake :only '[metabase.foo-test]'
# 4.1 BigQuery
# ------------------------------------------------------------------------------
# Service account with BigQuery access in the project. The test user creates and drops the test-data datasets itself.
# https://cloud.google.com/iam/docs/keys-create-delete
# MB_BIGQUERY_CLOUD_SDK_TEST_PROJECT_ID="<project-id>"
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON='{"type": "service_account", ...}'
# Only for database routing tests
# MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING='{"type": "service_account", ...}'
# 4.2 Redshift
# ------------------------------------------------------------------------------
# Test data is loaded into a unique schema per test run.
# MB_REDSHIFT_TEST_HOST="<cluster>.<id>.<region>.redshift.amazonaws.com"
# Comma-separated hosts to pick one at random from, instead of MB_REDSHIFT_TEST_HOST
# MB_REDSHIFT_TEST_HOSTS=""
# MB_REDSHIFT_TEST_PORT=5439
# MB_REDSHIFT_TEST_DB="testdb"
# MB_REDSHIFT_TEST_USER="metabase_ci"
# MB_REDSHIFT_TEST_PASSWORD="<password>"
# Only for database routing tests
# MB_REDSHIFT_TEST_DB_ROUTING="dev"
# 4.3 Snowflake
# ------------------------------------------------------------------------------
# Key-pair authentication. The test user creates and drops the test-data databases itself.
# https://docs.snowflake.com/en/user-guide/key-pair-auth
# MB_SNOWFLAKE_TEST_ACCOUNT="<orgname>-<accountname>"
# MB_SNOWFLAKE_TEST_USER="<username>"
# MB_SNOWFLAKE_TEST_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# MB_SNOWFLAKE_TEST_WAREHOUSE="<warehouse>"
# MB_SNOWFLAKE_TEST_ADDITIONAL_OPTIONS=""

27
.github/CODEOWNERS vendored
View File

@@ -43,12 +43,20 @@ 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/ai/mcp-tools.md # auto-generated
docs/embedding/sdk/api # auto-generated
docs/embedding/eajs/snippets # auto-generated
# @metabase/devexp
.github @metabase/devexp
# These files are left unowned on purpose.
# They change in most backend PRs, so an owning team would review many changes for little benefit.
# Ratchet budgets may split per module in future, which would give each module's team its own file.
# .clj-kondo/ratchets.edn @metabase/devexp
# .clj-kondo/ratchets-test.edn @metabase/devexp
# .clj-kondo/config.edn @metabase/devexp
# @metabase/cloud-ops
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
@@ -56,6 +64,25 @@ src/metabase/analytics/prometheus.clj @metabase/cloud-ops
enterprise/frontend/src/metabase-enterprise/metabot @metabase/metabot-frontend
frontend/src/metabase/metabot @metabase/metabot-frontend
# @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/embeddings @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/entity_retrieval @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/search @metabase/metabot-backend
enterprise/backend/src/metabase_enterprise/semantic_search @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/embeddings @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/entity_retrieval @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/search @metabase/metabot-backend
enterprise/backend/test/metabase_enterprise/semantic_search @metabase/metabot-backend
frontend/src/metabase-types/api/search.ts @metabase/metabot-backend
frontend/src/metabase/api/search.ts @metabase/metabot-backend
modules/embedder @metabase/metabot-backend
src/metabase/embeddings @metabase/metabot-backend
src/metabase/entity_retrieval @metabase/metabot-backend
src/metabase/search @metabase/metabot-backend
test/metabase/embeddings @metabase/metabot-backend
test/metabase/entity_retrieval @metabase/metabot-backend
test/metabase/search @metabase/metabot-backend
# @metabase/core-frontend-gadget
bin/custom-viz @metabase/core-frontend-gadget
docs/developers-guide/custom-visualizations.md @metabase/core-frontend-gadget

View File

@@ -22,6 +22,9 @@ runs:
# `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.
#
# `-r --include` rather than a `target/junit/*.xml` glob: a multi-JVM run nests one directory
# per partition under `target/junit`, and a non-recursive glob matches none of it.
- name: Summarize test wall times
if: ${{ inputs.profiled == 'true' }}
continue-on-error: true
@@ -30,13 +33,13 @@ runs:
{
echo "### Slowest test namespaces (seconds)"
echo '```'
grep -h '<testsuite' target/junit/*.xml 2>/dev/null \
grep -rh --include='*.xml' '<testsuite' target/junit 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 \
grep -rh --include='*.xml' '<testcase' target/junit 2>/dev/null \
| sed -E 's|.*classname="([^"]*)" name="([^"]*)" time="([^"]*)".*|\3 \1/\2|' \
| sort -rn | awk 'NR<=25' || true
echo '```'
@@ -53,9 +56,12 @@ runs:
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.artifact-name }}
# Globs, because a multi-JVM run nests its junit XML one directory per partition and
# names each log `test-log-p<index>.json`. The flamegraphs stay flat, with that same
# `p<index>-` prefix.
path: |
target/clj-async-profiler/results/*flamegraph.html
target/junit/*.xml
logs/test-log.json
target/junit/**/*.xml
logs/test-log*.json
retention-days: 14
if-no-files-found: warn

View File

@@ -85,17 +85,27 @@ runs:
git checkout ${{ github.sha }}
- name: Approve and Merge the PR
# Approving takes a different token than the one that opened the PR: GitHub won't let a token
# approve its own pull request.
- name: Approve the backport PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: |
gh pr review --approve "$PR_NUMBER"
gh pr merge --squash --auto "$PR_NUMBER"
run: gh pr review --approve "$PR_NUMBER"
shell: bash
env:
# 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 }}
# Merging, though, has to use `github-token` rather than the `GITHUB_TOKEN` that approves: GitHub
# starts no workflow run for a push made with `GITHUB_TOKEN`, so an auto-merge enabled with it
# lands the backport on the release branch with no CI at all.
- name: Merge the backport PR
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'false' }}
run: gh pr merge --squash --auto "$PR_NUMBER"
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ steps.create_backport_pull_request.outputs.backport_pr_number }}
- uses: ./.github/actions/notify-pull-request
if: ${{ steps.create_backport_pull_request.outputs.has-conflicts == 'true' }}
with:

View File

@@ -34,6 +34,7 @@ disabled_ratchets=';; Kondo ignore ratchets apply only to master; this release b
# 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
# active ratchets test ratchets only none disable only the test ratchets file
# 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
@@ -155,29 +156,52 @@ test_mixed_conflict_resolves_only_ratchets() (
assert_cherry_pick_pending
)
test_test_ratchets_only_backport_disables_only_that_file() (
local repo="$test_root/test-ratchet-only"
init_repo "$repo"
local commit
commit=$(feature_commit "$repo" change_test_ratchets)
git -C "$repo" checkout -q release
cd "$repo"
cherry_pick_backport "$commit"
assert_ratchets "$active_ratchets"
assert_test_ratchets_disabled
assert_cherry_pick_completed
assert_worktree_clean
)
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
write_disabled_ratchets .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets-test.edn
git add --sparse .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.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"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/release-cut-test.edn"
local backport_repo="$test_root/release-cut-backport"
init_repo "$backport_repo"
local commit
commit=$(feature_commit "$backport_repo" change_ratchets)
commit=$(feature_commit "$backport_repo" change_both_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"
git show HEAD:.clj-kondo/ratchets-test.edn > "$test_root/backport-test.edn"
cmp -s "$test_root/release-cut.edn" "$test_root/backport.edn" ||
fail "the release cut and the backport committed different opt-out files"
fail "the release cut and the backport committed different prod opt-out files"
cmp -s "$test_root/release-cut-test.edn" "$test_root/backport-test.edn" ||
fail "the release cut and the backport committed different test opt-out files"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut.edn")" \
"the release cut did not install the expected opt-out"
"the release cut did not install the expected prod opt-out"
assert_eq "$disabled_ratchets" "$(cat "$test_root/release-cut-test.edn")" \
"the release cut did not install the expected test opt-out"
)
test_cut_release_workflow_uses_the_shared_writer() (
@@ -253,6 +277,14 @@ assert_ratchets_disabled() {
assert_ratchets "$disabled_ratchets"
}
assert_test_ratchets() {
assert_eq "$1" "$(cat .clj-kondo/ratchets-test.edn)" "unexpected test-ratchet state"
}
assert_test_ratchets_disabled() {
assert_test_ratchets "$disabled_ratchets"
}
assert_cherry_pick_completed() {
! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null || fail "the cherry-pick is still pending"
}
@@ -285,6 +317,7 @@ init_repo() {
printf 'base\n' > "$repo/app.txt"
mkdir -p "$repo/.clj-kondo"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets.edn"
printf '%s\n' "$active_ratchets" > "$repo/.clj-kondo/ratchets-test.edn"
git -C "$repo" add .
git -C "$repo" commit -qm "Base"
}
@@ -317,6 +350,15 @@ change_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets.edn"
}
change_test_ratchets() {
printf '{:ignore-counts {:example 2}}\n' > "$1/.clj-kondo/ratchets-test.edn"
}
change_both_ratchets() {
change_ratchets "$1"
change_test_ratchets "$1"
}
change_app_and_ratchets() {
change_app "$1"
change_ratchets "$1"
@@ -335,9 +377,9 @@ change_release_app_and_ratchets() {
change_release_ratchets "$1"
}
# Setup writes the opt-out through the shared writer; the only copy is the expected text at the top.
# Setup writes the opt-out through the shared writer; the only copies are the expected text at the top.
disable_release_ratchets() {
(cd "$1" && write_disabled_ratchets)
(cd "$1" && write_disabled_ratchets .clj-kondo/ratchets.edn && write_disabled_ratchets .clj-kondo/ratchets-test.edn)
}
commit_release_change() {
@@ -369,6 +411,7 @@ 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 "test ratchets only backport disables only that file"
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"

View File

@@ -5,22 +5,28 @@ 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
local file=$1
mkdir -p "$(dirname "$file")"
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
> "$file"
}
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
local file
local disabled_any=
for file in .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn; do
[ -n "$(git diff-tree --root --no-commit-id --name-only -r "$commit" -- "$file")" ] || continue
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets
git add -- "$file"
echo "$file: disabling kondo ratchets on the release branch"
write_disabled_ratchets "$file"
git add -- "$file"
disabled_any=1
done
[ -n "$disabled_any" ] || return 0
if ! git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null; then
if ! git diff --cached --quiet HEAD; then
@@ -30,7 +36,7 @@ disable_ratchets() {
fi
if [ -z "$(git ls-files -u)" ]; then
echo "$file was the only conflict, finishing the cherry-pick"
echo "disabled ratchet file(s) were 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

View File

@@ -1,33 +1,17 @@
name: Create test plan
name: Create frontend test plan
description: Compute the affected-tests plan for the changed files.
inputs:
changed-files:
description: All changed files (CSV).
required: true
fe-changed-files:
description: Changed frontend_sources files (CSV).
required: true
be-changed-files:
description: Changed backend_sources files (CSV).
required: true
unit-infra-touched:
description: Whether FE unit infra changed.
required: true
loki-infra-touched:
description: Whether Loki infra changed.
required: true
e2e-infra-touched:
description: Whether e2e harness/support code changed.
required: true
shared-sources-touched:
description: Whether cljc/cljs/package.json changed.
required: true
stats-url:
description: Endpoint that stores the affected-tests stats row.
required: false
default: ''
stats-api-key:
description: API key for stats-url.
required: false
default: ''
outputs:
stats:
description: FE Affected Tests stats row (JSON).
value: ${{ steps.compute.outputs.stats }}
artifact-id:
description: Artifact containing test-plan.json for this run.
value: ${{ steps.upload.outputs.artifact-id }}
@@ -35,6 +19,14 @@ outputs:
runs:
using: composite
steps:
# The action reads the diff itself, so a caller hands it no file lists.
- name: Test which files changed
id: changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
token: ${{ github.token }}
list-files: csv
filters: .github/file-paths.yaml
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Resolve closest-ancestor e2e coverage manifest
@@ -59,13 +51,13 @@ runs:
id: compute
shell: bash
env:
CHANGED_FILES: ${{ inputs.changed-files }}
FE_CHANGED_FILES: ${{ inputs.fe-changed-files }}
BE_CHANGED_FILES: ${{ inputs.be-changed-files }}
UNIT_INFRA_TOUCHED: ${{ inputs.unit-infra-touched }}
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
CHANGED_FILES: ${{ steps.changes.outputs.all_changed_files }}
FE_CHANGED_FILES: ${{ steps.changes.outputs.frontend_sources_files }}
BE_CHANGED_FILES: ${{ steps.changes.outputs.backend_sources_files }}
UNIT_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_unit_infra }}
LOKI_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_loki_infra }}
E2E_INFRA_TOUCHED: ${{ steps.changes.outputs.e2e_infra }}
SHARED_SOURCES_TOUCHED: ${{ steps.changes.outputs.shared_sources }}
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
run: |
# stdout is the plan JSON; the planner's own info logs go to stderr.
@@ -83,7 +75,7 @@ runs:
- name: Report test plan
shell: bash
run: |
run: | # sh
plan=test-plan.json
row() { # name, selected, total
if [ "$2" -eq "$3" ]; then echo "| $1 | all ($3) | $3 |"; else echo "| $1 | $2 | $3 |"; fi
@@ -122,3 +114,20 @@ runs:
echo '```'
echo "</details>"
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
# Only land rows for PRs from the upstream repo so forks don't pollute the table. Best-effort:
# a failed upload must not cost us the plan.
- name: Upload affected tests stats
if: >-
${{ inputs.stats-url != ''
&& github.event.pull_request.head.repo.full_name == 'metabase/metabase' }}
continue-on-error: true
shell: bash
env:
STATS_JSON: ${{ steps.compute.outputs.stats }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ENG_STATS_URL: ${{ inputs.stats-url }}
API_KEY: ${{ inputs.stats-api-key }}
run: bun .github/scripts/upload-affected-tests-stats.ts

View File

@@ -14,7 +14,7 @@ runs:
steps:
## First we deploy the base branch
- name: Check out base branch
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.base-ref }}
@@ -45,7 +45,7 @@ runs:
## Then we deploy the PR branch to ensure it migrates up successfully
- name: Check out pull request branch
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.head-ref }}
@@ -84,7 +84,7 @@ runs:
shell: bash
- name: Re-check out base branch
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.base-ref }}

View File

@@ -27,7 +27,7 @@ runs:
# Anything headed for master runs master's own sources, so a PR can test its own changes.
- name: Fetch ci-conductor sources from master
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: metabase/metabase
ref: master

View File

@@ -130,6 +130,7 @@ runs:
if [ "$PROFILE_TESTS" = "true" ]; then
echo "Profiling this run; a flamegraph will be attached to the job."
echo "profiled=true" >> "$GITHUB_OUTPUT"
ALIASES="$ALIASES:profile-tests"
BASE="$BASE:profile-tests"
fi
# An explicit selection wins over the rerun narrowing below: it was asked for by hand, on this
@@ -149,6 +150,15 @@ runs:
shell: bash
id: run-test
env:
# PROFILE_TESTS is not set here: the suites that test.profile-backend.yml dispatches set it in
# their workflow `env`, which reaches this step. The stress-test workflows are dispatched too
# but leave it unset, so they never profile.
#
# ONLY_TESTS comes from test.profile-backend.yml. Inside a called workflow `github.event` is
# the caller's payload, so it resolves here rather than in each calling workflow. On any
# other run it renders empty and the job's own selection stands. It replaces the job's own
# test selection, so every matrix leg of the chosen job runs the whole list.
ONLY_TESTS: ${{ github.event.inputs.tests }}
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}
TEST_JVMS_PER_RUNNER: ${{ inputs.jvms-per-runner }}

View File

@@ -1,5 +1,5 @@
# Catch-all filter — emits the full changed-files list as `all_changed_files`
# so create-test-plan in run-tests.yml can reuse dorny's diff.
# for the create-test-plan action.
all_changed:
- "**"
@@ -23,6 +23,8 @@ shared_ci: &shared_ci
- ".github/actions/prepare-frontend/**"
- ".github/actions/prepare-backend/**"
- ".github/workflows/run-tests.yml"
# Every suite's gate calls it, so a change here can turn any suite off.
- ".github/workflows/test.gate.yml"
shared_e2e: &shared_e2e
- "e2e/runner/**"
@@ -39,6 +41,8 @@ frontend_configs: &frontend_configs
- "package.json"
- "babel.config.json"
- "eslint.config.mjs"
- "oxlint.config.mts"
- ".oxfmtrc.json"
- "postcss.config.js"
- "rspack.config.js"
- "rspack.main.config.js"
@@ -75,6 +79,7 @@ frontend_all: &frontend_all
- *frontend_ci
- *frontend_sources
- *frontend_specs
- "frontend/lint/**/!(*.md)"
frontend_loki_ci: &frontend_loki_ci
- ".github/workflows/loki.yml"
@@ -121,8 +126,11 @@ ci_scripts:
- ".github/file-paths.yaml"
- ".github/scripts/**"
- ".github/actions/**"
- ".github/workflows/e2e-{test,tests,matrix-builder}.yml"
- ".github/workflows/loki.yml"
- ".storybook/story-files.cjs"
- "e2e/runner/read-spec-paths.js"
- "e2e/support/cypress.config.js"
- "frontend/lint/module-boundaries.mjs"
- "frontend/lint/shared-tiers.mjs"
- "frontend/test/jest-test-paths-filter.ts"
@@ -133,12 +141,6 @@ ci_scripts:
- "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
# full e2e run (see create-test-plan / e2eInfraTouched).
e2e_infra: &e2e_infra
- *shared_e2e
backend_ci: &backend_ci
- *shared_ci
- ".github/actions/test-driver/**"
@@ -155,7 +157,7 @@ backend_sources: &backend_sources
# 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)"
- ".clj-kondo/**/!(ratchets.edn|ratchets-test.edn)"
backend_specs: &backend_specs
- *shared_specs
@@ -200,12 +202,14 @@ project_backend_checks:
- "{resources,test_resources,test_config}/**/*.{clj,cljc,cljs}"
- ".clj-kondo/config.edn"
- ".clj-kondo/config/modules/config.edn"
- ".clj-kondo/config/modules/cycle-clusters.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/ratchets-test.edn"
- ".clj-kondo/config/modules/ratchets.edn"
project_migration_checks:
@@ -244,14 +248,14 @@ embedding_sdk_ci: &embedding_sdk_ci
- ".github/actions/prepare-cypress/**"
- ".github/workflows/embedding-sdk*.yml"
embedding_sdk_components:
embedding_sdk_components: &embedding_sdk_components
- *default
- *embedding_sdk_ci
- *sources
- *shared_e2e
- "e2e/test-component/**/*.cy.*.{js,jsx,ts,tsx}"
embedding_sdk_host_sample_apps:
embedding_sdk_host_sample_apps: &embedding_sdk_host_sample_apps
- *default
- *embedding_sdk_ci
- *frontend_sources
@@ -266,13 +270,29 @@ snowplow: &snowplow
- "bin/lint-snowplow-schemas.bb"
- "bin/lint-snowplow-schemas-test.bb"
e2e_all:
# 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
# full e2e run (see create-test-plan / e2eInfraTouched).
e2e_infra: &e2e_infra
- *default
- *e2e_ci
- *frontend_configs
- *shared_e2e
- *snowplow
- "e2e/**/!(*.cy.spec.@(js|jsx|ts|tsx))"
- "frontend/build/**"
- "patches/**"
- ".github/scripts/build-e2e-matrix.js"
- ".github/file-paths.yaml"
e2e_all: &e2e_all
- *default
- *e2e_ci
- *sources
- *e2e_specs
- "e2e/**"
- *snowplow
- *e2e_infra
documentation:
- "docs/**"
@@ -281,10 +301,28 @@ documentation:
docs:
- "docs/**"
embedding_documentation:
embedding_documentation: &embedding_documentation
- ".typedoc/**"
- "docs/embedding/**"
# Everything the SDK package build feeds, so the build itself follows the union of its consumers.
build_embedding_sdk_package:
- *embedding_sdk_ci
- *embedding_sdk_components
- *embedding_sdk_host_sample_apps
- *embedding_documentation
- *frontend_sources
# The uberjar only exists to feed e2e, the SDK suites that boot a Metabase instance, bundle-size and
# containerize, so it is worth building exactly when one of them might run. The SDK's type checks run
# against the package artifact alone, so embedding docs do not belong here.
build_uberjar:
- *backend_all
- *frontend_all
- *e2e_all
- *embedding_sdk_components
- *embedding_sdk_host_sample_apps
yaml:
- "**/*.yml"
- "**/*.yaml"
@@ -345,8 +383,10 @@ openapi:
- "src/metabase/api/**"
- "src/metabase/api_routes/**"
- "src/metabase/*_rest/api.clj"
- "src/metabase/*/api.clj"
- "src/metabase/*/api/*.clj"
- "src/metabase/**/rest/api.clj"
- "src/metabase/**/rest/api/*.clj"
- "src/metabase/**/api.clj"
- "src/metabase/**/api/*.clj"
- "enterprise/backend/src/metabase_enterprise/**/api.clj"
- "enterprise/backend/src/metabase_enterprise/**/api/*.clj"
- "resources/openapi/**"

View File

@@ -2,9 +2,10 @@
// its aim is to split the e2e test matrix into multiple jobs
// grouping some specific tests together, other tests are split into chunks
const SPECS_PER_JOB = 5;
const DEFAULT_SPEC_PATTERN = "./e2e/test/scenarios/**/*.cy.spec.*";
const specialTestConfigs = [
const taggedJobs = [
{
name: "oss-subset",
edition: "oss",
@@ -15,69 +16,38 @@ const specialTestConfigs = [
{ name: "python", tags: "@python", specs: DEFAULT_SPEC_PATTERN },
];
/**
*
* @param {*} inputSpecs - specs, which were changed in the PR, separated by comma
* .e.g e2e/test/scenarios/onboarding/command-palette.cy.spec.js,e2e/test/scenarios/question/document-title.cy.spec.js
* or a pattern like DEFAULT_SPEC_PATTERN
*/
function buildMatrix(options, inputSpecs, inputChunks) {
function buildMatrix(options, specFiles, maxJobs) {
const { java, defaultRunner } = options;
// number of specs per chunk when running specific specs
const SPECS_PER_CHUNK = 5;
const defaultOptions = {
"java-version": java,
runner: defaultRunner,
edition: "ee",
};
const isDefaultSpecPattern =
inputSpecs === "" || inputSpecs === DEFAULT_SPEC_PATTERN;
console.log("Processed specs value:", inputSpecs);
console.log("Is default pattern:", isDefaultSpecPattern);
let regularChunks;
if (isDefaultSpecPattern) {
regularChunks = inputChunks - specialTestConfigs.length;
} else {
// when pattern is not default, it means we passed some custom list of the changed specs
// so we need to calculate how many chunks we need to run
const matchingSpecsCount = inputSpecs.split(",").length;
regularChunks = Math.max(
1,
Math.ceil(matchingSpecsCount / SPECS_PER_CHUNK),
if (!Number.isInteger(maxJobs) || maxJobs <= taggedJobs.length) {
throw new Error(
`E2E job limit must be an integer greater than ${taggedJobs.length} to allow at least one regular job alongside the tagged jobs`,
);
}
const regularTests = Array.from({ length: regularChunks }, (_, index) => {
const paddedIndex = String(index + 1).padStart(2, "0");
if (specFiles?.length === 0) {
return { config: [], regularChunks: 0 };
}
return {
name: `e2e-group-${paddedIndex}`,
// works when specs less than 5, otherwise seems all chunks will contain
// same specs
...(!isDefaultSpecPattern && {
specs: inputSpecs
.split(",")
.slice(SPECS_PER_CHUNK * index, SPECS_PER_CHUNK * (index + 1))
.join(","),
}),
};
});
const maxRegularJobs = maxJobs - taggedJobs.length;
let regularChunks = maxRegularJobs;
if (specFiles !== null) {
const jobsForSelectedSpecs = Math.ceil(specFiles.length / SPECS_PER_JOB);
regularChunks = Math.min(maxRegularJobs, jobsForSelectedSpecs);
}
const testSets = isDefaultSpecPattern
? regularTests.concat(specialTestConfigs)
: regularTests;
const config = testSets.map((options) => ({
...defaultOptions,
...options,
const regularJobs = Array.from({ length: regularChunks }, (_, index) => ({
name: `e2e-group-${String(index + 1).padStart(2, "0")}`,
}));
return { config, regularChunks, isDefaultSpecPattern };
// Regular jobs exclude the OSS, Mongo, and Python tags, so these jobs must also run for selected specs.
const config = [...regularJobs, ...taggedJobs].map((test) => ({
"java-version": java,
runner: defaultRunner,
edition: "ee",
...test,
}));
return { config, regularChunks };
}
module.exports = { buildMatrix };

View File

@@ -1,99 +1,74 @@
const { buildMatrix } = require("./build-e2e-matrix");
const options = { java: 25, defaultRunner: "runner" };
const specs = (count) =>
Array.from({ length: count }, (_, i) => `e2e/test/scenarios/${i}.cy.spec.ts`);
describe("buildMatrix", () => {
const options = {
java: "11",
defaultRunner: "${{ vars.DEFAULT_RUNNER_KEY }}",
};
const testBuildMatrix = (entry, chunks) =>
buildMatrix(options, entry, chunks);
it("should calculate correct regular chunks with default parameters", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 30);
expect(result.regularChunks).toBe(27);
it.each([0, 3, 3.5])("should reject an invalid job budget: %s", (chunks) => {
expect(() => buildMatrix(options, specs(1), chunks)).toThrow();
});
it("should create correct number of configuration objects", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 10);
it("should use the full job budget when there is no plan", () => {
const result = buildMatrix(options, null, 50);
expect(result.config.length).toBe(10);
expect(result.regularChunks).toBe(47);
expect(result.config).toHaveLength(50);
expect(result.config[0]).toMatchObject({
name: "e2e-group-01",
edition: "ee",
runner: "runner",
});
expect(result.config[46].name).toBe("e2e-group-47");
});
it("should apply default specs path when empty entry is provided", () => {
const result = testBuildMatrix("", 6);
expect(result.regularChunks).toBe(3);
expect(result.config.length).toBe(6);
});
it("should name regular test groups sequentially", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 10);
const regularTests = result.config.filter((cfg) =>
cfg.name.startsWith("e2e-group-"),
);
regularTests.forEach((test, index) => {
expect(test.name).toBe(
`e2e-group-${String(index + 1).padStart(2, "0")}`,
);
it("should create no jobs for an empty plan", () => {
expect(buildMatrix(options, [], 50)).toEqual({
config: [],
regularChunks: 0,
});
});
it("should handle case when input chunks equals special tests count", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 3);
it.each([
[1, 1],
[5, 1],
[6, 2],
[12, 3],
[424, 47],
])(
"should allocate %i selected specs to %i regular jobs",
(count, regularChunks) => {
const result = buildMatrix(options, specs(count), 50);
expect(result.regularChunks).toBe(0);
expect(result.config.length).toBe(3);
expect(result.config[0].name).toBe("oss-subset");
expect(result.config[1].name).toBe("mongo");
expect(result.config[2].name).toBe("python");
});
expect(result.regularChunks).toBe(regularChunks);
expect(result.config).toHaveLength(regularChunks + 3);
},
);
it("should correctly calculate regularChunks for non-default spec pattern", () => {
const entry = "file.cy.spec.js,".repeat(12).slice(0, -1);
const result = testBuildMatrix(entry, 10);
it.each([null, specs(1), specs(424)])(
"should retain every tagged variant",
(plan) => {
const result = buildMatrix(options, plan, 50);
// For 12 matching specs with SPECS_PER_CHUNK=5, we expect ceil(12/5) = 3 chunks
expect(result.regularChunks).toBe(3);
expect(result.config.slice(-3)).toMatchObject([
{ name: "oss-subset", edition: "oss", tags: "@OSS @prerelease+-@EE" },
{ name: "mongo", edition: "ee", tags: "@mongo" },
{ name: "python", edition: "ee", tags: "@python" },
]);
},
);
const firstGroupSpecs = result.config[0].specs.split(",").length;
const secondGroupSpecs = result.config[1].specs.split(",").length;
const thirdGroupSpecs = result.config[2].specs.split(",").length;
it("should leave regular jobs to cypress-split", () => {
const result = buildMatrix(options, specs(12), 50);
expect(firstGroupSpecs).toBe(5);
expect(secondGroupSpecs).toBe(5);
expect(thirdGroupSpecs).toBe(2);
});
it("should return isDefaultSpecPattern as true when empty or default value is provided", () => {
let result = testBuildMatrix("", 5);
expect(result.isDefaultSpecPattern).toBe(true);
result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 5);
expect(result.isDefaultSpecPattern).toBe(true);
});
it("should return isDefaultSpecPattern as false when non-default spec pattern is provided", () => {
const result = testBuildMatrix("test1.cy.spec.js,test2.cy.spec.js", 1);
expect(result.isDefaultSpecPattern).toBe(false);
});
it("should return special test configs when default spec pattern is provided", () => {
const result = testBuildMatrix("./e2e/test/scenarios/**/*.cy.spec.*", 50);
expect(result.config[47].name).toBe("oss-subset");
expect(result.config[48].name).toBe("mongo");
expect(result.config[49].name).toBe("python");
});
it("should not return special test configs when non-default spec pattern is provided", () => {
const result = testBuildMatrix("test1.cy.spec.js,test2.cy.spec.js", 50);
expect(result.config.length).toBe(1);
expect(result.config[0].name).toBe("e2e-group-01");
const regularJobs = result.config.slice(0, result.regularChunks);
expect(regularJobs.map((job) => job.name)).toEqual([
"e2e-group-01",
"e2e-group-02",
"e2e-group-03",
]);
for (const job of regularJobs) {
expect(job.specs).toBeUndefined();
}
});
});

View File

@@ -78,10 +78,6 @@ CYPRESS_VERSION="$CYPRESS_VERSIONS"
# 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"
@@ -116,10 +112,6 @@ spec() {
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

View File

@@ -3,7 +3,7 @@ import { join } from "path";
const SCRIPT = join(__dirname, "cache-keys.sh");
const CACHES = ["m2", "bun-store", "cypress", "eslint"];
const CACHES = ["m2", "bun-store", "cypress"];
// 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
@@ -71,10 +71,4 @@ describe("cache-keys.sh", () => {
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",
);
});
});

View File

@@ -49,6 +49,7 @@ describe("file-paths.yaml", () => {
"%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/ratchets-test.edn")).toBe(false);
expect(matches(name, ".clj-kondo/config/modules/ratchets.edn")).toBe(
false,
);
@@ -61,6 +62,32 @@ describe("file-paths.yaml", () => {
expect(matches("ci_scripts", ".nvmrc")).toBe(true);
});
it.each([
"frontend/lint/config.mjs",
"frontend/lint/oxlint/rule-map.json",
"frontend/lint/eslint-plugin-metabase/rules/no-module-side-effects.js",
"frontend/lint/tests/oxlint-config.test.mjs",
])(
"runs frontend checks without forcing full suites when %s changes",
(file) => {
expect(matches("frontend_all", file)).toBe(true);
expect(matches("frontend_unit_infra", file)).toBe(false);
expect(matches("frontend_loki_infra", file)).toBe(false);
},
);
it("treats frontend/lint/OXLINT.md as a documentation-only change", () => {
expect(matches("frontend_all", "frontend/lint/OXLINT.md")).toBe(false);
expect(matches("documentation", "frontend/lint/OXLINT.md")).toBe(true);
});
it.each([
"frontend/lint/module-boundaries.mjs",
"frontend/lint/shared-tiers.mjs",
])("keeps CI script coverage for %s", (file) => {
expect(matches("ci_scripts", file)).toBe(true);
});
it.each([
"jest.config.js",
"jest.base.conf.js",
@@ -90,10 +117,63 @@ describe("file-paths.yaml", () => {
expect(matches("ci_scripts", path)).toBe(true);
});
// Every suite's gate calls the same workflow, so a change to it has to reach every suite.
it.each([
"backend_all",
"frontend_all",
"frontend_loki_all",
"e2e_all",
"embedding_sdk_components",
"embedding_sdk_host_sample_apps",
"ci_scripts",
])("runs %s when the test gate changes", (filter) => {
expect(matches(filter, ".github/workflows/test.gate.yml")).toBe(true);
});
// The build filters replace what used to be an inline condition in run-tests.yml, so what they
// leave out is the whole point: a diff that changes nothing an artifact ships gets no build.
it.each([
"src/metabase/core.clj",
"frontend/src/metabase/App.tsx",
"e2e/test/scenarios/question/foo.cy.spec.js",
// The SDK component and host-app suites boot a Metabase instance, so their CI entry points
// have to build one.
".github/workflows/embedding-sdk.yml",
])("builds the uberjar for %s", (path) => {
expect(matches("build_uberjar", path)).toBe(true);
});
it.each([
".clj-kondo/ratchets.edn",
"README.md",
"docs/questions/introduction.md",
// The SDK type checks read the package artifact, not a running instance.
"docs/embedding/sdk/introduction.md",
])("skips the uberjar for %s", (path) => {
expect(matches("build_uberjar", path)).toBe(false);
});
it.each([
"enterprise/frontend/src/embedding-sdk-package/index.ts",
"docs/embedding/sdk/introduction.md",
".github/workflows/embedding-sdk.yml",
])("builds the SDK package for %s", (path) => {
expect(matches("build_embedding_sdk_package", path)).toBe(true);
});
it("does not build the SDK package for unrelated documentation", () => {
expect(
matches("build_embedding_sdk_package", "docs/questions/introduction.md"),
).toBe(false);
});
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/ratchets-test.edn"),
).toBe(true);
expect(
matches(
"project_ratchet_checks",
@@ -139,4 +219,52 @@ describe("file-paths.yaml", () => {
])("does not run Loki stories when %s changes", (file) => {
expect(matches("frontend_loki_all", file)).toBe(false);
});
it.each([
"e2e/support/cypress.config.js",
"e2e/test/scenarios/shared-helper.js",
"e2e/runner/read-spec-paths.js",
".github/workflows/e2e-test.yml",
".github/workflows/run-tests.yml",
".github/actions/prepare-cypress/action.yml",
".github/scripts/build-e2e-matrix.js",
".github/file-paths.yaml",
"frontend/build/shared/esbuild/side-effect-free-modules-plugin.js",
"rspack.main.config.js",
"bun.lock",
"locales/fr.po",
"snowplow/events/event.yaml",
"patches/@cypress+grep+6.0.0.patch",
])("runs all E2E specs when %s changes", (file) => {
expect(matches("e2e_all", file)).toBe(true);
expect(matches("e2e_infra", file)).toBe(true);
});
it.each(["js", "jsx", "ts", "tsx"])(
"allows changes to .cy.spec.%s files to narrow E2E",
(extension) => {
expect(
matches("e2e_infra", `e2e/test/scenarios/a.cy.spec.${extension}`),
).toBe(false);
},
);
it.each([
".github/workflows/e2e-test.yml",
".github/workflows/e2e-tests.yml",
".github/workflows/e2e-matrix-builder.yml",
"e2e/runner/read-spec-paths.js",
"e2e/support/cypress.config.js",
])("runs CI-script tests when %s changes", (file) => {
expect(matches("ci_scripts", file)).toBe(true);
});
it("runs the module cycles test when a cluster is named", () => {
expect(
matches(
"project_backend_checks",
".clj-kondo/config/modules/cycle-clusters.edn",
),
).toBe(true);
});
});

View File

@@ -206,9 +206,15 @@ describe("prepareTestSelection", () => {
describe("test selection workflow steps", () => {
const ACTION = "./.github/actions/prepare-test-selection";
const CONSUMERS: Record<string, { suite: keyof typeof SUITES }> = {
const CONSUMERS: Record<
string,
{ suite: keyof typeof SUITES; "require-download"?: boolean }
> = {
"frontend.yml": { suite: "unit" },
"loki.yml": { suite: "loki" },
"e2e-matrix-builder.yml": { suite: "e2e" },
// The matrix was sized for the planned specs, so a shard fails when the plan download fails.
"e2e-test.yml": { suite: "e2e", "require-download": true },
};
it("skips Loki visual tests only for an empty story selection", () => {

View File

@@ -0,0 +1,34 @@
const { readFileSync } = require("node:fs");
const { readSpecPaths } = require("../../e2e/runner/read-spec-paths");
jest.mock("node:fs", () => ({ readFileSync: jest.fn() }));
describe("readSpecPaths", () => {
it("should use the full suite when no paths file is provided", () => {
expect(readSpecPaths("")).toBeNull();
expect(readSpecPaths(undefined)).toBeNull();
});
it("should preserve an empty selection", () => {
readFileSync.mockReturnValue("[]");
expect(readSpecPaths("specs.json")).toEqual([]);
});
it("should preserve selected paths, including spaces and commas", () => {
const files = ["e2e/test/a.cy.spec.js", "e2e/test/a folder/b,c.cy.spec.ts"];
readFileSync.mockReturnValue(JSON.stringify(files));
expect(readSpecPaths("specs.json")).toEqual(files);
});
it.each(["{", "null", '""', "{}", "[null]", '[""]'])(
"should reject invalid spec paths: %s",
(contents) => {
readFileSync.mockReturnValue(contents);
expect(() => readSpecPaths("specs.json")).toThrow();
},
);
});

View File

@@ -98,6 +98,15 @@ merge_outputs() {
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
# Flamegraphs, when the run was profiled. Flat with a `p$idx-` prefix, not nested: the
# collect-profile action globs `results/*flamegraph.html` without descending.
if [ -d "$jvm_dir/target/clj-async-profiler/results" ]; then
mkdir -p "$WORKSPACE/target/clj-async-profiler/results"
for f in "$jvm_dir"/target/clj-async-profiler/results/*flamegraph.html; do
[ -e "$f" ] || continue
cp -a "$f" "$WORKSPACE/target/clj-async-profiler/results/p$idx-$(basename "$f")" || true
done
fi
done
}
trap merge_outputs EXIT

View File

@@ -29,6 +29,7 @@ interface Condition {
locale: string;
scripts: number;
scriptKb: number;
cssKb: number;
totalKb: number;
runs: number;
}
@@ -78,6 +79,7 @@ function buildRows(
Locale: condition.locale,
Scripts: condition.scripts,
"Script kb": condition.scriptKb,
"Css kb": condition.cssKb,
"Total kb": condition.totalKb,
Runs: condition.runs,
}));

11
.github/team.json vendored
View File

@@ -20,7 +20,8 @@
"dpsutton",
"escherize",
"bgrabow",
"alxnddr"
"alxnddr",
"tsplude"
]
},
{
@@ -55,16 +56,15 @@
"name": "Metabot",
"label": ".Team/Metabot",
"members": [
"appleby",
"retro",
"sloansparger",
"tsplude",
"lbrdnk",
"crisptrutski",
"undrfined",
"andreis",
"prascuna",
"krever"
"Krever",
"svozniuk"
]
},
{
@@ -94,7 +94,8 @@
"andsalves",
"kevinkelleher12",
"AndreyChernykh",
"bowbahdoe"
"bowbahdoe",
"appleby"
]
},
{

View File

@@ -2,38 +2,6 @@ name: App DB Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches).
type: boolean
default: false
# Run one app-DB job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- services, matrix legs, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
app-db:
description: 'Which app-DB job to run. Only this one runs; the others are skipped.'
type: choice
required: true
options:
- mariadb
- mysql
- postgres
tests:
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.db.custom-migrations-test/some-test. Bare namespaces work too. Blank runs the job''s usual set.'
type: string
required: false
default: ''
profile:
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
type: boolean
required: false
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-app-db
@@ -45,18 +13,19 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
# passed as inputs to each job so that turning either on is one line, not one line per job.
# Empty (and so off) on workflow_call, where neither input exists.
#
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job runs the
# whole list -- each app DB has three.
PROFILE_TESTS: ${{ inputs.profile }}
ONLY_TESTS: ${{ inputs.tests }}
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
# so a dispatch is a profiling run.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all
app-db-matrix:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
name: Determine app DB versions
@@ -64,12 +33,13 @@ jobs:
mariadb: ${{ steps.versions.outputs.mariadb }}
mysql: ${{ steps.versions.outputs.mysql }}
postgres: ${{ steps.versions.outputs.postgres }}
shards: ${{ steps.versions.outputs.shards }}
steps:
- name: Select versions to test
- name: Select versions and shards to test
id: versions
uses: actions/github-script@v9
env:
RUN_ALL: ${{ inputs.force-run }}
RUN_ALL: ${{ needs.gate.outputs.force-run }}
with:
script: | # js
const runAll = process.env.RUN_ALL === 'true';
@@ -133,35 +103,41 @@ jobs:
core.setOutput(db, JSON.stringify(selected));
}
const commonExcludes = ":mb/driver-tests :mb/transforms-python-test :mb/old-migrations-test";
const shards = [
{
name: 'Enterprise Tests',
'build-static-viz': false,
'test-args': `:only '"enterprise/backend/test"' :exclude-tags '[${commonExcludes}]'`,
},
{
name: 'EE App DB Tests (Part 1)', // migrations shard
'build-static-viz': true,
'test-args': `:only '"test"' :only-tags '[:mb/app-db-migrations-test]' :exclude-tags '[${commonExcludes}]'`,
},
{
name: 'EE App DB Tests (Part 2)',
'build-static-viz': true,
'test-args': `:only '"test"' :partition/total 2 :partition/index 0 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
},
{
name: 'EE App DB Tests (Part 3)',
'build-static-viz': true,
'test-args': `:only '"test"' :partition/total 2 :partition/index 1 :exclude-tags '[${commonExcludes} :mb/app-db-migrations-test]'`,
},
];
core.setOutput('shards', JSON.stringify(shards));
be-tests-mariadb:
needs: app-db-matrix
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mariadb') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.mariadb) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
services:
mysql:
@@ -175,10 +151,6 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: mysql
MB_DB_TYPE: mysql
@@ -205,8 +177,9 @@ jobs:
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
# "be-tests-mariadb-10-6-ee (0)"
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
- uses: actions/checkout@v6
# Shared by every app DB job below via `*app-db-test-steps`.
steps: &app-db-test-steps
- uses: actions/checkout@v7
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
@@ -214,39 +187,17 @@ jobs:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
test-args: ${{ matrix.job.test-args }}
be-tests-mysql:
needs: app-db-matrix
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'mysql') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.mysql) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
services:
mysql:
@@ -260,10 +211,6 @@ jobs:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: mb_test
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: mysql
MB_DB_TYPE: mysql
@@ -298,54 +245,19 @@ jobs:
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
# "be-tests-mariadb-10-6-ee (0)"
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
steps: *app-db-test-steps
be-tests-postgres:
needs: app-db-matrix
if: ${{ !inputs.skip && (inputs.app-db == '' || inputs.app-db == 'postgres') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.app-db-matrix.outputs.postgres) }}
job:
- name: Enterprise Tests
build-static-viz: false
test-args: >-
:only '"enterprise/backend/test"'
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 1)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 0
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
- name: EE App DB Tests (Part 2)
build-static-viz: true
test-args: >-
:only '"test"'
:partition/total 2
:partition/index 1
exclude-tag: ':mb/driver-tests :mb/transforms-python-test'
job: ${{ fromJSON(needs.app-db-matrix.outputs.shards) }}
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
env:
# __ADDITIONAL_EXCLUDED_TAG__: ${{ !inputs.force-run && ':mb/old-migrations-test' || '' }}
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
# actual serious env vars below
CI: 'true'
DRIVERS: postgres
MB_DB_TYPE: postgres
@@ -374,21 +286,11 @@ jobs:
POSTGRES_USER: mb_test
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }} (${{ matrix.job.name }})
id: test-driver
uses: ./.github/actions/test-driver
with:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
artifact-suffix: ${{ matrix.job.name }}
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
steps: *app-db-test-steps
app-db-tests-result:
needs:
- gate
- app-db-matrix
- be-tests-mariadb
- be-tests-mysql
@@ -396,7 +298,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: app-db-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
steps:
- name: Check job status
uses: actions/github-script@v9

View File

@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out the code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Babashka
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0

View File

@@ -17,7 +17,7 @@ jobs:
app_id: ${{ secrets.METABASE_BOT_APP_ID }}
private_key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Assign author and their team. Returns true if team exists.
id: auto-assign

View File

@@ -21,7 +21,7 @@ jobs:
commit: ${{ steps.find_commit.outputs.commit }}
targets: ${{ steps.targets.outputs.result }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
matrix:
target: ${{ fromJson(needs.prepare.outputs.targets) }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
@@ -129,7 +129,7 @@ jobs:
echo "::error::$msg"
exit 1
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}

View File

@@ -22,7 +22,7 @@ jobs:
outputs:
modules: ${{ steps.get-modules.outputs.modules }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- id: get-modules
run: |
modules=$(for d in src/metabase/*/; do basename "$d"; done | tr '_' '-' | jq -R -s -c 'split("\n") | map(select(length > 0))')
@@ -39,7 +39,7 @@ jobs:
matrix:
modules: ${{ fromJson(needs.setup-oss.outputs.modules) }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -70,7 +70,7 @@ jobs:
outputs:
modules: ${{ steps.get-modules.outputs.modules }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- id: get-modules
run: |
modules=$(for d in enterprise/backend/src/metabase_enterprise/*/; do basename "$d"; done | tr '_' '-' | jq -R -s -c 'split("\n") | map(select(length > 0))')
@@ -87,7 +87,7 @@ jobs:
matrix:
modules: ${{ fromJson(needs.setup-ee.outputs.modules) }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -2,42 +2,6 @@ name: Backend
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches).
type: boolean
default: false
run-project-backend-checks:
description: Run project-level backend checks.
type: boolean
default: false
run-project-migration-checks:
description: Run project-level migration checks.
type: boolean
default: false
run-project-ratchet-checks:
description: Run the ratchet check for a diff that touches nothing but the ratchets file.
type: boolean
default: false
# Run the backend test suite on demand, against the ref you dispatch on, optionally under a
# profiler. Everything else about the jobs -- matrix legs, tag filters, quarantine gating -- is
# whatever the normal CI run uses, because they are the same jobs.
workflow_dispatch:
inputs:
tests:
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank runs each leg''s usual set.'
type: string
required: false
default: ''
profile:
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
type: boolean
required: false
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
@@ -49,26 +13,53 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Both read by the be-tests run step below. Set here rather than passed per job so that turning
# either on is one line. Empty (and so off) on workflow_call, where neither input exists.
#
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole list: the
# partition args no longer apply, and legs that hold none of the named tests run nothing.
PROFILE_TESTS: ${{ inputs.profile }}
ONLY_TESTS: ${{ inputs.tests }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all project_backend_checks project_migration_checks project_ratchet_checks
# Generated static-viz output can shift without any backend source changing, so the suite follows
# it too. Only worth probing when nothing else has already decided the suite.
static-viz-files-changed:
name: Check whether static-viz files changed
needs: gate
if: >-
${{ needs.gate.outputs.verdict == 'defer'
&& !contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
static_viz: ${{ steps.static_viz.outputs.static_viz }}
steps:
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build static-viz frontend
run: bun run build-static-viz
env:
MB_EDITION: ee
- name: Check for static viz changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: static_viz
with:
token: ${{ github.token }}
filters: .github/static-viz-sources.yaml
project-checks:
name: 'Project tests: Suites'
needs: gate
if: >-
${{ inputs.force-run ||
inputs.run-project-backend-checks ||
inputs.run-project-migration-checks ||
inputs.run-project-ratchet-checks }}
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
# Bootstrap the shared Mage runtime before concurrent commands try to install it themselves.
@@ -76,21 +67,23 @@ jobs:
run: ./bin/mage
- parallel:
- name: Run backend checks
if: ${{ inputs.force-run || inputs.run-project-backend-checks }}
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks') }}
run: ./bin/mage project-tests backend
- name: Run migration checks
if: ${{ inputs.force-run || inputs.run-project-migration-checks }}
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks') }}
run: ./bin/mage project-tests migrations
# Babashka, not a JVM test run, so it is cheap enough to run alongside the backend checks.
# It still needs the JDK prepare-backend installs, to resolve clj-kondo on a cold cache.
# Babashka plus a scoped clj-kondo run, not a JVM test run, so it is cheap enough to run alongside the
# backend checks. It needs the JDK and Clojure CLI prepare-backend installs.
- name: Run ratchet checks
if: ${{ inputs.force-run || inputs.run-project-backend-checks || inputs.run-project-ratchet-checks }}
if: >-
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
run: ./bin/mage kondo-ratchets
project-tests:
name: Project tests
needs: project-checks
if: ${{ always() && !cancelled() }}
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
@@ -148,37 +141,49 @@ jobs:
process.exit(1);
be-linter-clj-kondo:
if: ${{ !inputs.skip }}
needs: &backend-gate-needs [gate, static-viz-files-changed]
# `!cancelled()` so a skipped static-viz probe -- the suite was already decided -- does not skip
# this job; its failure still does.
if: &backend-gate >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.static-viz-files-changed.result != 'failure'
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
name: Clj-Kondo
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Run clj-kondo
run: ./bin/mage kondo
be-linter-eastwood:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
timeout-minutes: 30
name: Eastwood
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- run: clojure -X:dev:ee:ee-dev:drivers:drivers-dev:test:eastwood
name: Run Eastwood linter
be-linter-eastwood-test:
# Master / release safety net: lint every backend test namespace.
if: ${{ !inputs.skip && inputs.force-run }}
# Master / release safety net: lint every backend test namespace. A force-run never probes
# static-viz, so the gate alone decides this one.
needs: gate
if: >-
${{ needs.gate.outputs.force-run == 'true'
&& contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 45
timeout-minutes: 50
name: Eastwood (tests only)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test
@@ -186,12 +191,18 @@ jobs:
be-linter-eastwood-test-changed:
# Lint only the test namespaces touched by the diff.
if: ${{ !inputs.skip && !inputs.force-run && github.event_name == 'pull_request' }}
needs: *backend-gate-needs
if: >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.static-viz-files-changed.result != 'failure'
&& needs.gate.outputs.force-run != 'true' && github.event_name == 'pull_request'
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
name: Eastwood (changed tests only)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Enough history for `git merge-base` on a typical PR.
fetch-depth: 200
@@ -251,7 +262,8 @@ jobs:
run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
be-tests:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -298,7 +310,7 @@ jobs:
# GDGT-3126: re-enable and properly fix old migrations tests
__ADDITIONAL_EXCLUDED_TAG__: ':mb/old-migrations-test'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -374,6 +386,16 @@ jobs:
else
$BASE :exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]' ${{ matrix.job.test-args }}
fi
env:
# Both come from test.profile-backend.yml, the only workflow that dispatches this suite, so a
# dispatch is a profiling run. On a normal CI run these render `false` and empty and the
# leg's own selection stands.
#
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole
# list: the partition args no longer apply, and legs that hold none of the named tests run
# nothing.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
ONLY_TESTS: ${{ github.event.inputs.tests }}
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
@@ -448,7 +470,8 @@ jobs:
# checks that all the namespaces we actually ship can be compiled, without any dependencies that we don't ship (such
# as `:dev` dependencies). See #27009 for more context.
be-check:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
strategy:
@@ -456,7 +479,7 @@ jobs:
java-version: [25]
name: "Check Java ${{ matrix.java-version }}"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare backend
uses: ./.github/actions/prepare-backend
# TODO -- we should probably also check WITHOUT ee -- do a check with only OSS namespaces on the classpath
@@ -464,12 +487,13 @@ jobs:
run: clojure -M:ee:drivers:load-namespaces
be-cljfmt:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
name: Cljfmt
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Run cljfmt
@@ -477,6 +501,8 @@ jobs:
be-tests-result:
needs:
- gate
- static-viz-files-changed
- project-tests
- be-linter-eastwood
- be-linter-eastwood-test
@@ -485,7 +511,7 @@ jobs:
- be-cljfmt
- be-check
- be-tests
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -33,7 +33,7 @@ jobs:
outputs:
docs: ${{ steps.changes.outputs.docs }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes

View File

@@ -72,7 +72,7 @@ jobs:
# would run that commit's copy of the action instead of this one. Nothing else in this job
# reads the tree.
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
with:

View File

@@ -2,18 +2,16 @@ name: Build scripts
on:
workflow_call:
inputs:
skip-build-scripts:
type: boolean
default: false
skip-embedder-plugin:
type: boolean
default: false
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: build_scripts embedder_plugin
test-embedder-plugin:
if: ${{ !inputs.skip-embedder-plugin }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedder_plugin') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
permissions:
@@ -28,7 +26,7 @@ jobs:
ports:
- 5432:5432
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build embedder plugin
@@ -63,11 +61,12 @@ jobs:
'[metabase-enterprise.semantic-search.appdb-pgvector-mode-test/appdb-pgvector-in-process-embedder-round-trip-test]'
test-build-scripts:
if: ${{ !inputs.skip-build-scripts }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'build_scripts') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
timeout-minutes: 5
uses: ./.github/actions/prepare-backend

View File

@@ -43,7 +43,7 @@ jobs:
outputs:
found: ${{ steps.probe.outputs.fetch-hit }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- id: probe
uses: ./.github/actions/fetch-artifact
with:
@@ -64,7 +64,7 @@ jobs:
MIN_DELTA_PERCENT: "0.5"
steps:
- name: Check out the code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ env.HEAD_SHA }}
- uses: ./.github/actions/prepare-node-bun

View File

@@ -13,15 +13,25 @@ concurrency:
cancel-in-progress: true
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: frontend_sources
bundle-size-check:
name: "Bundle size check"
needs: gate
# There is no base uberjar to compare against outside a pull request.
if: >-
${{ github.event_name == 'pull_request'
&& contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
continue-on-error: true
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
env:
BUNDLE_SIZE_THRESHOLD_PERCENT: 2
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/bundle-size-check
id: compare
with:

View File

@@ -5,9 +5,6 @@
# build a 200MB archive and race for one reservation on every lockfile change, and all pull the Cypress
# binary at once on a version bump.
#
# The ESLint cache is the exception and saves from the lint job itself: it is under a megabyte and only a
# couple of jobs write it, so there is no herd to avoid.
#
# A cache is readable from the ref that saved it, from that ref's pull requests, and - for every ref -
# from the default branch. Warming on push to each merge target therefore serves the pull requests aimed
# at it, and master's copy backs up every ref that has none of its own.
@@ -46,7 +43,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Cache spec
id: cache-spec

View File

@@ -19,7 +19,7 @@ jobs:
outputs:
frontend_all: ${{ steps.changes.outputs.frontend_all }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
@@ -33,7 +33,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Prepare front-end environment

View File

@@ -19,7 +19,7 @@ jobs:
outputs:
cljs: ${{ steps.changes.outputs.cljs }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
@@ -33,7 +33,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -15,7 +15,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install codespell
run: pip install codespell

View File

@@ -73,7 +73,7 @@ jobs:
env:
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 1

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -32,7 +32,7 @@ jobs:
MB_DB_TYPE: h2
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -58,7 +58,7 @@ jobs:
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -84,7 +84,7 @@ jobs:
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -111,7 +111,7 @@ jobs:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -139,7 +139,7 @@ jobs:
ports:
- "3306:3306"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -168,7 +168,7 @@ jobs:
# ports:
# - "3306:3306"
# steps:
# - uses: actions/checkout@v6
# - uses: actions/checkout@v7
# - uses: ./.github/actions/cross-branch-migration-test
# with:
# base-ref: ${{ env.BASE_REF }}
@@ -195,7 +195,7 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}
@@ -222,7 +222,7 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/cross-branch-migration-test
with:
base-ref: ${{ env.BASE_REF }}

View File

@@ -32,7 +32,7 @@ jobs:
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
sparse-checkout: .github
@@ -58,7 +58,7 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
@@ -116,7 +116,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
sparse-checkout: |
release
@@ -194,7 +194,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: master
sparse-checkout: |
@@ -61,10 +61,11 @@ jobs:
# to upload the markdown to #writing.
#
# Kondo ignore ratchets apply only to master. The cut commit replaces the
# budgets with an explicit opt-out so future budget changes are not applied
# to the release branch. Keeping the opt-out in the canonical file avoids
# introducing a separate marker that could drift out of sync, and the
# backport helper writes the same opt-out so both paths stay identical.
# budgets in both ratchet files with an explicit opt-out so future budget
# changes are not applied to the release branch. Keeping the opt-out in the
# canonical files avoids introducing a separate marker that could drift out
# of sync, and the backport helper writes the same opt-out so both paths
# stay identical.
- name: Cut the branch
id: cut
run: |
@@ -73,8 +74,9 @@ jobs:
git config user.email github-actions@github.com
git checkout -b "$BRANCH"
source .github/actions/create-backport/kondo-ratchets.sh
write_disabled_ratchets
git add --sparse .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets.edn
write_disabled_ratchets .clj-kondo/ratchets-test.edn
git add --sparse .clj-kondo/ratchets.edn .clj-kondo/ratchets-test.edn
git commit --allow-empty --no-verify -m "Cut $BRANCH branch"
git push -u origin "$BRANCH"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

View File

@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.automation_token }}
@@ -86,6 +86,16 @@ jobs:
clojure -M:ee:doc ai-providers-documentation
continue-on-error: false
- name: Generate MCP tool documentation
id: generate_mcp_tool_docs
# Same reason as the AI provider step: release branches without the generator would fail the command.
if: hashFiles('src/metabase/cmd/mcp_tools_dox.clj') != ''
run: |
set -e # Exit on any error
echo "Generating MCP tool documentation..."
clojure -M:ee:doc mcp-tools-documentation
continue-on-error: false
- name: Generate usage analytics documentation
run: ./bin/generate-usage-analytics-docs.bb
@@ -176,6 +186,7 @@ jobs:
- \`clojure -M:ee:doc api-documentation\`
- \`clojure -M:ee:doc command-documentation\`
- \`clojure -M:ee:doc ai-providers-documentation\`
- \`clojure -M:ee:doc mcp-tools-documentation\`
- \`./bin/generate-usage-analytics-docs.bb\`
- \`bun run embedding-sdk:docs:generate\`
- \`bun run embedding-eajs:docs:generate\`

View File

@@ -13,7 +13,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
sparse-checkout: |
docs

View File

@@ -64,7 +64,7 @@ jobs:
MB_ATHENA_TEST_SECRET_KEY: ${{ secrets.MB_ATHENA_TEST_SECRET_KEY }}
MB_ATHENA_TEST_S3_STAGING_DIR: ${{ secrets.MB_ATHENA_TEST_S3_STAGING_DIR }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Athena driver
uses: ./.github/actions/test-driver
with:
@@ -87,7 +87,7 @@ jobs:
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test BigQuery Cloud SDK driver
uses: ./.github/actions/test-driver
with:
@@ -110,7 +110,7 @@ jobs:
env:
CLUSTER_SIZE: nano-quickstart
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Druid driver
uses: ./.github/actions/test-driver
with:
@@ -140,7 +140,7 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MariaDB driver (10.6)
uses: ./.github/actions/test-driver
with:
@@ -170,7 +170,7 @@ jobs:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
MARIADB_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MariaDB driver (latest)
uses: ./.github/actions/test-driver
with:
@@ -193,7 +193,7 @@ jobs:
ports:
- "27017:27017"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MongoDB driver (4.4)
uses: ./.github/actions/test-driver
with:
@@ -212,7 +212,7 @@ jobs:
MB_MONGO_TEST_PASSWORD: metasample123
MB_TEST_MONGO_REQUIRES_SSL: true
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Spin up Mongo docker container
run: docker run -d -p 27017:27017 --name metamongo metabase/qa-databases:mongo-sample-4.4 mongod --dbpath /data/db2/ --tlsMode requireTLS --tlsCertificateKeyFile /etc/mongo/metamongo.pem --tlsCAFile /etc/mongo/metaca.crt
- name: Wait until the port 27017 is ready
@@ -251,7 +251,7 @@ jobs:
ports:
- "27017:27017"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MongoDB driver (5.0)
uses: ./.github/actions/test-driver
with:
@@ -270,7 +270,7 @@ jobs:
MB_MONGO_TEST_PASSWORD: metasample123
MB_TEST_MONGO_REQUIRES_SSL: true
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Spin up Mongo docker container
run: docker run -d -p 27017:27017 --name metamongo metabase/qa-databases:mongo-sample-5.0 mongod --dbpath /data/db2/ --tlsMode requireTLS --tlsCertificateKeyFile /etc/mongo/metamongo.pem --tlsCAFile /etc/mongo/metaca.crt
- name: Wait until the port 27017 is ready
@@ -312,7 +312,7 @@ jobs:
MONGO_INITDB_ROOT_USERNAME: metabase
MONGO_INITDB_ROOT_PASSWORD: metasample123
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MongoDB driver (latest)
uses: ./.github/actions/test-driver
with:
@@ -342,7 +342,7 @@ jobs:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MySQL driver (8.0)
uses: ./.github/actions/test-driver
with:
@@ -372,7 +372,7 @@ jobs:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: mb_test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MySQL driver (9)
uses: ./.github/actions/test-driver
with:
@@ -411,7 +411,7 @@ jobs:
ports:
- "3306:3306"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MySQL driver (latest)
uses: ./.github/actions/test-driver
with:
@@ -438,7 +438,7 @@ jobs:
ports:
- "1521:1521"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Oracle driver (18.4)
uses: ./.github/actions/test-driver
with:
@@ -474,7 +474,7 @@ jobs:
ports:
- "2484:2484"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Oracle driver (21.3)
uses: ./.github/actions/test-driver
with:
@@ -505,7 +505,7 @@ jobs:
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Postgres driver (14)
uses: ./.github/actions/test-driver
with:
@@ -539,7 +539,7 @@ jobs:
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Postgres driver (latest)
uses: ./.github/actions/test-driver
with:
@@ -570,7 +570,7 @@ jobs:
env:
JAVA_TOOL_OPTIONS: "-Xmx2g"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Wait for port ${{ env.MB_PRESTO_JDBC_TEST_PORT }} to be ready
run: while ! nc -z localhost ${{ env.MB_PRESTO_JDBC_TEST_PORT }}; do sleep 0.1; done
timeout-minutes: 15
@@ -609,7 +609,7 @@ jobs:
MB_REDSHIFT_TEST_HOST: ${{ secrets.MB_REDSHIFT_TEST_HOST }}
MB_REDSHIFT_TEST_PASSWORD: ${{ secrets.MB_REDSHIFT_TEST_PASSWORD }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Redshift driver
uses: ./.github/actions/test-driver
with:
@@ -641,7 +641,7 @@ jobs:
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_ROLE: RSA_ROLE_TEST_ROLE
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_DB: RSA_ROLE_TEST_DB
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Snowflake driver
uses: ./.github/actions/test-driver
with:
@@ -662,7 +662,7 @@ jobs:
ports:
- "10000:10000"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Spark driver
uses: ./.github/actions/test-driver
with:
@@ -678,7 +678,7 @@ jobs:
CI: 'true'
DRIVERS: sqlite
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test SQLite driver
uses: ./.github/actions/test-driver
with:
@@ -706,7 +706,7 @@ jobs:
SA_PASSWORD: 'P@ssw0rd'
MSSQL_MEMORY_LIMIT_MB: 1024
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MS SQL Server 2017 driver
uses: ./.github/actions/test-driver
with:
@@ -734,7 +734,7 @@ jobs:
SA_PASSWORD: 'P@ssw0rd'
MSSQL_MEMORY_LIMIT_MB: 1024
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MS SQL Server 2022 driver
uses: ./.github/actions/test-driver
with:
@@ -758,7 +758,7 @@ jobs:
- "8080:8080"
name: Starburst
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Make plugins directory
run: mkdir plugins
- name: Test Starburst driver
@@ -816,7 +816,7 @@ jobs:
username: ${{ needs.login-to-ecr.outputs.docker_username }}
password: ${{ needs.login-to-ecr.outputs.docker_password }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Make plugins directory
run: mkdir plugins
- name: Test Vertica driver

View File

@@ -2,53 +2,6 @@ name: Driver Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches, ci:run-all label).
type: boolean
default: false
# Run one driver job on demand, against the ref you dispatch on, optionally under a profiler.
# Everything else about the job -- credentials, services, test args, quarantine gating -- is
# whatever the normal CI run uses, because it is the same job.
workflow_dispatch:
inputs:
driver:
description: 'Which driver job to run. Only this one runs; the usual decision rules are bypassed.'
type: choice
required: true
options:
- athena
- bigquery
- clickhouse
- databricks
- druid-jdbc
- h2
- mongo
- mongo-sharded-cluster
- mongo-ssl
- mysql-mariadb
- oracle
- postgres
- presto-jdbc
- redshift
- snowflake
- sparksql
- sqlite
- sqlserver
- vertica
tests:
description: 'Optional: run only these tests, comma- or space-separated, e.g. metabase.driver.bigquery-cloud-sdk-test/sync-test. Bare namespaces work too. Blank runs the job''s usual set.'
type: string
required: false
default: ''
profile:
description: 'Profile the test run and attach a flamegraph to the job. Adds sampling overhead.'
type: boolean
required: false
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
@@ -60,19 +13,21 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Both read by the test-driver action, which is what actually runs the tests. Set here rather than
# passed as inputs to each job so that turning either on is one line, not one line per driver.
# Empty (and so off) on workflow_call, where neither input exists.
#
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen driver runs
# the whole list -- BigQuery, for instance, has three legs.
PROFILE_TESTS: ${{ inputs.profile }}
ONLY_TESTS: ${{ inputs.tests }}
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
# so a dispatch is a profiling run.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all
# This job determines which driver tests should run based on PR context.
# All decision logic is consolidated in mage driver-decisions.
determine-driver-skips:
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
@@ -99,7 +54,7 @@ jobs:
vertica-should-run: ${{ steps.driver-decisions.outputs.vertica-should-run }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Fetch base branch
@@ -116,8 +71,6 @@ jobs:
--git-ref="${BASE_REF}"
--force-run="${FORCE_RUN}"
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
--skip="${{ inputs.skip }}"
--only-driver="${ONLY_DRIVER}"
)
echo "Running:"
echo "./bin/mage -driver-decisions ${MAGE_ARGS[*]}"
@@ -131,8 +84,7 @@ jobs:
./bin/mage -driver-decisions "${MAGE_ARGS[@]}" --github-output-only >> "$GITHUB_OUTPUT"
env:
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
FORCE_RUN: ${{ inputs.force-run }}
ONLY_DRIVER: ${{ inputs.driver }}
FORCE_RUN: ${{ needs.gate.outputs.force-run == 'true' }}
shell: bash
be-tests-athena-ee:
@@ -140,6 +92,10 @@ jobs:
if: ${{ needs.determine-driver-skips.outputs.athena-should-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
runner-index: [0, 1]
env:
CI: 'true'
DRIVERS: athena
@@ -152,9 +108,9 @@ jobs:
# These credentials are used to test the driver when the user does not have the athena:GetTableMetadata permission
MB_ATHENA_TEST_WITHOUT_GET_TABLE_METADATA_ACCESS_KEY: ${{ secrets.MB_ATHENA_TEST_WITHOUT_GET_TABLE_METADATA_ACCESS_KEY }}
MB_ATHENA_TEST_WITHOUT_GET_TABLE_METADATA_SECRET_KEY: ${{ secrets.MB_ATHENA_TEST_WITHOUT_GET_TABLE_METADATA_SECRET_KEY }}
name: Athena
name: Athena Driver Tests ${{ matrix.runner-index }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Athena driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -163,6 +119,11 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
runner-index: ${{ matrix.runner-index }}
runner-count: ${{ strategy.job-total }}
jvms-per-runner: '2'
heap-per-jvm: '4g'
artifact-suffix: runner-${{ matrix.runner-index }}
be-tests-bigquery-cloud-sdk-ee:
needs: determine-driver-skips
@@ -185,7 +146,7 @@ jobs:
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
name: BigQuery Driver Tests ${{ matrix.runner-index }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test BigQuery Cloud SDK driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -218,7 +179,7 @@ jobs:
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
name: BigQuery Transforms Python Tests
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
uses: ./.github/actions/setup-python-runner
with:
@@ -265,7 +226,7 @@ jobs:
MB_CLICKHOUSE_TEST_NGINX_PORT: 8127
name: Clickhouse ${{ matrix.job.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -318,7 +279,7 @@ jobs:
CLUSTER_SIZE: nano-quickstart
name: Druid (JDBC)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Druid JDBC driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -381,7 +342,7 @@ jobs:
fi
echo "MB_DATABRICKS_TEST_${name}=${!var}" >> "$GITHUB_ENV"
done
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Databricks driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -399,7 +360,7 @@ jobs:
CI: 'true'
name: H2
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test H2 driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -418,7 +379,7 @@ jobs:
CI: 'true'
name: H2 (OSS)
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test H2 driver (OSS)
id: test-driver
uses: ./.github/actions/test-driver
@@ -531,7 +492,7 @@ jobs:
# "be-tests-mariadb-10-2-ee (0)"
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -592,7 +553,7 @@ jobs:
MONGO_INITDB_ROOT_PASSWORD: metasample123
name: ${{ matrix.version.name }} ${{ matrix.job.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -632,7 +593,7 @@ jobs:
MB_TEST_MONGO_REQUIRES_SSL: true
name: "${{ matrix.version.name }} (SSL)"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Spin up Mongo docker container
run: >-
docker run -d
@@ -682,7 +643,7 @@ jobs:
- "27017:17017"
name: MongoDB Sharded Cluster
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test MongoDB driver (Sharded cluster)
id: test-driver
uses: ./.github/actions/test-driver
@@ -744,7 +705,7 @@ jobs:
- "1521:${{ matrix.version.port }}"
name: ${{ matrix.version.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
@@ -831,7 +792,7 @@ jobs:
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -878,7 +839,7 @@ jobs:
env:
JAVA_TOOL_OPTIONS: "-Xmx2g"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/await-port
with:
port: ${{ env.MB_PRESTO_JDBC_TEST_PORT }}
@@ -987,7 +948,7 @@ jobs:
MB_REDSHIFT_TEST_PASSWORD: ${{ secrets.MB_REDSHIFT_TEST_PASSWORD }}
name: ${{ matrix.job.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -1014,6 +975,10 @@ jobs:
if: ${{ needs.determine-driver-skips.outputs.snowflake-should-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 70
permissions:
# Required to mint a GitHub Actions OIDC token for the Snowflake WIF live test.
id-token: write
contents: read
strategy:
fail-fast: false
matrix:
@@ -1049,9 +1014,19 @@ jobs:
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_CUSTOM_USER: RSA_ROLE_TEST_CUSTOM_USER
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_ROLE: RSA_ROLE_TEST_ROLE
MB_SNOWFLAKE_TEST_RSA_ROLE_TEST_DB: RSA_ROLE_TEST_DB
# Workload Identity Federation live test. USER is picked by event/ref so the GH OIDC `sub`
# claim matches a WORKLOAD_IDENTITY subject on the Snowflake side. Contexts that no
# WORKLOAD_IDENTITY user matches (release-** pushes, dispatches from feature branches)
# opt out via the explicit 'skip' sentinel.
MB_SNOWFLAKE_TEST_WIF_ACCOUNT: ${{ secrets.MB_SNOWFLAKE_TEST_ACCOUNT }}
MB_SNOWFLAKE_TEST_WIF_WAREHOUSE: ${{ secrets.MB_SNOWFLAKE_TEST_WAREHOUSE }}
MB_SNOWFLAKE_TEST_WIF_USER: ${{ github.event_name == 'pull_request' && 'METABASE WIF' || (github.ref == 'refs/heads/master' && 'METABASE WIF MAIN' || 'skip') }}
MB_SNOWFLAKE_TEST_WIF_DB: SNOWFLAKE_SAMPLE_DATA
MB_SNOWFLAKE_TEST_WIF_ROLE: PUBLIC
name: Snowflake ${{ matrix.job.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -1086,7 +1061,7 @@ jobs:
- "10000:10000"
name: Spark SQL
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test Spark driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -1106,7 +1081,7 @@ jobs:
DRIVERS: sqlite
name: SQLite
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test SQLite driver
id: test-driver
uses: ./.github/actions/test-driver
@@ -1157,7 +1132,7 @@ jobs:
MSSQL_MEMORY_LIMIT_MB: 1024
name: ${{ matrix.version.name }} ${{ matrix.job.name }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup python-runner
if: ${{ matrix.job.needs-python-runner == true }}
uses: ./.github/actions/setup-python-runner
@@ -1195,7 +1170,7 @@ jobs:
# - "8080:8080"
# name: Starburst
# steps:
# - uses: actions/checkout@v6
# - uses: actions/checkout@v7
# - name: Make plugins directory
# run: mkdir plugins
# - name: Test Starburst driver
@@ -1255,7 +1230,7 @@ jobs:
password: ${{ needs.login-to-ecr.outputs.docker_password }}
name: Vertica
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Make plugins directory
run: mkdir plugins
- name: Test Vertica driver
@@ -1269,6 +1244,7 @@ jobs:
drivers-tests-result:
needs:
- gate
- determine-driver-skips
# if you add a driver job, you must add it to this list for it to be a required check
- be-tests-athena-ee
@@ -1296,7 +1272,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: drivers-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
steps:
- name: Check job status
uses: actions/github-script@v9
@@ -1306,8 +1282,8 @@ jobs:
script: | # js
const needs = JSON.parse(process.env.needs);
// Drop the determine-driver-skips bookkeeping job; every remaining job is a driver
// job whose result gates this check.
// Drop the determine-driver-skips bookkeeping job; the rest -- the gate and the
// driver jobs -- gate this check.
const jobs = Object.entries(needs)
.filter(([jobName]) => jobName !== 'determine-driver-skips')
.map(([jobName, jobValues]) => ({

View File

@@ -33,7 +33,7 @@ jobs:
MB_EDITION: ${{ inputs.edition || 'ee' }}
INSTRUMENT_COVERAGE: "true"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -104,7 +104,7 @@ jobs:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
# merge-multiple unions every shard's per-spec dir into one tree; the

View File

@@ -37,7 +37,7 @@ jobs:
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: true
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}

View File

@@ -7,17 +7,21 @@ on:
required: false
type: number
default: 30
specs:
test-plan-artifact-id:
description: "ID of this run's test-plan.json artifact, '' to run the full suite."
required: false
type: string
default: "./e2e/test/scenarios/**/*.cy.spec.*"
default: ""
outputs:
matrix:
value: ${{ jobs.build-matrix.outputs.matrix }}
regularChunks:
value: ${{ jobs.build-matrix.outputs.regularChunks }}
isDefaultSpecPattern:
value: ${{ jobs.build-matrix.outputs.isDefaultSpecPattern }}
hasTests:
value: ${{ jobs.build-matrix.outputs.hasTests }}
test-plan-artifact-id:
description: "The test plan artifact ID when the matrix was built from a narrowed selection, otherwise ''."
value: ${{ jobs.build-matrix.outputs.test-plan-artifact-id }}
jobs:
build-matrix:
@@ -26,25 +30,38 @@ jobs:
outputs:
matrix: ${{ steps.matrix.outputs.config }}
regularChunks: ${{ steps.matrix.outputs.regularChunks }}
isDefaultSpecPattern: ${{ steps.matrix.outputs.isDefaultSpecPattern }}
hasTests: ${{ steps.matrix.outputs.hasTests }}
test-plan-artifact-id: ${{ steps.selection.outputs.paths-file && inputs.test-plan-artifact-id || '' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
sparse-checkout: |
.github
/.nvmrc
/package.json
e2e/runner/read-spec-paths.js
sparse-checkout-cone-mode: false
- uses: actions/setup-node@v6.4.0
- name: Prepare Node + Bun environment
if: ${{ inputs.test-plan-artifact-id != '' }}
uses: ./.github/actions/prepare-node-bun
- name: Prepare E2E spec selection
id: selection
if: ${{ inputs.test-plan-artifact-id != '' }}
uses: ./.github/actions/prepare-test-selection
with:
node-version: lts/Jod # 22.x.x
suite: e2e
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
- name: Build e2e matrix
uses: actions/github-script@v9
id: matrix
env:
chunks: ${{ inputs.chunks }}
E2E_SPEC_PATHS_FILE: ${{ steps.selection.outputs.paths-file }}
with:
script: | # js
const { buildMatrix } = require('${{ github.workspace }}/.github/scripts/build-e2e-matrix.js');
const inputSpecs = "${{ inputs.specs }}";
const { readSpecPaths } = require('${{ github.workspace }}/e2e/runner/read-spec-paths.js');
const specFiles = readSpecPaths(process.env.E2E_SPEC_PATHS_FILE);
const inputChunks = Number(process.env.chunks);
const java = 25;
const defaultRunner = "${{ vars.DEFAULT_RUNNER_KEY }}";
@@ -52,12 +69,11 @@ jobs:
java,
defaultRunner,
};
const { config, regularChunks, isDefaultSpecPattern } = buildMatrix(options, inputSpecs, inputChunks);
const { config, regularChunks } = buildMatrix(options, specFiles, inputChunks);
core.setOutput("regularChunks", JSON.stringify(regularChunks));
core.setOutput("config", JSON.stringify({ include: config }));
core.setOutput("isDefaultSpecPattern", JSON.stringify(isDefaultSpecPattern));
core.setOutput("hasTests", config.length > 0);
console.log({ regularChunks });
console.log("matrix config", config);
console.log("isDefaultSpecPattern", isDefaultSpecPattern);

View File

@@ -154,7 +154,7 @@ jobs:
MB_EDITION: ${{ inputs.mb_edition }}
CYPRESS_ENABLE_NETWORK_THROTTLING: ${{ inputs.enable_network_throttling }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Important because we need previous commits hashes to find and download the uberjar!
fetch-depth: 20

View File

@@ -15,6 +15,10 @@ on:
required: false
default: ./e2e/test/scenarios/**/*.cy.spec.*
type: string
test-plan-artifact-id:
description: "ID of this run's test-plan.json artifact, '' to use the default spec pattern."
type: string
default: ""
tags:
required: false
type: string
@@ -79,7 +83,7 @@ jobs:
CYPRESS_MB_PRO_CLOUD_TOKEN: ${{ secrets.STAGING_MB_PRO_CLOUD_TOKEN }}
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: true
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
@@ -114,6 +118,17 @@ jobs:
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
# The matrix builder sized these jobs for the planned specs, so the test plan download has to succeed.
- name: Prepare E2E spec selection
id: selection
if: ${{ inputs.test-plan-artifact-id != '' }}
uses: ./.github/actions/prepare-test-selection
with:
suite: e2e
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
require-download: true
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
@@ -187,6 +202,7 @@ jobs:
# do not write cypress-split summary to github
SPLIT_SUMMARY: false
SPEC_PATH: ${{ env.specs || inputs.specs }}
E2E_SPEC_PATHS_FILE: ${{ steps.selection.outputs.paths-file }}
# Re-run attempts run only the previously-failed specs (env.specs), so record them.
CYPRESS_VIDEO: ${{ env.specs && 'true' || 'false' }}
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
@@ -205,6 +221,7 @@ jobs:
shell: bash
env:
SPEC_PATH: ${{ env.specs || inputs.specs }}
E2E_SPEC_PATHS_FILE: ${{ steps.selection.outputs.paths-file }}
# Re-run attempts run only the previously-failed specs (env.specs), so record them.
CYPRESS_VIDEO: ${{ env.specs && 'true' || 'false' }}
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}

View File

@@ -3,10 +3,8 @@ name: E2E Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
specs:
test-plan-artifact-id:
description: "ID of this run's test-plan.json artifact, '' to run the full suite."
type: string
required: false
default: ""
@@ -16,11 +14,18 @@ concurrency:
cancel-in-progress: true
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: e2e_all
e2e-matrix-builder:
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/e2e-matrix-builder.yml
with:
chunks: 50
specs: ${{ inputs.specs }}
test-plan-artifact-id: ${{ inputs.test-plan-artifact-id }}
reset-e2e-test-comment:
if: ${{ github.event_name == 'pull_request' }}
@@ -58,8 +63,9 @@ jobs:
uses: ./.github/workflows/e2e-test.yml
needs:
- e2e-matrix-builder
if: |
!cancelled() && !inputs.skip
# No status function, so a skipped or failed matrix builder keeps this out: the strategy below
# cannot parse a matrix it never produced. The builder itself only runs when the gate says so.
if: ${{ needs.e2e-matrix-builder.outputs.hasTests == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.e2e-matrix-builder.outputs.matrix) }}
@@ -68,6 +74,7 @@ jobs:
name: ${{ matrix.name }}
edition: ${{ matrix.edition }}
specs: ${{ matrix.specs }}
test-plan-artifact-id: ${{ needs.e2e-matrix-builder.outputs.test-plan-artifact-id }}
total_chunks: ${{ fromJson(needs.e2e-matrix-builder.outputs.regularChunks) }}
split_index: ${{ strategy.job-index }}
tags: ${{ matrix.tags }}
@@ -78,9 +85,9 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
needs: e2e-tests
if: always() && !cancelled() && github.ref == 'refs/heads/master'
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
sparse-checkout: |
.github
@@ -137,24 +144,35 @@ jobs:
e2e-tests-result:
needs:
- gate
- e2e-matrix-builder
- e2e-tests
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: e2e-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
env:
E2E_TESTS_RESULT: ${{ needs.e2e-tests.result }}
MATRIX_RESULT: ${{ needs.e2e-matrix-builder.result }}
HAS_TESTS: ${{ needs.e2e-matrix-builder.outputs.hasTests }}
GATE_RESULT: ${{ needs.gate.result }}
RUN: ${{ needs.gate.outputs.run }}
steps:
- name: Stub
if: ${{ needs.e2e-tests.result == 'skipped' }}
run: |
echo "Didn't run due to conditional filtering"
- name: Test results
if: ${{ needs.e2e-tests.result != 'skipped' }}
run: | # sh
echo "E2E Tests: ${{ needs.e2e-tests.result }}"
if [ "${{ needs.e2e-tests.result }}" != "success" ]; then
if [ "$GATE_RESULT" != "success" ]; then
echo "The test gate failed, so the suite never ran"
exit 1
elif [ "$RUN" != "true" ]; then
echo "Didn't run due to conditional filtering"
elif [ "$MATRIX_RESULT" != "success" ]; then
echo "E2E matrix builder failed"
exit 1
elif [ "$HAS_TESTS" = "false" ]; then
echo "The plan selected no E2E specs"
elif [ "$E2E_TESTS_RESULT" != "success" ]; then
echo "E2E Tests failed"
exit 1
else
echo "E2E Tests passed"
fi

View File

@@ -38,7 +38,7 @@ jobs:
CYPRESS_MB_PRO_SELF_HOSTED_TOKEN: ${{ secrets.STAGING_MB_PRO_SELF_HOSTED_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare Docker containers
uses: ./.github/actions/e2e-prepare-containers
@@ -214,7 +214,7 @@ jobs:
outputs:
target-ref: ${{ steps.get-target-ref.outputs.value }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 2
@@ -251,7 +251,7 @@ jobs:
steps:
- name: Check out the code from the previous commit
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ needs.decide-target-ref.outputs.target-ref }}

View File

@@ -28,7 +28,7 @@ jobs:
branch_name: ${{ fromJson(steps.build-sample-apps-data.outputs.result).branch_name }}
matrix: ${{ toJSON(fromJson(steps.build-sample-apps-data.outputs.result).matrix) }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
@@ -111,7 +111,7 @@ jobs:
actions: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/maximize-disk-space
@@ -223,7 +223,7 @@ jobs:
actions: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare Shoppy E2E environment
uses: ./.github/actions/prepare-shoppy-e2e
@@ -286,7 +286,7 @@ jobs:
actions: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare Shoppy E2E environment
uses: ./.github/actions/prepare-shoppy-e2e
@@ -384,7 +384,7 @@ jobs:
actions: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Retrieve uberjar artifact for ee
uses: ./.github/actions/fetch-artifact

View File

@@ -14,7 +14,7 @@ jobs:
outputs:
frontend_embedding_sdk_package_sources: ${{ steps.changes.outputs.frontend_embedding_sdk_package_sources }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
@@ -31,7 +31,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: jesusvasquez333/verify-pr-label-action@657d111bbbe13e22bbd55870f1813c699bde1401 # v1.4.0
id: verify-release-notes-labels
with:

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -2,43 +2,27 @@ name: SDK
on:
workflow_call:
inputs:
cli-snippets-type-check:
description: "Whether to run cli snippets type check."
required: true
type: boolean
docs-snippets-type-check:
description: "Whether to run docs snippets type check."
required: true
type: boolean
documentation-validation:
description: "Whether to run documentation generation validation check."
required: true
type: boolean
component-tests:
description: "Whether to run component tests."
required: true
type: boolean
host-sample-apps-tests:
description: "Whether to run host and sample apps compatibility tests."
required: true
type: boolean
skip:
description: Whether to skip running tests due to one of the artifacts failing to build.
required: true
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
# Each job below maps to its own filter, so they read `matched` rather than `run`.
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: >-
frontend_sources embedding_documentation embedding_sdk_components
embedding_sdk_host_sample_apps
embedding-sdk-cli-snippets-type-check:
if: inputs.cli-snippets-type-check && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Retrieve Embedding SDK dist artifact
@@ -50,11 +34,12 @@ jobs:
run: bun run embedding-sdk:cli-snippets:type-check
embedding-sdk-docs-snippets-type-check:
if: inputs.docs-snippets-type-check && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_documentation') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Retrieve Embedding SDK dist artifact
@@ -66,11 +51,12 @@ jobs:
run: bun run embedding-sdk:docs-snippets:type-check
embedding-sdk-api-documentation-generation-validation:
if: inputs.documentation-validation && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Prepare front-end environment
@@ -116,23 +102,26 @@ jobs:
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
embedding-sdk-component-tests:
name: "SDK component tests"
if: inputs.component-tests && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_components') }}
uses: ./.github/workflows/embedding-sdk-component-tests.yml
secrets: inherit
not-required-embedding-sdk-apps-compatibility-tests:
name: "SDK e2e tests for compatibility with (host/sample) apps"
if: inputs.host-sample-apps-tests && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_host_sample_apps') }}
uses: ./.github/workflows/embedding-sdk-host-sample-apps-tests.yml
secrets: inherit
embedding-sdk-tests-result:
needs:
- gate
- embedding-sdk-cli-snippets-type-check
- embedding-sdk-docs-snippets-type-check
- embedding-sdk-api-documentation-generation-validation
- embedding-sdk-component-tests
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/prepare-node-bun
- name: Get Emotion file count
id: get-emotion-file-count

View File

@@ -65,7 +65,7 @@ jobs:
|| (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'PR-Env-self-hosting')) }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
with:

View File

@@ -57,7 +57,7 @@ jobs:
stats_dump: ${{ steps.label.outputs.stats_dump }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Resolve Labels
id: label
env:

View File

@@ -46,7 +46,7 @@ jobs:
timeout-minutes: 60
name: Stress test frontend unit test flake fix
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment

View File

@@ -3,36 +3,10 @@ name: Frontend
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
skip-lint:
type: boolean
default: false
skip-custom-viz:
type: boolean
default: false
# ID of this run's test-plan.json artifact, or '' to run the full suite.
test-plan-artifact-id:
type: string
default: ""
skip-ci-scripts:
type: boolean
default: false
workflow_dispatch:
inputs:
skip:
description: "Skip main frontend jobs (type-check, unit tests, timezone tests)"
type: boolean
default: false
skip-lint:
description: "Skip linting jobs (oxfmt, eslint, css variables check)"
type: boolean
default: false
skip-custom-viz:
description: "Skip custom-viz package tests"
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-frontend
@@ -46,46 +20,43 @@ env:
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
jobs:
# Each job below maps to its own filter, so they read `matched` rather than `run`.
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: frontend_all e2e_all custom_viz_all ci_scripts
fe-lint:
if: ${{ !inputs.skip-lint }}
needs: gate
# Linting covers the e2e specs too.
if: >-
${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all')
|| contains(fromJSON(needs.gate.outputs.matched), 'e2e_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Cache spec
id: cache-spec
run: .github/scripts/cache-keys.sh | tee -a "$GITHUB_OUTPUT"
# Small enough, and written by few enough jobs, that this one reads and writes its own cache
# rather than waiting on cache-warm.
- name: ESLint cache
uses: actions/cache@v5
with:
path: ${{ steps.cache-spec.outputs.eslint-path }}
key: ${{ steps.cache-spec.outputs.eslint-key }}
restore-keys: ${{ steps.cache-spec.outputs.eslint-restore-key }}
- name: Compile CLJS
run: bun run build-pure:cljs
- name: Find CSS variables that are used but never defined
run: bun run find-never-defined-css-variables
- name: Run oxfmt formatting linter
run: bun run lint-format-pure
- name: Run ESLint linter
run: bun run lint-eslint-pure
- name: Run oxlint linter (including module boundaries)
run: bun run lint-oxlint
- name: Verify lint configuration and behavior
run: bun run test-oxlint
- name: Run CSS linter
run: bun run lint-css
fe-type-check:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -96,7 +67,8 @@ jobs:
name: Check types
fe-tests-unit:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
strategy:
@@ -109,7 +81,7 @@ jobs:
JEST_JUNIT_OUTPUT_NAME: test-report-frontend-unit.xml
UNIT_TEST_RESULTS_FILE: unit-test-results.json
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -160,11 +132,12 @@ jobs:
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
fe-tests-timezones:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Run frontend timezones tests
@@ -172,11 +145,12 @@ jobs:
# No CLJS compile: the CI-script specs don't import app code.
fe-tests-ci-scripts:
if: ${{ !inputs.skip-ci-scripts }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'ci_scripts') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Run CI script tests
@@ -185,14 +159,15 @@ jobs:
# The custom-viz package is an isolated Bun workspace under
# enterprise/frontend/src/custom-viz
fe-custom-viz:
if: ${{ !inputs.skip-custom-viz }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'custom_viz_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
defaults:
run:
working-directory: enterprise/frontend/src/custom-viz
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare Node + Bun environment
uses: ./.github/actions/prepare-node-bun
- name: Install dependencies
@@ -208,13 +183,14 @@ jobs:
fe-tests-result:
needs:
- gate
- fe-lint
- fe-type-check
- fe-tests-unit
- fe-tests-timezones
- fe-tests-ci-scripts
- fe-custom-viz
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -33,6 +33,7 @@ concurrency:
env:
AUTOMATION_BRANCH: automation/shrink-ratchets
KONDO_RATCHETS_FILE: .clj-kondo/ratchets.edn
TEST_KONDO_RATCHETS_FILE: .clj-kondo/ratchets-test.edn
MODULE_RATCHETS_FILE: .clj-kondo/config/modules/ratchets.edn
jobs:
@@ -45,22 +46,22 @@ jobs:
steps:
# `ref: master` checks out the branch head at checkout time rather than the pushed commit, so a run
# that queued behind another one starts from the newest master it can see.
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
ref: master
# The persisted GITHUB_TOKEN header would override the automation token in the push URL and
# get the force-push rejected. Nothing here needs it: the repo is public, so the fetch of the
# automation branch is anonymous, and every gh call carries its own token.
persist-credentials: false
- name: Set up Java for cold dependency resolution
uses: actions/setup-java@v5.2.0
with:
distribution: temurin
java-version: 25
# The shrink runs clj-kondo through the Clojure CLI to attribute :discouraged-var/:discouraged-namespace
# ignores, so it needs the CLI as well as the JDK.
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Shrink ratchets
run: |
cp "$KONDO_RATCHETS_FILE" "$RUNNER_TEMP/kondo-ratchets-before.edn"
cp "$MODULE_RATCHETS_FILE" "$RUNNER_TEMP/module-ratchets-before.edn"
cp "$TEST_KONDO_RATCHETS_FILE" "$RUNNER_TEMP/test-kondo-ratchets-before.edn"
./bin/mage kondo-ratchets-shrink
# Two independent outputs: `push` says the branch needs a new commit, `pr` names the open automation
# PR if there is one. Approval and auto-merge key on `pr` alone, so a run that failed after opening
@@ -79,7 +80,7 @@ jobs:
--jq '.[0].number // empty')
echo "pr=$open_pr" >> "$GITHUB_OUTPUT"
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"; then
if git diff --quiet -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"; then
echo "Ratchet budgets already match master $(git rev-parse --short HEAD)"
[ -z "$open_pr" ] || echo "The open automation PR #$open_pr is re-approved below"
echo "push=false" >> "$GITHUB_OUTPUT"
@@ -95,7 +96,7 @@ jobs:
# lazily, so a pending UNKNOWN reads as not conflicted and the next run settles it.
if [ -n "$open_pr" ] \
&& git fetch --quiet --depth=1 origin "refs/heads/$AUTOMATION_BRANCH" 2>/dev/null \
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" \
&& git diff --quiet FETCH_HEAD -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE" \
&& [ "$(gh pr view "$open_pr" --repo "$GITHUB_REPOSITORY" \
--json mergeable --jq .mergeable)" != CONFLICTING ]; then
echo "The automation PR #$open_pr already carries this result"
@@ -104,7 +105,7 @@ jobs:
exit 0
fi
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
git diff --stat -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"
echo "push=true" >> "$GITHUB_OUTPUT"
- name: Update the automation PR
id: deliver
@@ -122,12 +123,14 @@ jobs:
"$KONDO_RATCHETS_FILE" \
"$RUNNER_TEMP/module-ratchets-before.edn" \
"$MODULE_RATCHETS_FILE" \
"$RUNNER_TEMP/test-kondo-ratchets-before.edn" \
"$TEST_KONDO_RATCHETS_FILE" \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
> "$RUNNER_TEMP/ratchets-pr-body.md"
git config user.name github-actions
git config user.email github-actions@github.com
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE"
git add -- "$KONDO_RATCHETS_FILE" "$MODULE_RATCHETS_FILE" "$TEST_KONDO_RATCHETS_FILE"
git commit --quiet -m "Tighten ratchets"
git push --force --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \

View File

@@ -21,7 +21,7 @@ jobs:
# disables overlay DB cache
CODEQL_OVERLAY_DATABASE_MODE: none
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
@@ -50,7 +50,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
name: Run Trivy for (${{ matrix.edition }})
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Run Vulnerability Scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
@@ -75,7 +75,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Generate all pom.xml
@@ -105,7 +105,7 @@ jobs:
package-manager: yarn
category: release-helper
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: 'package.json'
@@ -200,7 +200,7 @@ jobs:
file: modules/drivers/vertica/pom.xml
category: driver-vertica
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
- name: Download pom files
uses: actions/download-artifact@v8
@@ -224,7 +224,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
sparse-checkout: |
release

View File

@@ -24,7 +24,7 @@ jobs:
workflows_files: ${{ steps.changes.outputs.workflows_files }}
yaml: ${{ steps.changes.outputs.yaml }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
@@ -39,7 +39,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Setup frontend env
if: |
@@ -106,7 +106,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
@@ -130,7 +130,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Java
uses: actions/setup-java@v5.2.0
with:

View File

@@ -138,7 +138,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Prepare frontend environment
uses: ./.github/actions/prepare-frontend
@@ -150,7 +150,7 @@ jobs:
run: NODE_ENV=development bun run build-pure:cljs
- name: Build Storybook
run: bun run build-storybook
run: bun run build-storybook:test
env:
STORYBOOK_STORIES_FILTER: ${{ needs.detect-changed-stories.outputs.story_files }}

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
ref: ${{ github.head_ref }}

Some files were not shown because too many files have changed in this diff Show More