The file bundled two unrelated concerns. Search tracking now lives in
api/analytics/search.ts next to its only caller, and the export
mutation in api/analytics/export.ts. The analytics/ directory satisfies
the no-analytics-import-outside-analytics-files rule that pinned the
old filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Show pinned items in the collection contents list
* Update e2e specs for pinned items appearing in the contents list
* Simplify the collection empty state offset to a plain vh value
* Simplify bookmark state, skip the pinned query in trash, and drop the dead toolbar prop
* Use a real pointer click for the pinned item menu so hover cannot be raced away
* fix: run source-card field-type inference before cross-stage inference (BOT-1604)
The repair pipeline mini-resolves earlier stages to type a later stage's
cross-stage field refs, but ran that pass before stamping base-type onto a
source-card stage's own bare-name field refs. The mini-resolve of a
source-card-sourced prefix then always failed schema validation and was
silently skipped, leaving cross-stage refs untyped and the query permanently
unrunnable for any multi-stage construct_notebook_query built on a saved
question/model.
* docs: fix stale pass numbering in repair pipeline docstring (BOT-1604)
Number the new source-card field-type pass 4.5 and keep the pre-existing
cross-stage pass as 5, instead of bumping cross-stage to 5.5 - this leaves
the other in-file references to "Pass 5" correct and matches the file's
existing fractional-pass-number convention.
- metric-details surfaces the metric's FK-less join dimensions (which the
consumer-framed dimension list silently drops), each carrying the exact
portable join clause to paste into `joins:` and an alias-qualified field
reference. Fail closed: an entry is emitted only when its join clause exports
and targets a real table; join-key columns are excluded.
- the :no-fk-path repair error is now actionable, pointing the agent at the
metric's /dimensions resource for the join to add.
Also fix a pre-existing option-key typo in resources.clj
(:with-queryable-dimensions / :with-field-values -> the ?-suffixed forms the
readers expect) so the plain metric resource correctly skips dimensions and
field-value fetches.
* docs - host the model button image in the repo
The troubleshooting guide linked the model button screenshot from
https://www.metabase.com/learn/images/models/model-icon.png, which now
404s with no redirect. Learn images were moved out of the marketing repo
into a CDN bucket, and that key space has been renamed several times with
old keys hard-deleted rather than redirected.
This broke the nightly Update Docs job in metabase/docs.metabase.github.io
on Aug 8-10. It only goes green today because the link gate tolerates one
broken link and the other one got fixed upstream.
Commit the image here instead of chasing the CDN. It matches how every
other image in docs/ is referenced, it gets versioned along with the
release branch so older doc snapshots keep the image they shipped with,
and it is the only form our link checkers actually validate: lychee runs
--offline (external URLs skipped) and the docs site's htmlproofer runs
--disable-external.
The source file was a WebP served with a .png extension, so it has been
transcoded to a real PNG - pngcrush in optimize-images.yml would have
failed on the original bytes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Optimized 1 images, saved 1.8 KB (1.7% smaller)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
fix(ui): align Alert icon with text when there is no title
Icon box was locked to the 24px title row, so title-less alerts (e.g.
incremental transform warning) hung the icon above a single line of text.
Without a title, icon and close button now center on the first message line.
* BOT-1953: Suppress the Metabot sidebar on the conversation route
The full-page Metabot surface disables the app-bar button and the Cmd+E
shortcut so the chat cannot open on top of itself. That check compared the
pathname against /question/ask exactly, but #77549 added a second route
rendering the same MetabotAsk, /metabot/conversation/:convoId, which
MetabotAsk itself redirects to after the first message. The sidebar came
back from there on.
useIsFullPageMetabot matches either route and drives both suppression
sites. useIsAskPage stays strict for MetabotAsk's redirect, which must not
re-fire once it is already on the conversation route. The route path now
has one spelling, Urls.CONVERSATION_BASE_PATH, so a rename cannot silently
turn the suppression off again.
The conversation matcher pins the route shape rather than a prefix, with an
optional trailing slash because react-router matches the route either way.
Cmd+E had no coverage because a note in the test harness claimed tinykeys
shortcuts do not fire under jsdom. They do, bound to Control rather than
Meta since jsdom reports an empty navigator.platform.
* BOT-1953: Match the Metabot route predicates with react-router
useIsAskPage compared the pathname to /question/ask by string equality,
so /question/ask/, which react-router serves as the same page, left the
app-bar button and Cmd/Ctrl+E active on a full-page Metabot surface.
Both predicates now go through matchPath, the matcher the router itself
uses: trailing slash and case tolerated, extra segments and an empty
conversation id rejected.
* BOT-1953: Keep the urls module to plain URL builders
The two route predicates move into the metabot hooks that use them, which
now call matchPath directly; urls/ goes back to exporting only functions
that build strings. Their url-level specs go with them, since the metabot
ui and app-bar button specs already pin the behavior.
* remove dead workflows and support code
# Conflicts:
# .github/workflows/build-for-release.yml
# .github/workflows/main.yml
# .github/workflows/presto-kerberos-integration-test.yml
* remove dead clojure files
* Stop autopinning models and metrics on creation
* Fix e2e assertions that expected new models to be pinned
* Remove negative-case pin tests per review
* Add lazyModalRoute for modals under a code-split page
* Split documents into its own chunk
* Prefetch the documents chunk from links into it
* Prefetch the comments sidesheet with the document page
* Fix two Metabot tool failures surfaced by the reliability data
get_transform_details passed the transform's query map straight into the XML
formatter. clojure.data.xml reads a map in second position as an attribute map
and then throws on MBQL 5's namespaced :lib/type key, so every query-source
transform failed and only Python ones (no :query key) got through. That is 72
errors in 75 calls over 90 days, across 15+ instances. The query now renders as
SQL text, matching how the line below already handles :target.
create_sql_query has two implementations sharing one tool name, and neither used
the shared agent-error contract. The :sql profile's code-edit variant had no
error handling at all, so the :agent-error? exceptions validate-database-access
raises escaped uncaught and were tracked as tool failures. That variant errored
on 26% of calls against 0.04% for the :internal one. Both now delegate to
handle-agent-error, as the other tools do: the intended validation message goes
back to the model, and anything unexpected is rethrown so real defects stay
visible in the telemetry rather than being reported as successes.
Neither tool had a test; both now do.
* Address review: transform rendering and 403 classification
Transform source queries now render verbatim (data.xml escaped <, >, &,
which breaks old_string matching against the real query), python bodies
render, and the source type attribute is no longer a keyword literal.
Notebook-built queries fall back to EDN with the attached metadata
provider stripped, shared with transform->xml, whose string guard always
dropped the normalized (map) query.
validate-database-access tags the plain 403 from read-check so both
create_sql_query variants classify permission failures as agent errors,
and the pre-classification ERROR logs are gone; run-tool already logs
both paths at the right levels.
* Tolerate string queries in transform-query->text
Legacy transforms stored raw SQL strings under :query, and orphaned sources
skip normalization, so the query reaching this helper is not always a map.
dissoc on a string threw ClassCastException; the search formatter's old
string guard had tolerated exactly this shape. Strings now pass through
verbatim, and the helper's namespace gets its first tests.
* Use a doc link for escape-xml in the escape-xml-content docstring
* Read orphaned sources' string keys in extract-sql-content
Orphaned transform sources skip normalization and keep their JSON string
keys, so the keyword probes missed them and the model got EDN of a
string-keyed map instead of the SQL. Multi-stage queries now fall through
to the EDN branch too: stage 0 alone is partial SQL and hid the rest.
Also tags the read-check 404 alongside the 403, closing the race where
the database vanishes between the exists? check and the read.
Update E2E test timings with averaged data
Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).
🤖 Generated automatically by update-e2e-timings workflow
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
We no longer upload JUnit XML to S3, so the action and all 47 call sites
go. Test results stay visible through the JUnit reporter and CI
Conductor.
`id-token: write` drops from minimal-tests and transforms-python-stress-test,
whose only OIDC consumer was the upload's AWS credential handshake. It
stays in run-tests, which must keep granting it to drivers.yml's ECR login.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings `actionlint` (v1.7.12, the version CI pins) to zero findings, down
from 186.
Real problems, not just lint noise:
- `pr-env-destroy-manual.yml` used an unquoted YAML scalar containing
` #`, so YAML truncated the command at the comment marker and the step
actually ran `echo "Destroying PR environment for PR` — an unterminated
quote. Moved to a block scalar with the PR number in an env var.
- `yaml.yml`'s `yaml-linter` job read `needs.files-changed.outputs.yaml`
without declaring `needs:`, so its `if` was always false and the linter
never ran.
- `containerize-uberjar.yml` referenced `github.event.inputs.commit` in
three places, but its `workflow_dispatch` declared no inputs.
- The three Slack notification workflows interpolated
`github.event.issue.title` straight into a `github-script` body; it now
goes through an env var.
- Several step-summary blocks wrote `echo '... ${{ ... }}'` in single
quotes, which never expanded as intended.
The rest is mechanical: quoting `$GITHUB_OUTPUT`/`$GITHUB_ENV`/
`$GITHUB_STEP_SUMMARY` redirect targets, grouping consecutive appends
into `{ ...; } >> file`, replacing `ls | xargs basename` with a glob
loop, `A && B || C` with `if/then`, `[[ "$?" != 0 ]]` with `if ! cmd`,
and assorted quoting.
`containerize-jar.yml`'s `release` input is now a real `boolean` rather
than a string of `"true"`/`"false"`, which also removes the
`${{ 'true' }}` workaround at its call sites.
Adds `.github/actionlint.yaml` to declare the `arm-ubuntu-24-2core`
self-hosted runner label.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Split the research catalog tool to keep its payload bounded
get_research_candidates returned every research-eligible metric with every
candidate dimension inlined — on instances with many metrics the JSON blob
overwhelmed the following LLM request's context.
Two-tier replacement:
- list_research_metrics (new): slim index — id, name, description
(truncated to 150 chars), in_library — no dimensions. Optional q filters
by substring on metric and dimension names.
- get_research_candidates now requires metric_ids (max 20 per call) and/or
q, and returns a deduped shape: same-group dimensions resolve to the same
underlying Field, so descriptive fields (name, types, interestingness)
are stated once per dimension group, with each metric's dimension id and
per-metric display name (renames are common) in
dimension_id_and_name_by_metric. A q match beyond the cap is ranked
library-first-then-interestingness, truncated, and stamped
{truncated, shown, matched}. Argument-less calls return an instructive
error instead of the catalog.
add_research_groups is unchanged — the ids the model echoes back are the
same per-metric dimension ids as before. explorations.selmer updated to
describe the new contract (factual sync only).
* Cap the list_research_metrics index
Load-testing with 3,000 metrics (~62k dimensions) showed the unfiltered
index was still a ~394 KB / ~100k-token blob — slim rows don't save you
when there are thousands of them. Above 500 matches the index is now
ranked library-first-then-interestingness, truncated, and stamped
{truncated, shown, matched} — the same contract as the
get_research_candidates q cap — so the model knows to narrow with q.
Under the cap, rows keep their natural library-first-then-name order.
At the 3,000-metric load the unfiltered index drops to ~66 KB; q-filtered
responses are unaffected.
* Code review cleanup
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Declare two request params the handlers read but never declared
Request decoding drops keys a schema does not declare, so a handler that
reads an undeclared key silently gets nil.
- POST /api/card/pivot/:card-id/query destructured `parameters` and
passed it to `process-query-for-card`, but declared only
`ignore_cache` (and `dashboard_id`) — parameterised pivot queries
silently ignored their parameters. Declared exactly as the non-pivot
sibling in the same namespace does.
- POST /api/notify/db/:id destructured `synchronous?`, the documented
flag that makes the sync run synchronously, without declaring it, so
every call ran asynchronously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add regression tests for both undeclared params
Each test fails on master and passes with the schema fix:
- pivot: reuses the native card whose `{{category}}` template tag is
`:required`, so the query only runs if the request body's value reaches
the query processor. Without the declaration the request 500s.
- notify: blocks the redefined `sync-database!` on a promise and asserts
the response is still outstanding, which can only hold when the handler
derefs the sync future. Without the declaration `{:success true}` comes
back immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Avoid row-constructor IN in multi-catalog Databricks sync (GHY-4263)
On a multi-catalog Databricks connection, describe-fields and describe-fks
filtered schemas with `(table_catalog, table_schema) IN ((?, ?))`. Databricks'
planner is orders of magnitude slower on that form against
`system.information_schema.columns` than on AND-ed equalities: on a Unity
Catalog workspace with ~220k columns the same query drops from 3m27s to 2.0s
when the predicate is rewritten. sync-fields issues one such query per schema,
so a ~50-schema sync took hours and never ran to completion.
Emit `catalog = ? AND schema = ?` per pair, OR-ed together for multi-pair
calls, instead of the row constructor.
* Update comment
* groundwork cleanup & gap fixing in provider adapters
* moonshot / kimi BE support
* FE support
* fix provider name & default model to kimi k3
* clean comments
* Honor suite-level quarantine rules in the gate
/api/quarantine serves a top-level `suites` array of glob rules alongside
`tests`. A rule matching the job's reporting suite quarantines the whole
suite, so the gate passes without comparing failures test by test.
Globs match as an unanchored, case-insensitive substring and support `*`,
`?` and `{a,b}` — a subset of the minimatch semantics ci-conductor
validates against, compiled inline because this package carries no
runtime dependencies and CI runs it straight from the checkout.
Both response keys are read as optional, so the script and the server
need no lockstep deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop the driver "info" status
Suite-level quarantine rules say the same thing from CI Conductor, for
every suite rather than drivers alone: run it, but don't let it gate.
mage now reads only "skip" from ci-test-config.json, and no longer emits
a per-driver status; the workflows drop the plumbing that carried it,
along with the job summary and PR comment that reported an info driver's
real outcome. The quarantine gate's own verdict and CI Conductor's UI
cover that visibility.
"skip" stays: not running a cloud driver at all saves real spend and has
no equivalent in the quarantine endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Remove dead checks from drivers
* Tweak comment
* Drop the "info" status from notification
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upload named the artifact after the event's own commit, while every
lookup around it — the concurrency group, check-existing-artifacts, the
bundle-stats upload and check-jar-health — keys on the dispatch input
first. A dispatch with an explicit commit therefore built that commit and
uploaded it under the dispatch branch's tip.
Inline the composite action into its only caller so the name comes from
the same expression as the lookups instead of a second copy of the
fallback chain.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ignore missing sandbox/perms features when syncing
for consistency with other code, we should not worry about whether the
premium feature in question is actually enabled. If sandboxing is
enabled and configured and we're runnning EE code we should respect it
when a new table/group/db is synced.
* dedupe implied rows when setting data permissions
build-database-permission and build-table-permissions merge the results
of their recursive implication calls with `merge-with concat`, and
several implication paths can produce the same row:
- setting view-data to :blocked implies `transforms :no` both directly
and via the implied `create-queries :no`, so the DB-level path
INSERTed the transforms row twice
- coalescing table perms to a DB-level row (all tables agreeing on a
create-queries value) implies a DB-level `view-data :unrestricted`
row, and the recursive table-level view-data call coalesces to that
same row, so the table-level path INSERTed the view-data row twice
Nothing deduped the merged :to-insert list, so every such call silently
persisted duplicate data_permissions rows. Duplicate DB-level rows are
the arming condition for the UXW-4927 sandbox data leak: the
going-granular path for newly-synced tables only deleted one row per
(group, perm-type), and a surviving duplicate out-votes the new table's
:blocked row at enforcement time.
* make the going-granular DELETE+INSERT atomic
The bulk path for newly-synced tables issued its DELETE and INSERT
outside any transaction, so a failed insert — e.g. the unique-constraint
violation added in the next commit — could leave the DB-level rows
deleted with the table-level expansion half-written. Wrap the pair in a
transaction. Nested transactions are savepoint-based (see
metabase.app-db.connection), so this is safe under callers that already
run inside a transaction, like the Table after-insert hook.
* dedupe data_permissions and add a unique constraint
data_permissions had no unique constraint on
(group_id, perm_type, db_id, table_id), so nothing prevented duplicate
rows for the same scope, and duplicate DB-level rows armed the UXW-4927
sandbox data leak. Three v58 changesets (v58 is the oldest release this
will be backported to):
1. Delete existing duplicates, keeping the most restrictive perm_value
per scope, lowest id as tie-break.
2. Add a generated column unique_perms_helper = COALESCE(table_id, -1).
All three app DBs treat NULLs as distinct in unique indexes, H2 has
no partial indexes, and MariaDB has no functional indexes, so a
NULL-safe generated column is the only portable mechanism — same
approach as metabase_table.unique_table_helper (v55.2025-05-19).
Unlike that precedent the base column carries an ON DELETE CASCADE
foreign key, which MySQL rejects for STORED generated columns
(errno 1215), so MySQL/MariaDB use a VIRTUAL column; Postgres has no
virtual generated columns and no such restriction, so it keeps
STORED. Dedupe, constraint enforcement, and FK cascade verified on
Postgres 17, MySQL 8.0, and MariaDB 10.6.
3. Add a unique constraint on
(group_id, perm_type, db_id, unique_perms_helper).
Inserts stay fail-loud: after the previous commits, a constraint
violation can only mean a genuine bug, and it should abort the write
(the going-granular DELETE+INSERT now runs in a transaction) rather
than be silently absorbed.
The generated column is excluded from the model (after-select) and from
load-from-h2/dump-to-h2 copies, mirroring unique_table_helper. Tests
that inserted raw duplicate rows on top of the hook-created defaults now
set permissions through the API instead.
* sidebar empty states
* fix e2e test
* fix tests after rebase on master
* keep empty failed/canceled threads in the sidebar so they can be restarted
* review comment
* Prefetch a code-split page when a link to it is hovered
* Show a fallback while the router hydrates a lazy route
* Split the query builder into its own chunk
* Move the query builder's action types off the actions barrel
* Split the question selectors into their own module
* Read the question selectors from the leaf module in the app shell
* Import the query builder's view components directly in table editing
* Keep a pending lazy navigation from being replaced
* Prefetch the query builder chunk from links into it
* Drop the redundant job id from the granular-rerun artifact name
For most drivers `github.job` and `junit-name` are the same string, so the name
came out as `failed-tests-be-tests-snowflake-ee-be-tests-snowflake-ee-Driver
Tests-attempt-1`.
`junit-name` plus `artifact-suffix` already discriminate every call site in the
artifact namespace that run-tests.yml shares between app-db.yml, backend.yml,
drivers.yml and semantic-search.yml — including the `be-tests-postgres` job that
exists in both app-db.yml and drivers.yml, where the two derive the same
`be-tests-postgres-ee` junit-name but differ on suffix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop the rerun artifact restore from erroring when there is nothing to restore
An attempt that fails with nothing attributable to a var uploads no failed-tests
artifact, so the next attempt's restore legitimately finds none and falls back to
the full suite. That is the designed path, but `download-artifact`'s `name` input
treats a miss as an error, printing a red annotation on a job whose real failure
is elsewhere.
`pattern` filters the artifact list the action already fetches and downloads
nothing on zero matches, so this is the same single API call as before and costs
no extra rate-limit budget. The pattern is an exact literal, and artifact names
are unique within a run, so it matches at most one.
`merge-multiple` is deliberately left unset: a single match already lands at
`target/failed-tests`, and setting it would merge a hypothetical second match into
that one file — letting a matrix leg rerun another leg's failures — where the
default nests them and falls back to the full suite instead.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
* Render pinned collection items in a single grid of compact static cards
* Fix pinned-card e2e waits that relied on removed previews
* Register explorations as pinnable and movable drag types
* Address review comments
* Memoize search model hooks
Cache model hooks by the registered spec methods using a single-entry FIFO. This avoids repeated per-row hook construction while bounding reload-time cache growth and keeping invalidation best-effort.
* Trim the model-hooks cache test
The finally-clause recompute was dead: restoring the method restores the cache key, so
the probe's entry can never be hit again. Split the caching assertion out of the
invalidation test.
* Assert the model-hooks cache is warm before probing invalidation
The first call registers spec methods as it resolves models, so it caches under a key
that is stale by the time it returns. Probing from that state proves nothing: the miss
comes from the stale key, not from the redefinition.
* Note that the model-hooks cache key is only an invalidation token
* Set *warn-on-reflection* in the search spec test namespace
Clj-Kondo flags any interop call in a namespace that leaves the var unset; the
.addMethod probes are the first interop here.
* [Metabot] Pretty print tool call results
Split a tool call's result into Response, Structured output and Other
fields sections instead of dumping the raw JSON-encoded result map into
a single JSON code editor. Text output renders with real newlines, and
structured_output (also the kebab-case spelling kept by rows migrated
from the v1 format) gets its own section.
* Simplify the tool call result parser
* Centralize remote-sync read-only handling in useTransformPermissions
Fixes Create index (and other transform mutations) being offered in
remote-sync read-only mode and failing with 403. The hook now returns
combined readOnly plus granular permissionsReadOnly/remoteSyncReadOnly,
and all direct getIsRemoteSyncReadOnly usages across transforms are
replaced with hook values or props. Run button stays gated by
permissions only (running is allowed in remote-sync read-only).
* Rename RunButtonSection readOnly prop to permissionsReadOnly
* Drop redundant remoteSyncReadOnly from incremental switch disabled state
* Fix plugin setup order in indexes page spec
* Enable Kimi K3 via OpenRouter
* Enable DeepSeek V4 Flash via OpenRouter
* Raise the conversation-title output budget from 128 to 512 tokens
Reasoning models spend the output budget on reasoning before emitting the forced
tool call, so at 128 tokens the structured call often returns no tool call at all
and no title is saved.
* Fixed failing CI
* Add query timestamp, refactor error marker
* no-mbql-introspection pass
* Order temporal-axis exploration variants by date, not aggregation
When the row cap fires on a time series, aggregation-desc ordering keeps
the highest-count buckets scattered across the full range: gaps in the
x-axis, old spikes beating recent data. Temporal axes now order by the
temporal breakout desc (aggregation desc as tiebreak) before the limit,
so the most recent contiguous window survives.
- time-facet: order by the temporal breakout (second breakout) desc
- default: branch on dim type — temporal dims by recency, categorical
dims keep aggregation desc
- per-value-time-series: gains the same date-desc cap (had none, so a
single fine-grained series could blow query-caching-max-kb)
- all three also filter the NULL date bucket: it can't render on a time
axis, and its position under ORDER BY DESC is warehouse-dependent
(Postgres NULLs first, MySQL last), making cap survival
non-deterministic otherwise
- temporal-pattern-day/hour unchanged (bounded, no cap interaction)
* Added debug info to interestingness calculations
* even less mbql introspection
* oops, delete .scratch
* Sidebar UI fixes - error marker, loading shimmer
* Interestingness >= 0.7
* Adjust prompts list animation
* Fix e2e
* Note that the LLM query planner is a POC, not used in production
* Fix searching for explorations
* Use llm-metabot-provider for everything
* Log interestingless calculation as info for now
* Log interestingless calculation back as debug
* Fix FE lint
* fix ai summary showing dirty while generating
* Use llm-metabot-provider
* collection breadcrumbs
* add back null values for temporal variants
* order by for temporal pattern variants
* fix top-n-other
* doh, fix temporal pattern queries
* fix name in viz header, don't show be error in tooltip
* sort numeric bins in heat map header, fix tests
* UXW-4483 Add matching colors for category + breakout charts (#75912)
* Fix CI
* Fix units, fix loki
* UXW-4522 Update last query run timestamp in background
* Fix loki
* Fix e2e
* Improve Explorations viz types (#75958)
* UXW-4483 Add matching colors for category + breakout charts
* improve viz types first pass
* add error boundary
* add row chart support for dimension_value_colors
* Fix typecheck
* fix tests
* better handling of segments with row fallback, fix null color bug
---------
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
* UXW-4402: Make explorations respect sandboxing/impersonation/db routing (#75572)
* Make explorations respect sandboxing/impersonation/db routing
* some self-review fixes
* more cleanup
* test fixes
* doh
* Fixing CI failures
* capitalize 'By' consistently
* Fixed CI failure
* add loki snapshots
* Review fix
* Fixes after merge
* Rework Explorations UX (#76300)
* comment on exploration viz first pass
* wip
* wip
* wip
* UX changes first pass finished
* code cleanup, fix, add tests
* address review comments
* Code cleanup
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Refine explorations with metabot (#75326)
* teach metabot about new exploration ux
* refine explorations
* address self-cr
* pass over FE
* CR response
* fix test
* Add :model/Exploration to api + eid-translation model-imports
These modules reference :model/Exploration after merging the latest
explorations base; declare the imports to satisfy model boundaries.
---------
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
* Harden explorations runner:
- planner crash recovery
- Bound timeline scoring
- add metrics
* UX tweaks
* Additional logging of errors on ai summary, plus increased thinking budget
* Improved timeline interestingness calculation
* Handle sonnet 5 etc better
* Make explorations groups a real thing (#76643)
* Rename ExplorationThreadGroup model to ExplorationBlock
Explorations organize their queries in a hierarchy: an Exploration has
Threads (one per run), a Thread has Blocks, and a Block is one
metrics-by-dimensions grouping that renders as a single sidebar heading.
That persisted grouping entity was named ExplorationThreadGroup, and the
word "group" was overloaded across the backend and frontend. Rename the
entity to Block — the name the frontend already uses — so the larger
data-model changes that follow start from one clear name. No behavior
change.
Renames :model/ExplorationThreadGroup -> :model/ExplorationBlock and
table exploration_thread_group -> exploration_block.
- Append a renameTable changeset rather than editing the createTable in
place: the original is already applied to running dev DBs, so an
in-place edit would fail checksum validation and force a DB rebuild.
- Rename the model namespace/file; update all call sites, the require
alias (:as block), and insert-blocks!.
- Update model registrations (resolution, serdes models, copy, entity-id
test) and re-sort the entries.
- Refresh the model's docstrings to describe a "block".
The planner's :thread-groups ctx key and the read-side group-tree are
left untouched here; they belong to later read-side work.
* Persist exploration pages with stable ids reconciled across reruns
Within a Block, an Exploration's queries are organized into Pages: a Page
is the bundle of queries for one (metric, dimension, query_type). Until
now a Page existed only as a derived, content-addressed key computed at
read time, so anything that wanted to point at a Page had to reference a
string that changed whenever the grouping definition changed. This makes
a Page a real persisted row whose surrogate id is its identity, so
comments (and stars, coming next) can anchor to a Page and survive both a
rerun — where queries are deleted and regenerated — and a future
re-grouping.
Introduce ExplorationPage: the persisted (block, card, dimension,
query_type) page within a block.
- New exploration_page table + model, FK to exploration_block (cascade)
and report_card. Registered in resolution, serdes (excluded), copy,
and the entity-id test.
- Add exploration_query.page_id (NOT NULL, FK, cascade): the reconciler
stamps it on every query, so it's required from the start. group_id
stays for now as the block pointer the read-side and deep-link URLs
use; dropping it waits for the read-side rework.
- Reconcile in insert-plan-rows!, the single insert point for both
initial Start and rerun: find-or-create a page per distinct key
(stamping page_id), then GC pages left with no queries. reset-thread-
for-rerun! is unchanged — pages survive a query delete (cascade is
page->query), so find-or-create recovers the same page ids on rerun.
- No DB unique index on the key: dimension_id is text (not portably
indexable on MySQL) and planning is single-threaded, so uniqueness is
enforced by find-or-create and matching is done in Clojure.
- Migration changesets appended (the table/group_id changesets are
already applied to the running dev db).
Tests cover page-id stamping, rerun id-stability, orphan GC, and perms.
* Return nested blocks→pages from the read API; drop query.group_id
An Exploration Thread's structure is Blocks, each containing its Pages,
each Page bundling the ids of its queries. Until now the backend instead
returned a hand-rolled flat list of typed nodes (sidebar/singleton/page)
linked by parent_group_id and keyed by auto:<...> strings, which the
frontend then re-nested. Now that Blocks and Pages are real rows, return
the nested shape the tables already encode: GET /:id returns :blocks,
each with its :pages, built from ExplorationBlock + ExplorationPage rows
and queries grouped by page_id.
- New metabase.explorations.blocks (renamed from .groups): blocks-tree
builds the nested shape; block headings + page names + score-sorting
ported over; block-display-name made public for the LLM planner ctx.
- Deep links go to a page by id: page-url → /question/research/<exp>/
page/<page-id>; the AI-summary and document-append builders pass the
primary query's page_id.
- Drop exploration_query.group_id (and its FK/index); a query reaches its
block through its page now — build-row-context (used by the runner to
finalize each row's MBQL) resolves the block via the page, which is what
the group_id read there used to do.
- API schemas: ExplorationQueryGroup → ExplorationBlockNode +
ExplorationPageNode.
- Migration changesets appended (the table is live on the dev db).
- Rewrite the read-model + endpoint tests for the nested shape; every
ExplorationQuery test fixture now hangs off a page (page_id NOT NULL).
The create payload key stays :groups for now (separate input contract,
touches the data-picker flow). FE consumption of :blocks lands next.
* Consume nested blocks→pages on the frontend
The frontend renders an Exploration Thread as sidebar headings — one per
Block — with a navigable item per Page (a Page being the bundle of
queries for one metric/dimension/variant). It used to receive a flat
typed-node `groups` list and re-nest it itself; this switches it to the
nested `blocks` array (block → pages) the read API now returns, and
renames the navigable unit from "group" to "page" throughout. Users see
the same sidebar and charts — this lines the frontend up with the
backend's shape.
- exploration.ts: add ExplorationBlockNode / ExplorationPageNode; thread
`.groups` → `.blocks`; remove ExplorationQueryGroup / its display-type;
the navigable id is now a page node id (kept as an opaque string).
- Sidebar utils build the tree directly from blocks→pages (one heading
per block, one child per page); score-sorting + empty-block filtering
preserved.
- The navigable unit is renamed group→page end to end: the route segment
becomes /page/<id>, matching the backend's AI-summary deep links. The
selection/url/comment-draft id stays a string via String(page.id), so
the existing string-keyed plumbing is unchanged.
- Visualization toolbar/header/comment-draft props renamed group→page;
the mark-as-interesting handler is left a stub for the stars feature.
- Specs, stories, and test-utils rewritten to the nested shape.
The create-payload request key stays `groups` (separate input contract).
* Retain comment-anchored pages across rerun GC; point comment URLs at /page/
A Page bundles the queries for one (metric, dimension, query_type), and a
comment can be anchored to a Page — comment.child_target_id holds the page
id. On a rerun an Exploration's queries are deleted and regenerated, and
any Page left with no queries is garbage-collected. A Page a comment
points at must survive that cleanup even when it ends up empty, or the
comment would resolve to nothing. This makes the rerun GC comment-aware
and moves comment deep-links onto the page route.
- gc-orphan-pages! now retains an orphan page when some "exploration"-
targeted comment's child_target_id equals the page id (compared as
strings). Empty + no comment is still GC'd as before. Page ids are
globally-unique PKs, so matching on child_target_id alone is
unambiguous.
- exploration-comment-url: /group/<child> → /page/<child>, matching the
page route and blocks/page-url; stale "group ID" docstring fixed.
- explorations imports :model/Comment for the retention lookup (comments
doesn't depend on explorations, so no dependency cycle).
- No backfill: the dev Postgres has zero exploration comments and zero
auto:* child_target_id rows, so the optional migration is skipped.
- Test: comment on a page → drop that page's selection → rerun → the page
is retained and the comment still resolves; a no-comment empty page in
the same rerun is still GC'd.
* Generate short and long page names from metric, dimension, and variant
A Page — the bundle of queries for one (metric, dimension, query_type) —
is shown in the sidebar under its Block's heading, but is also referenced
on its own elsewhere (comment deep-links, AI-summary chart titles,
search). Page names are derived, not stored, so a Page needs both a short
label that omits whatever its Block heading already shows (e.g. under a
"Number of Orders" heading: "By Category over time") and a full,
self-describing label for context-free places ("Number of Orders by
Category over time"). This generates both from the Page's parts — the
metric name, the dimension name, and a short per-variant qualifier —
rather than by string-slicing a finished name.
- variants/variant-qualifier: query_type → a short, param-free localized
qualifier ("over time", "(Day of week)", ...) or nil for the default
breakdown. Lives next to query-name so all variant naming is in one
place. Unit-tested.
- blocks: page-long-name and page-short-name generate from parts (metric
+ dimension + qualifier) using tru templates and the block's anchor;
page-node emits :name and :long_name.
- API ::ExplorationPageNode and the FE ExplorationPageNode type gain
:long_name (FE display wiring left for separate work).
- Same-dimension pages that differ only by variant (default vs
top-n-other) are now distinctly named instead of colliding; a
blocks-tree test that asserted the collision now asserts they differ.
* Add ability to star pages, sidebar tabs UX (#76755)
* add ability to star, sidebar tabs
* - renamed "user interestingness" to "starred"
- Let starred just be true|false, no null
- Removed DELETE starred API, just PUT now
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Fix CI: test compile error, migration boolean type, e2e formatting
- documents/search_test.clj: qualify text->prose-mirror-ast with the
documents.test-util/ alias in the exploration-thread exclusion test.
The unresolved symbol broke backend test compilation, which cascaded
to every app-db/driver test job and clj-kondo ("0 tests" everywhere).
- 20260428_explorations.yaml: use ${boolean.type} instead of bare
boolean for exploration_page.starred (required post-v49 so MySQL
uses BIT(1)).
- oxfmt formatting for two explorations frontend files (fe-lint).
* Update e2e specs for reworked comment URL + explorations sidebar
- comment helper: match the node comment link by path only. The
`?new=true` marker was removed from comment URLs (base commit
"respect query params in all urls"); the CommentsMenu unit test was
updated but this e2e helper was missed. Verified the comment flow
still works (opening a node's thread shows the new-comment editor).
- explorations spec: align with the reworked sidebar —
* `by <dim>` leaf label is now `By <dim>`
* Scratchpad doc test: only Scratchpad is created synchronously; the
AI Summary doc is created asynchronously, so stop asserting it (and
the removed "Findings" grouping) at creation time.
* AI-summary completion test: the AI Summary doc row now hangs under
the thread heading ("Initial investigation"), not a "Findings" group.
* remove the two "Add to document" tests — that control was removed.
* Fix stylelint rule-empty-line-before in explorations CSS modules
Add blank line before nested &:hover rules in ActionToolbar.module.css
and ExplorationGroupVisualization.module.css. These surfaced in fe-lint's
lint-css step once the oxfmt formatting failure was resolved.
* - cleaned up migration file
- Improve group renaming in code
- preserve starts between re-runs like comments
* Fixing CI failures
---------
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Don't send empty messages to the LLM
* address review comments from 76755
* UXW-4765 Hide charts (#77488)
* UXW-4765 Hide charts
* Review fixes
* Fix the failing explorations unit tests and Loki stories (#77564)
* Fix the failing explorations unit tests and Loki stories at the branch tip
* Restore the original all-hidden copy and drop thread headings from it
* Delete the outdated ExplorationGroupVisualization stories and their Loki references
* Rephrase the hide toast to put the chart name first
* UXW-4764 Remove interestingness badges on results (#77544)
* UXW-4764 Remove interestingness badges on results
* Drop e2e assertion for the removed sidebar interestingness markers
* Colocate the interesting and error markers with their only consumers
* Remove the interestingness marker from the timeline picker too
* fix types, lints, formatting
* Uxw 4779 update tab menu to match designs (#77543)
* UXW-4779 Update sidebar tab menu to match designs
* Show a pointer cursor across the whole SegmentedControl
* Give the icon tabs img semantics and hide the decorative icon names
* Let getExplorationSidebarTabsInfo own the tab icon config
* Revert the SegmentedControl root pointer cursor, Mantine already puts it on the labels
* Regenerate modules config after master merge
The merge left 'explorations' and master's new 'entity-retrieval' out of
sorted order, which metabase.core.modules-test asserts on.
* Float the action toolbar pinned to the bottom of the chart canvas (#77667)
* fix types
* revert timelines in documents
* Review fixes: stop requiring the QP's internal cache.impl from other modules
Explorations code that saves and loads query-result snapshots was requiring
metabase.query-processor.middleware.cache.impl directly — an internal
namespace of the query-processor module that other modules aren't supposed
to reach into. Since the module's :api set is generated from actual usage,
those requires had put cache.impl on the module's public surface — something
master never exposed. Modules talk to each other through a small public
facade (metabase.query-processor.core) instead.
The two helpers those callers need — do-with-serialization (write a QP
result as a gzipped blob) and with-reducible-deserialized-results (read one
back) — are now re-exported from the qp.core facade, and the explorations
callers go through it. One consumer can't: queries/cached_result.clj is
itself loaded while qp.core is loading, so requiring the facade from there
would be a circular-load error at boot. It gets a proper home instead — a
new small, documented public namespace,
metabase.query-processor.result-serialization, re-exporting the same two
helpers. With nothing outside the module requiring cache.impl anymore, the
regenerated :api drops it, matching master.
* Review fixes: explorations planner and AI-summary polish
- Nested cardEmbeds: the AI-summary postprocessor wrapped only top-level
cardEmbed nodes in resizeNode containers, while the card materializer
walks the whole document — so a cardEmbed the LLM emitted inside e.g. a
blockquote got a real card but rendered collapsed to zero height. The
wrapper now walks the tree too (skipping already-wrapped nodes).
- Awareness-tier sort validation: phase 2 validates that embedded
categorical charts carry an explicit sort instruction, but the validation
set was built only from top-tier breakouts — an awareness-tier categorical
chart escaped validation entirely, and its prompt block (slim-block) never
told the model a sort was required. Both tiers are validated now and
slim-block emits the same per-chart sort directive as full-block.
- Contextual-scoring gate order: safe-score+describe resolved its scoring
context (a Card select and a SQL compile) before checking whether the row
is scorable at all (chart config present, creator known, non-blank thread
prompt) — wasted DB reads and a query compile for every unscorable row.
The cheap gates run first now. Its docstring also claimed the LLM gate
throws and is swallowed; it actually returns nil from a pre-flight cond.
- Discovery-cache TTL: the top-K category discovery cache was a
process-lifetime LRU, so a stale top-K result could be served forever on
a long-lived instance; entries now also expire after 15 minutes.
- Chart-name i18n: filtered-subset chart names built their multi-value
summary as (str (count values) " values") — a hardcoded, unpluralized
English fragment inside an otherwise-translated name. It uses trun now.
- Docstring/comment corrections: effective-cardinality documented (and
branched on) a :num-bins bucket that no caller can produce — branch and
docs removed; run-one-plan-iteration! said it runs "the LLM planner" when
the default is the mechanical one; a variants-test section comment claimed
temporal variants add a not-null filter that no variant adds.
Also repoints this module's result-blob serialization calls at the qp.core
facade (the consumer half of the cache.impl cleanup in this stack's first
commit).
* Review fixes: explorations API permissions, documents, and search
- Creating an exploration accepted arbitrary card ids (in blocks) and
timeline ids without checking the caller could read them, so you could
attach someone else's private metric or timeline and see its name/events
later. Both are read-checked at create time now, and timeline hydration
filters out timelines the viewer can't read. A duplicated timeline id used
to hit a unique constraint and 500 the request; ids are deduped.
- The restart endpoint re-selected the Exploration row after resetting the
thread, but the reset never touches that row — the redundant select is
dropped.
- GET /api/document listed documents that belong to exploration threads,
which search, recents, and collection items all deliberately hide. It
applies the same filter now — which as a side effect removes an N+1
pattern where each listed doc could trigger several permission queries.
- The document validator rejected {:type "doc" :content []} — the exact
empty document the backend itself creates for new threads. An empty root
doc is valid now.
- Search: is_published was deliberately hidden from search responses on
master (#66841) and this branch accidentally re-exposed it — hidden again
in both search engines. Exploration descriptions are searchable in the
legacy engine too, matching the new engine.
- Data picker: metrics and dimension groups now use the same
interestingness filter (a metric could previously reference dimension ids
that appeared in no group), search matches the displayed
"<group> - <dimension>" text, and a collection-ids query that ran twice
per request runs once.
- Consistency: the create event publishes after the transaction commits
(observers could previously fire before the data was visible).
* Review fixes: metabot / LLM infrastructure
- Asking Claude for extended thinking while also forcing a tool call
(tool_choice "required") produced a request Anthropic rejects with a 400.
The guard that already handled this for structured output now covers the
general tools path too.
- The e2e guard that stops tests from hitting a real LLM provider threw a
raw MalformedURLException on unparseable URLs; it now fails closed with
its normal friendly message — and it was moved out of a catch-all that
would have rerouted it, so it fails loudly.
- The timeline tools handed the LLM broken XML (an attribute quote and a tag
never closed) and interpolated user-written names/descriptions unescaped,
letting content break the markup. Output is well-formed and escaped.
- Hardening/docs: the retryable-error check bounds its exception cause-chain
walk at 10 (a cyclic getCause could loop forever); the
contextual-interestingness response schema now really forbids extra
properties (its docstring claimed it already did); docstrings about
thinking model buckets, return shapes, and inputs corrected.
- Also repairs three test breaks that already existed at HEAD: an arity
mismatch from the OpenRouter merge, a test asserting thinking blocks are
dropped (they now stream as reasoning parts), and tests that didn't stub
the LLM gate's newer usage/permission checks.
* Review fixes: lib-metric, metrics transforms, startup sync, interestingness
- group-by-source was re-exported for JVM Clojure but missing from the
ClojureScript branch of lib-metric.core; added.
- Metric/measure display-info inherited a default :long-display-name even
where the method overrode :display-name, so the two could disagree;
they're set together now.
- A failure in the startup metric-dimension sync aborted server boot
entirely; it's wrapped to log an error and continue.
- The interestingness "leftovers" backfill loaded every unscored field for
the database into memory in one select; it now streams one field at a
time, and the namespace docstring mentions this second recompute trigger.
- Added a unit test covering the dimensions :out transform (kebab-key
renames + type-value keywordization).
* Review fixes: enterprise perms/sync/deps, cached card query, test quality
- Remote sync: an event payload that simply omitted a key used to satisfy a
nil condition (e.g. "exploration_thread_id must be nil") — conditions now
require the key to be present with the right value, checked with
(find object k), which distinguishes a present-but-nil entry from an
absent key. Vector values in removal conditions rendered as broken
equality clauses; they render as IN.
- Dependency graph: smartLink nodes with nil/placeholder ids added nil to
the dependency set (the sibling cardEmbed branch already guarded this);
guarded, with regression tests for both branches.
- Test quality: the revisions exclusion test asserted the impl against its
own private set, so it could never fail; it checks a hardcoded expected
list now. Exploration bookmarks get real API coverage
(create/list/delete/ordering/archived), and a mislabeled test string was
fixed.
* Review fixes: v4 instance-analytics views with explorations usage sources
The AI-usage analytics views translate machine source names ("exploration",
"contextual_interestingness", …) into human-readable labels with a SQL CASE.
The explorations stack added its new sources to the v1 SQL files — but the
views actually installed on instances are v3, and nothing reinstalled them,
so users saw the raw machine names fall through the ELSE branch.
Adds v4 of ai_usage_log and metabot_conversations (v3 plus the new CASE
branches, with one consistent "Explorations" label — v1 said "Exploration"
for the source but "Explorations" for the profile), a migration that
reinstalls the views at v4, an updated pointer comment in usage.clj, and a
parity test that fails whenever a known source/profile is missing a CASE
branch in the latest views — exactly the drift that happened here.
* Review fixes: frontend (minimal) and e2e
Only the low-risk frontend fixes from the review pass, plus the e2e test
fixes; the other frontend findings are deliberately left out to keep this
branch's FE diff small.
- getDimensionIcon existed byte-for-byte in two files; the duplicate module
is deleted and its one importer now uses the copy in
common/utils/columns.ts.
- e2e: an assertion that searched for text inside another text node (and so
always passed) is meaningful now; readSelectedText no longer breaks
Cypress's retry behavior by asserting after a .then; and the seemingly
pointless GET /api/metric/:id in seedMetrics got a comment explaining the
side effect it exists for (it persists metric dimensions).
* Remove the LLM query planner
Explorations plans queries with the mechanical planner only now. Removed:
the LLM planner namespace, its prompt templates (plan.selmer /
repair.selmer) and prompt loader, the prompt-markdown renderers in the
planner-context namespace, the explorations-query-planner setting
(mechanical/llm/auto), and the :skip-no-llm exit for "planner requires an
LLM but none is configured".
pick-planner! now always returns the mechanical planner — kept as a seam
so tests can stub it and a future planner has an obvious dispatch point.
The planner-contract protocol and the shared context builder stay (they
are planner-agnostic), and the query_plan_transcript column stays (every
planner writes a transcript); its remark no longer describes an LLM
exchange.
* remove graph._dimension_value_colors (per bar colors)
* Add row count to explorations API (#77740)
Already available, we just need to persist it and thread it through the
API.
* Format fix
* Exploration UX... um explorations. (#76901)
* click thru
* better comment links
* chart avatars
* avatars
* allow subsubexplorations
* disable explore further for Other group in top n charts and dimension anchored blocks
* fix explore further for binned numeric columns
* comment highlighting, remove avatars
* comment label formatting
* map and table highlighting
* use built in viz click actions popover instead of custom
* fix comment badge in dark mode
* self review cleanup
* fix and simplify new thread toast
* fix be lints, add tests
* fix tests
* add fe, e2e tests
* - POST /:id/explore-further now verifies the clicked page
belongs to this exploration before copying its block, closing a hole where
any page in the instance could be copied into any writable exploration.
- key on the stable :explore-filters spec instead of a hash of
the per-row-reconstructed :dataset_query (which minted fresh :lib/uuids and so
never hit). build-row-context exposes :explore-filters on the row context for
the cache to key on.
Also reuse metabase.util/capitalize-first-char in the block title and thread
name helpers instead of hand-rolled str/upper-case.
* Code cleanup
* Gate exploration derived metadata by viewer's permissions
* fix lints
* Code review improvements
* Den/uxw 4741 explorations new sidebar (#77674)
* Update tabs view, add filter button menu
* Fix tests
* Add sidebar items vertical lines + icons
* Other sidebar changes - viewed state, sticky hierarchy
* Show sub-explorations near it's parent item
* Fix issues
* Self review
* Fixes after merge
* Self review
* Review fixes
* Fix empty states
* Review fixes
---------
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
* Explorations self-review cleanup (#77717)
* Consolidate the explorations migration in place
The 063 explorations migration had grown by accretion: tables were created
and then altered by later changesets in the same unreleased file. Since the
branch convention is to edit unmerged changesets in place, the final shapes
are folded into the createTables:
- exploration_query gains params, nullable dataset_query/entity_id, and
page_id inline (the page_id FK and index stay separate — exploration_page
is created later in the file, so they can't run earlier).
- stored_result gains data_access_token inline; its backfill changeset was
deleted outright — created and backfilled in the same release, it could
only ever run against an empty table.
- exploration_query_result gains chart_stats / metric_description /
chart_description inline, which also retires the randomly-named changeset
id v63.gr324asd.
- idx_eqti_query_id deleted: the unique constraint on the same leading
column already provides that index.
The completed_at and analysis_started_at backfills are deleted outright:
the explorations feature merges to master in one piece, so at upgrade time
no instance can have pre-existing exploration threads to backfill. (They
were also dangerous — they stamped columns the cancel endpoint and the
completion handler gate on being NULL, permanently locking out any thread
in flight during the migration.)
Cosmetics: backfilled default docs are named "Scratchpad" to match runtime
(the backfill said "Findings"); stale remarks corrected (chart_stats is EDN
not JSON, the real query_type variant list, what collection_id actually
defaults to).
Existing dev databases will need a migration reset.
* Set exploration thread started_at in the INSERT
The thread was inserted without started_at and then immediately updated to
set it, on the theory that the background worker must not see the thread
before its dependent document/block/timeline rows exist. The whole create
shares one transaction, so intra-transaction ordering is invisible to the
worker (it reads on another connection; the commit is atomic) — the flag
can simply be set in the INSERT, saving a write.
* Guard exploration restart against in-flight work
The restart endpoint could fire while the planner was still working on the
thread, deleting its rows mid-plan and wedging the thread in a state no
worker would ever pick up again. reset-thread-for-rerun! is now a CAS: it
applies only to a terminal thread (completed_at set) with no query still
mid-execution, and the endpoint returns 409 ("cancel it before restarting")
otherwise. A successful reset leaves the thread exactly in the shape the
planning worker's claim predicate picks up.
The endpoint also published :event/exploration-update, which provably did
nothing: restart changes no Exploration column, and the revision system
skips unchanged objects. The publish is removed; the AI-summary doc reset
still records a Document revision, which is what /mine's last-touched
ordering composes.
* Route mid-stream LLM errors through the provider-friendly rethrow
LLM responses stream lazily; a connection timeout between SSE chunks
escaped as a raw SocketTimeoutException because the provider error handling
only wrapped request setup — the reduction happens later, in the agent
loop. Every provider adapter's SSE reducible (claude, openai, openrouter,
azure, bedrock) is now wrapped so consumption-time failures get the same
translation as request-time errors.
* Grant the read-only timelines scope under metabot-nlq
The explorations metabot profile instructs the model to use the timeline
tools, but those tools were granted only under
:permission/metabot-other-tools while the profile is gated on NLQ — so for
NLQ-only users they were silently filtered out of scope while the prompt
kept referring to them. agent:timelines:* (whose only member is the
read-only list/details scope) is now granted under both permissions.
* Don't retry deterministically-failing fields in the interestingness leftovers pass
The leftovers pass selects on dimension_interestingness IS NULL, so a field
whose scoring deterministically throws was re-attempted on every sync
forever. A per-process attempted-marker now skips fields that already
failed in this process; a restart re-enables retry, so transient failures
still converge. Process-local by design — no schema change, and no sentinel
score leaking into product surfaces that read the column as a number.
* Fail closed when a sandboxed field's user attributes are missing
sandbox-token-for-table returned nil when the field was sandboxed but the
user's sandbox-attribute lookup came up empty — and the downstream
compatibility gate reads nil as "unrestricted". That's fail-open. It now
returns a user-scoped "indeterminate" sentinel that matches nobody else's
token (two indeterminate users don't match each other either): fail-closed.
Also adds direct and end-to-end tests for the impersonation and routing
token producers, which previously had none.
* Require the (card, stored_result) pairing on the cached card-query path
POST /api/card/:id/query with a stored_result_id read-checked the card but
then served whatever snapshot id the client sent — nothing verified the
snapshot actually belonged to that card. The pairing must now exist in
stored_result_use (404 when it doesn't, so an unpaired probe can't confirm
a snapshot exists). Both materialization paths already insert the pairing
rows, and the table ships in the same unreleased release, so no legacy data
lacks them.
The viewer-lens check also denied a nil dataset_query only by accident (a
schema exception swallowed by the catch — and in prod, where mu/defn logs
instead of throwing, it would have failed open); it denies explicitly now.
Plus: consistent t2/select-one forms in the endpoint, a stored_result_use
docstring update, and the generated module config picking up the new
StoredResultUse consumption.
* Apply the personal-collection default when it resolves after first render
The "default new explorations to my personal collection" logic captured
personalCollectionId once in a useState initializer — if the selector
resolved after the first render, the default never applied. It now
reconciles when the id arrives, without overriding an explicit user choice.
* Move the explorations migrations to v64
The v63 release branch has been cut, so the explorations feature will ship
in v64 and its migrations belong in the 064 directory. Both files move
(20260428_explorations.yaml and 20260519_exploration_bookmark.yaml) and
every changeset ID is reprefixed v63. -> v64.
No changeset content changes. Since we're already editing unreleased
changesets in place on this branch, existing dev databases needed a
migration reset anyway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* v_metabot_conversations v4: restore include-deleted semantics lost in merge
Master's #77542 (conversation branches) and this branch both created
metabot_conversations/v4 in parallel: master's dropped the
deleted_at IS NULL filters so soft-deleted (rewound/regenerated) messages
are counted; ours added CASE branches for the new explorations usage
sources and profile. The 'Merged from master' resolution kept only our
copy, silently losing the deleted-message semantics — which is why
deleted-messages-included-test and last-message-at-test fail: they assert
master's behavior against our view.
This drops the three filter lines from each dialect's v4 file so v4 is the
union of both changes. Both installing changesets (our v63 and master's
v64) are runOnChange: true and checksum the referenced file, so every
instance reinstalls the corrected view on upgrade — no new version or
migration needed.
* Drop the redundant v_metabot_conversations v4 installer
Master's #77542 (064/20260713_metabot_analytics_views_include_deleted.yaml)
already installs metabot_conversations v4 via a runOnChange changeset against
the same SQL files, which now carry both sides' changes: the explorations
CASE branches and the include-deleted semantics. A second installer here was
redundant — and would have collided with master's changeset IDs
(v64.2026-07-13T00:00:00/01) had this file been mechanically renumbered to
v64 like the other explorations migrations.
The ai_usage_log v4 changeset stays: nothing on master installs that view.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Move the ai_usage_log v4 migration to 064
Same reasoning as the explorations tables: v63 is cut, this ships in v64.
The changeset ID takes the next free slot for its date
(v64.2026-07-13T00:00:02 — master's include-deleted file owns 00 and 01)
rather than a mechanical v63->v64 rewrite that would have collided.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Show a crossed-eye indicator with a tooltip on hidden pages in the exploration sidebar (#77907)
* Explorations no AI Summary (#77878)
* Extract AI Summary + document-in-exploration out of explorations
Removes the LLM-generated "AI Summary" feature and the ability to display a
document within an exploration. The feature-restoring (inverse) commit is kept
on branch `explorations-ai-summary`; cherry-pick it to bring the functionality
back.
Backend:
- Delete the explorations.ai-summary.* pipeline, composite, document-perms,
prompt templates, and their tests
- Drop exploration->Document creation, the /thread/:id/documents endpoints,
the :documents hydrate, ai_summary_document_id, and the AI-summary reset
- Revert the documents-module coupling: content-visibility gate, the
document.exploration_thread_id FK/column, the view-log skip, and the
GET /document list filter; delete documents/validate.clj
- Drop the collection-items show_exploration_documents option and the
exploration-doc exclusions in recents / search / serdes / remote-sync;
drop the search exploration-thread-id attribute
- Remove the ai_summary_document_id and document.exploration_thread_id
migration changesets (edited in place)
Frontend:
- Delete the ExplorationDocument component and its tests
- Remove the AI-summary sidebar node, completion toast, and the document
API / urls / types; keep the shared comments sidesheet and page routing
* Extract static card-embed in documents out of explorations
Removes the static (cached-snapshot) card-embed capability from documents, so it
can land on its own branch (`static-embed`) independently of the AI-Summary
feature. The feature-restoring (inverse) commit lives on `static-embed`.
Keeps the base `stored_result` model, the exploration query-result cache, and the
cached-result permission helpers (`assert-can-view-cached-result!`) that the
exploration `/query/:id` streaming endpoint uses. Also keeps the general
`report_card.document_id` "card in a document" feature (it predates this work).
Removed:
- The cached-result serve path (`cached-dataset`, `apply-sort`,
`deserialize-cached-result`, `allowed-chart-sorts`, `viewer-can-view-cached-result?`)
and the `stored_result_id`/`sort` branch of `POST /api/card/:id/query`
- `stored_result_use.card_id` (column + model arm; keeps `exploration_id`)
- The documents serdes static-node filter (reverted to master's live-only rewrite)
and the prose-mirror static docstrings
- Frontend: the tiptap `cardEmbed` static rendering (the `stored_result_id` + `sort`
+ `chart_href` trio) across the CardEmbed node/menu, document hooks, redux, and
`card.ts`; deleted the CardEmbedNode static unit spec
* Fix pre-existing cljfmt/oxfmt formatting to unblock CI
These 4 files (a stray blank line each) are unchanged from the base explorations
branch — the format checks were failing on inherited, not extraction, changes.
* more documents cleanup
* Update Loki Snapshots
* change references from entity (which was page or document) to just page
---------
Co-authored-by: Kevin Kelleher <kevin.kelleher@metabase.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Restore fix
* Fix Metabot exploration tools streaming empty results to the client (#77990)
Since 6912a50e85 ([Metabot] Chat internal format improvements, #77301)
only a tool result's :output string makes it onto the tool-output-available
SSE event; a bare map streams as "". The exploration plan tools returned
bare maps, so the FE — which applies plan edits by parsing the streamed
tool result — silently dropped every edit while the LLM (which still saw
the full result) reported success.
Wrap each tool's payload in {:output (json/encode ...)} and add a
regression test that runs real tool results through parts->aisdk-sse-xf
and asserts the payload survives onto the wire.
* Explorations timeline fixes (#77958)
* fix timelines
* disable timelines for row charts
* Explorations sort order (#77980)
* sort empty pages to bottom
* sort subexplorations by the initial exploration's interestingness
* Add e2e tests
* Explorations use persistent queue (#77901)
* explorations: run planning/queries/scoring on the persistent queue
Switch explorations from the hand-rolled polling runner to the mq persistent
queue: three queues (plan, query, timeline-score) with per-queue on-error
handlers and worker-count-bounded concurrency.
The generic mq subsystem changes this builds on live in a separate branch
(mq-queue-improvements) so they can land on master independently. Until that
lands and flows back into `explorations`, this branch depends on those mq
features (:on-error, :max-concurrent-batches) and won't run standalone.
* Remove explorations settings-test
* Code cleanup
* Code cleanup
* Use in-memory queue backend for e2e tests
The persistent Quartz queue backend stores its durable-job state in the app
DB's QRTZ_* tables. e2e restores app-db snapshots between tests (H.restore),
which drops those tables out from under the running scheduler while the
per-process ensured-jobs cache still believes the jobs exist. Post-restore
publishes then target a non-existent job and never deliver, so exploration
planning produces no queries and the detail-page sidebar stays empty.
e2e is a single node, so the in-memory backend (no app-db state, unaffected by
snapshot restore) is the correct fit. Set via the :e2e alias so it applies to
both local (runFromSource) and CI (runFromJar, via getJvmOptsFromDepsEdn).
* fix type
* Remove Timeline interestingness from explorations (#78013)
* Remove Timeline interestingness from explorations
Removes the LLM-driven per-(query, timeline) interestingness scoring
feature and all of its wiring
* Improve names (#77862)
* improve names
* review changes
* fix dimension disambiguation not working
* Fix explore-further on expression columns (#77900)
1. The 500 — normalize-target-ref (in metrics/transforms.clj) silently dropped the expression name. The :field ref schema has a legacy-form decoder that relocates the id into MBQL-5 position; the :expression schema has none, so a
valid legacy ["expression" "Gear"] normalized to [:expression {…} nil]. That nil-named ref then failed Malli validation inside find-matching-column mid-request → 500.
2. The drill couldn't work anyway — a top-n-other chart breaks out on a synthetic CASE expression that exists only in the variant query, never on the metric card. So even a valid expression ref could never resolve against the card.
* Code cleanup
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Update Loki Snapshots
* Improved LLM error handling
* Fixed queueng of explore-further
* Fix e2e tests
* Add copy link hotkey support
* Fix e2e tests
* Fix CI
* Fix after merge
* Make highlighted state in line charts more obvious when hovering over comment badge (#77998)
* show tooltip when highlighting comment for cartesian charts
* set blurScope to blur across stacked chart grids, only show tooltip for single line series
* review feedback
* add types file that was supposed to be included with 77998
* self review
* Auto-switch the add metrics modal to the All tab when the library has nothing to show (#78070)
* Fix back button (#77995)
* Fix back button
Co-Authored-By: Andrei Chernykh <a.d.chernykh@gmail.com>
* Remove the stray /question/explore route, fix the entry spec's removed setMode prop, and clean up dead code
* Keep the research draft and conversation alive across the entry/plan step and browser back/forward via a shared provider on the research route
* Fold the plan-step URL assertion into the existing manual-flow e2e test instead of a separate one
---------
Co-authored-by: Ethan McCue <ethan@mccue.dev>
* Explorations: remove dormant extended-thinking support; unify LLM timeouts (#78173)
* Remove unused Anthropic extended-thinking support
Extended thinking was fully dormant: the Claude adapter supported it
(streaming translation of thinking blocks, config normalization, request-body
support) and call-llm-structured-with-trace forwarded a :thinking opt, but no
production caller ever passed :thinking, and the multi-turn agent path
(call-llm) never accepted it at all. Remove the adapter support and the
structured-path plumbing until a real consumer exists.
This also resolves several open review questions about the thinking code
(signature round-trip on multi-turn continuations, structured-output
guarantees under thinking, per-model capability guards) by removing the code
they applied to.
* Unify LLM HTTP timeout knobs across metabot.self and llm.anthropic
metabase.metabot.self.core/request hardcoded its connection (10s) and
socket (120s) timeouts, diverging from the metabase.llm.anthropic HTTP
path, which already reads the operator-tunable llm-connection-timeout-ms /
llm-request-timeout-ms settings. Operators had two uncoordinated knobs.
Delete the two private constants and read the settings at call time in
`request` (keeping the per-request `(merge req)` override). Raise the
setting defaults to the former constants' values (5000 -> 10000,
60000 -> 120000) so no path loses its safety margin, and move the
constants' streaming/inter-byte rationale into the defsetting docstrings.
Both LLM HTTP paths now share a single operator knob pair.
Resolves PR #1 review comment 2.
* UXW-4758 Ability to view all timeline events
* Show proper empty state message in sidebar for initial load
* Bump prefer-with-dynamic-fn-redefs ratchet to 28
The explorations MQ queue tests need three real with-redefs (not
mt/with-dynamic-fn-redefs): the handlers run on an MQ worker thread that
does not carry the calling thread's dynamic bindings, as documented
inline at test/metabase/explorations/queues_test.clj:102,153,265. Seed
the budget to match so metabase.core.kondo-ratchet-test passes.
* fix regression of #77667 caused by #76901
* Restore explorations CASE branches in v_metabot_conversations (v6)
master's #77549 rewrote v_metabot_conversations to v5 (summary->title
rename) without the explorations source/profile CASE branches that v4
carried, so the exploration/contextual_interestingness sources and the
explorations profile surfaced as raw machine names and failed
metabase-enterprise.metabot.usage-test/known-sources-and-profiles-have-view-case-branches-test
across every dialect.
v5's installer is not runOnChange and is already released on master, so
edit it in place would break the checksum. Instead add v6 (v5 + the three
branches) and a runOnChange installer that reinstalls the view after the
v5 rename, matching the v4 installer pattern.
* fix errors caused by merging master
* UXW-4758 Close timeline events sidebar when comments are opened
* Explorations queries api performance (#78175)
* revert error message display changes
* Avoid a full fingerprint version increment
* Fix unit tests error logs
* Removed the composed LRU + TTL cache that is not supported by the core.cache factories. The data should be naturally bounded enough that TTL should be good enough.
* Added scopes for explorations in metabot
* Explorations PUT safety (#78188)
Don't allow random assignment on PUT /api/exploration/:id
* Pick the right column as the metric for interestingness scoring (#78269)
Pick the right column as the metric
* Explorations unstranded threads (#78250)
Fixing ways threads can be stranded
* Hide SQL error messages from API response
* Explorations pass research plan (#78236)
Correctly pass research plan
* Revert in-place edits to historical instance-analytics view SQL
The explorations CASE branches were added directly to the v1/v4 view SQL
files, but those files back runOnChange changesets: editing them changes
the checksum, so on an already-migrated DB the old installers re-fire.
metabot_conversations v1 still selects c.summary, which the v64 rename
(summary -> title) has dropped, so booting the branch on a master-migrated
DB fails with 'column c.summary does not exist' (all six cross-branch
migration CI jobs).
The branches live on in the branch-new final versions (ai_usage_log v4,
metabot_conversations v6), so nothing is lost on fresh installs.
* Raise prefer-with-dynamic-fn-redefs ratchet to 29
The branch added four with-redefs ignores in explorations/queues_test.clj
(all justified: the redefined handlers run on MQ worker threads, which do
not see thread-local dynamic bindings, so mt/with-dynamic-fn-redefs cannot
work there) but the budget was only bumped to 28. Verified all 29 ignores
in the tree still sit on live with-redefs calls, so none can be removed
instead.
* Improving exploration error handling
* Fix with-current-user binding in exploration runner (#78163)
* Better error vs. empty handling in query cache
Conflicts:
src/metabase/explorations/query_plan/variants.clj
* self review
* Added FKs
* fix e2e
* Added visible-metric-cards-where-clause to queries.core
* Cleaned up removed-in-master agent:table scope
* Reuse Event card design from QueryBuilder
* Handle prompt injection
* Fix hotkeys hijack default actions
* Reuse the query instead of continually re-running it
* Fix extra rerenders on queries polling
* Review fixes
* Review fixes
* Fix chart skeleton for all screens
* review comments
* Fix Andrey's review comments
* Fix Andrey's review comments
* Fix Nemanja's review comments for e2e
* Fix Nemanja's review comments for e2e
* more review comments
* Ensure user has read permissions on cards when doing an explore-further
* Handle numeric PKs/FKs better in explorations (UXW-4757) (#77991)
Numeric PK/FK dimensions produced unusable one-bar-per-id charts and were
auto-selected as interesting dimensions. Two root causes:
1. The query planner treated any numeric dim as auto-binnable, but...
they're not, and we emitted unbounded bare breakouts, and ruled out
the top-n-other variant.
2. Dimension interestingness hard-zeros PKs but not FKs, and the
cardinality scorer's auto-binnable branch gave numeric FKs 0.9 for a
binned chart the QP can never produce, so they got auto-selected.
Fixes:
- default-bucket-for-dim returns nil for :Relation/* dims, so numeric keys
band by raw distinct count: <=100 -> default, >20/unknown -> top-n-other.
- top-n-other stringifies its CASE THEN values so numeric dims compile to
a single-typed CASE (mixed numeric/string CASE breaks strict warehouses).
- type-penalty hard-zeros numeric FKs (semantic FK + numeric effective/base
type). Non-numeric FKs (e.g. a country-code FK) stay scoreable.
- The cardinality scorer excludes :Relation/* from the auto-binnable
branch, matching lib's own binning rule.
* UXW-4475: self-heal missing metric dimensions in explorations read path (#78061)
Metrics whose dimensions were never successfully synced (dimensions NULL) -
e.g. metrics created on a model before it had result_metadata, or SQL-model
metrics predating the source-card sync fix - showed zero dimensions in the
explorations picker and Metabot research tools forever, because those paths
read persisted dimensions without ever syncing.
hydrated-metrics now runs sync-dimensions! for loaded metrics that have a
dataset_query but empty dimensions, then reloads the healed rows. Metrics
that compute nothing persist nothing and retry cheaply on a later request
(app-DB reads only, no warehouse queries, no write when nothing changed).
* Fix stored-snapshot permission gate ignoring card-sourced queries (#78455)
The data-access-lens capture derived its table set from
query->source-table-ids on the raw stored dataset_query, which only
collects numeric :source-table ids. For queries built on cards
(:source-table "card__N"), neither the creator's nor the viewer's token
picked up any tables, so both sandbox dimensions were empty and
data-access-compatible? returned true — letting a sandboxed viewer
stream an unsandboxed creator's snapshot.
Add query-perms/query->resolved-source-table-ids, which preprocesses the
query (as admin, via the existing preprocess-query helper) so source
cards — nested chains and card-sourced joins included — resolve to their
underlying table ids, and use it at both lens-capture sites (the
exploration runner's token write and the cached-result read gate). The
preprocess binds qp.i/*skip-middleware-because-app-db-access*, the same
flag the offline semantic checker uses, so the routing/impersonation
preprocess middlewares don't resolve the *viewer's* destination/role
(and throw on lapsed premium features) while we're only after the
query's table footprint. The helper throws when the card chain can't be
resolved (deleted card); non-admin viewers are then denied while
admins — never sandboxed, impersonated, or routed off the router db —
fall back to the same admin-only access as a nil token.
Also make stored_result.dataset_query NOT NULL via a follow-up
addNotNullConstraint changeset: the gate derives the viewer's lens from
it, so a query-less snapshot has no defensible verdict. A gate caller
passing a map without the key now gets a loud exception (500) instead of
a quiet fallback, so trimmed-row bugs surface in development.
Also fix explore-further-nested-thread-name-omits-metric-prefix-test to
reference a real exploration_page instead of a hardcoded id 999 that
violates the source_page_id FK on fresh databases.
* Fix build
* Fixed comment
* Better cached result testing
Conflicts:
src/metabase/queries/cached_result.clj
* Hash data access token info
* Added test from before
* Fix unit tests
* Fix Andrey's review comments
* Sloan review comments
* Explorations - fix metrics case (#78411)
* Add delay for Sidebar shimmer text, split ExplorationTreeNode
* Fixes for routing after merge
* Latest reviews fixes
* UXW-4908 new Comments UI
* add more analytics
* Code review fixes
* Fix scroll to view for focused comment
* Correct permissions "lenses" (#78697)
* Correct permissions "lenses"
Some early code had a huge, obvious bug, asserted in tests, for database routing.
For explorations, because queries are pre-computed, we need to calculate "lenses" representing the state
of database routing, sandboxing, and impersonation. These lenses are calculated when the exploration is
created, and then calculated when someone attempts to view the exploration. The idea is that if the lens
reveals that the viewer would have been able to view the underlying queries, then they should also be able
to view your exploration.
Until now, a viewer who was "unrestricted" on one of those dimensions was allowed to see a restricted
snapshot. I.e., a "non-sandboxed" user could see a sandboxed user's exploration, a "non-impersonated" user
could see an impersonated user's snapshot, and a "routed-to-the-router" user could see a "routed-to-a-destination"
user's snapshot.
The issue here... "unrestricted" does not necessarily mean "unrestricted." It's entirely possible that:
- User A is sandboxed with a sandbox that replaces Table T with card C, which actually queries Table FT.
User B is permitted to see Table T (unrestricted) but is blocked on Table FT.
- User A is impersonated - to a *more* permissive role. User B is unimpersonated, but should not be able to see
the results of a query made by User A.
- User A has database routing, configured to point to `MySecretTenantData`. User B is routed to `__METABASE_ROUTER__`,
which holds a template schema and maybe some data to play around with. User B should not be able to see the results of
A's exploration.
This simplifies the code here:
- superusers can see every exploration, regardless of lens
- everyone else sees an exploration's results *ONLY* when their data-access lens is *identical* to the creator's. Same sandbox
(and relevant user attributes), same impersonation role, same routing destination. Unrestricted only matches unrestricted.
Also hardened the write side: capturing the creator's lens fingerprint used to be best-effort, silently saving a snapshot with
no fingerprint when it failed. Now the exploration query fails visibly instead, and the read side treats a
fingerprint-less snapshot as a bug (denied to non-admins, logged as an error) rather than something to adjudicate.
* Apply suggestions from code review
clean up test descriptions and other docstrings
Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>
* Respond to CR
* fix impersonation test
---------
Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>
* Fixed compile issue
* Address query-plan review comments on #73244
- context.clj: correct the column-fingerprint-for-target docstring (missing
fingerprints make default/time-facet eligibility fail closed and route
dims to top-n-other, not the reverse), fix the applicability return-shape
doc, and extend the ns docstring to cover build-row-context.
- context.clj + variants.clj: remove the dead temporal_dimension_id override
plumbing (nothing ever sets that param; the mechanical planner never emits
per-value-time-series items).
- variants.clj: drop the unreferenced known-variants var.
- query_plan.clj + planner.clj: validate every planner result against
result-schema in the orchestrator, treating a mismatch as a planner
failure, and document that the schemas are enforced.
- planner.clj: fix the plan! docstring's claim that the ctx shape is
documented on generate-query-plan!.
* Remove unused planner result schemas instead of enforcing them
Drops plan-item-schema and result-schema from the planner contract (review
follow-up on #73244): they were never applied to any value, and rather than
wiring validation into the orchestrator we document the plan!/result shapes
in prose on the protocol. Also rewords the context ns docstring's
plan/validate/materialize mention accordingly.
* Trim redundant clause from query-plan.context ns docstring
* Fix FE typecheck and unit tests
* Fix some tests
* Fix dimensions display in NewExplorationData
* Select all dimensions for a metric by default
* Fix metric-dimensions dependency test vs metric dimension auto-sync
The test (from master) created a metric card with hand-written
dimension_mappings and nil dimensions. On this branch a metric with nil
dimensions counts as uncurated, so the after-insert dimension auto-sync
seeds dimensions from the query and overwrites the provided mappings —
the mapped categories table never became a dependency.
Set the mappings via a plain update after insert instead: the
after-update hook only re-syncs when dataset_query changes, so they
stick, and the test still exercises what it means to — the dependency
backfill turning mapped-dimension tables into table dependencies.
* Fix metric dashcard perms test vs metric dimension auto-sync
The tests (from master) expect a metric dashcard query to return the
bare aggregation. On this branch the after-insert auto-sync seeds
metric dimensions, and qp.dashboard applies the default time dimension
as a breakout, turning the scalar count into a monthly series.
Suppress the auto-sync when creating the metric so the test stays
focused on what it exercises: permissions on the metric's source
model.
* Fix FE typecheck and unit tests
* Skip tests that will be addressed in the next PR
* Fix loki tests
* Add logs to identify e2e test failure reason
* Fix CI e2e failure
* Review improvements
* Redact raw driver errors from the exploration payload
`GET /api/exploration/query/:id` deliberately allowlists its 409 payload and
substitutes a generic message, because a driver error names tables, columns,
and row values from a query the reader may not have permission to run.
`GET /api/exploration/:id` hydrates the same `ExplorationQuery` rows onto its
response and returned `:error_message` verbatim, so the raw error reached
anyone who could read the exploration. Declaring the key in the response
schema does not help: a `defendpoint` response schema validates and encodes
but does not strip.
Give the substitution one home in `redact-query-error` and route both paths
through it — `hydrate-exploration` covers all five `::HydratedExploration`
endpoints at a single point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Track data_access_token with thread and query instead of relying on the (not always there) cached result
* Key the derived-data verdict on both query projections
* Remove eager dimension-sync for metrics
* Fix unit tests after merge
* Fix typecheck
* Code review fixes
* Revert "Select all dimensions for a metric by default"
This reverts commit d5e8883c95.
* Code review fixes
* Fixing tests
* Fix e2e test
* fix issues after merge
* fix error when saving exploration in Our Analytics
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Andrei Chernykh <andrei.chernykh@metabase.com>
Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: Timothy S. Dean <timothy@timothydean.dev>
* refactor: extract reusable typed schema module
* remove obsolete database-name query parameter
* refactor: split typed schemas rest endpoint module
* refactor: move public facade to core namespace
* refactor: use bare namespace for typed schema endpoint
* docs: describe typed schema options
* refactor: narrow typed schema query parameter
* docs: explain typed schema database names
* docs: clarify typed schema model scope
* re-export render-typescript from core namespace
* refactor: tighten typed schema options
* docs: add docstrings to typed schema query parameters
test: clarify typed schema parser coverage
* refactor: isolate typed schema model scope
* docs: clarify typed schema model scope
* test: simplify typed schema model scope coverage
* test: simplify rest api tests
* test: separate typed schema API and core coverage
* test: cover typed schema database selection
* test: assert generated typed schema tables
* test: normalize typed schema timestamps
* refactor: introduce TypeScript AST between schema and rendering
schema->ast is a pure policy stage producing a tagged-vector AST
(documented and malli-schema'd in typed-schemas.javascript); render-js
is an option-free printer over it. Replaces print-time policy callbacks,
path-based node-kind guessing, and the reference magic-map. Output
verified byte-identical against the previous renderer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: split typed schema fetching from pure assembly
fetch-items is now the pipeline's only impure stage; create-schema
assembles Items into the semantic schema value and takes an info map so
generated-at/instance-url can be pinned for deterministic output.
build-semantic-schema composes the two, unchanged for callers. Folds
the base-schema namespace into core, drops the duplicate
database-id-filter-clause, and rewrites core tests against literal
items. Output verified byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: reify typed schema data access as a protocol
SchemaSource names every read fetch-items performs; app-db-source is
the production implementation. Tests reify the protocol with literal
values instead of redefining selection functions across namespaces.
Output verified byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* test: add real-db coverage above the in-memory pyramid
source-test exercises app-db-source against with-temp rows — the one
seam where typed schemas meet the database. core-test gains a single
end-to-end test running every entity kind (question, metric, model with
action, table with fields) from with-temp rows through fetch, assembly,
AST and rendering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* test: run the pipeline e2e against the test-data dataset
mt/dataset provides a real synced database, so the e2e cards sit on
real tables and fields — the question and metric need no hand-written
result_metadata, and metric columns are computed from real metadata.
Replaces the hand-rolled temp database/table/field rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* test: assert the e2e schema with =? pattern matching
One structural pattern replaces the key-plucking assertions; a single
exactness check keeps the database-scoping guarantee that partial map
matching would silently lose.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* test: sweep typed schema tests into =? pattern style
select-keys plucking becomes =? patterns (sets stay exact under =?, so
scope assertions keep their meaning); try/catch exception capture
becomes (is (thrown? ...)) + =? on ex-data; arg-asserting mocks become
stubs, with the argument pass-through now covered by the dataset e2e;
vestigial #' derefs on now-public fns are dropped; the five
prefer-with-dynamic-fn-redefs lint warnings are fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* docs: state the module's separation rules in namespace docstrings
Future changes should keep the boundaries: reads go through
SchemaSource, stages downstream of Items stay pure with environment
values entering via info, render policy stays schema->AST, TypeScript
syntax stays in the printer, and REST strings decode once at the
endpoint boundary. core-test documents where new tests belong in the
pyramid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: drop convenience arities orphaned by the source protocol
app-db-source always passes full arguments, so the single-argument
arities of select-schema-cards, select-tables, question-schemas,
metric-schemas and model-schemas had no production callers left. Each
fetch function now has one full-arity signature.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: define scopes and trim the library scope to its live keys
scope.clj now defines the vocabulary: references are portable ways to
name content (ids, names, entity ids); a scope is what they resolve to
on this instance — permission-filtered id sets with descendant
expansion, nil-vs-empty semantics, and the 404-vs-empty asymmetry
stated. LibraryScope gets a closed malli schema; its :collection-ids
and :library-collections keys were computed and asserted but read by
nothing, and are gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: move scope compilation to the fetch layer as scope-filter-clause
scope.clj now only resolves references into scopes; the fetch layer's
schema.common owns the one function that compiles a scope into a where
clause. The docstring states the nil-vs-empty enforcement and warns
against (seq ...) guards at call sites, which would turn 'resolved to
nothing' into 'unscoped' and select everything.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: batch collection reference resolution in typed schema scopes
Adds collection/descendants-flat-for (descendants of many collections
in one query, selecting :type) and rewrites scope resolution on top of
it: references partition into id and entity-id batches, resolve in at
most two selects, and 404s name every unresolved reference instead of
a bare 'Not found.'. Query count is now constant in the number of refs.
library-scope enforces its LibraryScope return schema via mu/defn.
Addressing, eligibility, and authorization are distinct layers in
resolve-collection-refs!, and their failures are deliberately
indistinguishable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: shrink the typed-schemas public surface to its contract
core now exports exactly three vars — SemanticSchemaOptions,
build-semantic-schema, render-typescript — mirroring the module's
data/print split; the pipeline implementation moves verbatim to the
internal pipeline namespace. New exports are compatibility promises,
and nothing needed the other five. The endpoint returns a ring map
directly instead of calling respond (async comes free), and
database-ids-for-ref uses select-one on the id path, documenting that
name references can genuinely match several databases (union semantics
pending a product decision).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* refactor: define the public API in core and name the build side
core now defines build-semantic-schema and render-typescript as visible
compositions of the internal stages instead of potemkin re-exports —
the public API is deliberate and decoupled from internal names. The
internal namespace becomes 'build': it interprets options into
SchemaSource reads and assembles the items into the schema value,
pairing with render/javascript as the module's two sides. The
compositions move out of build and render accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
* test: build e2e card queries with Lib instead of deprecated mbql-query
The e2e's cards now carry lib-built MBQL 5 queries
((lib/query mp (lib.metadata/table ...)) + lib/aggregate) rather than
hand-rolled legacy MBQL via the deprecated mt/mbql-query macro — which
also proves the pipeline handles MBQL 5 dataset_query cards end to end.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G69dZFxucVXhR1s11ZxmuB
---------
Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Drop dead QueryExecution delete from embed download test
with-query-execution! redefs save-execution-metadata!* and captures the
query execution in a promise, so nothing is written to or read from the
QUERY_EXECUTION table. The delete! only served to reach the app DB before
anything had initialized it, failing with "this database is empty".
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Persist the download format on export, never on format switching
The download popover remembers your last-used export format, but it was
persisting that preference on every click of the format segmented control,
spraying identical PUT requests at
/api/user-key-value/.../download_format_preference while the user just
looked around.
Switching formats now sends nothing. The preference is written as part of
handling an actual download, a fire-and-forget side task on top of the
export work, and only when it differs from the value the server already
has (the widget fetches it anyway to preselect the format, and the
mutation keeps the cached copy in sync, reverting it if the write fails).
Downloading with an unchanged format sends nothing at all. User-visible
behavior is unchanged: the popover still reopens on the format you last
exported with. The downloads e2e spec asserted the old
selection-persists behavior and is updated to the new semantics.
Considered and declined in review: guarding the comparison against a
still-loading preference (a download racing the initial GET can send one
redundant idempotent write; ordering a fire-and-forget path is not worth
it), and serializing overlapping preference writes (two different-format
exports within one HTTP round-trip could persist out of order; a mutation
queue for a preference value is overkill). Both races are rare, bounded,
and self-correcting on the next export.
* Trim the comments and the old-behavior test guards
Review feedback: the comment on persistExportFormat ran longer than the
function and said what the code already says, and both the e2e comment
and the no-request-on-switch assertions pinned behavior we removed
rather than behavior we built.
Also makes the optional chaining on formatPreference consistent within
persistExportFormat.
Refresh the bundled browserslist data to clear the "caniuse-lite is
outdated" warning on dev builds; the data was several months stale.
Lockfile only: bumps caniuse-lite to 1.0.30001806 and
baseline-browser-mapping to 2.11.9, and drops stale nested duplicates so
they resolve to the hoisted versions. Hand-edited because
update-browserslist-db misbehaves with bun in this repo.
* Support manifest-based runtime plugins
Summary
- Extract shared dependency pruning for runtime plugin uberjars.
- Initialize non-driver manifest plugins eagerly behind plugin API version 1.
- Preserve lazy loading for legacy driver manifests, while treating an empty driver list as a non-driver plugin.
Scope
This is capability-neutral infrastructure. It does not define an embedding API or ship an embedder.
Testing
- Plugin uberjar pruning tests.
- Plugin initialization, version-gate, empty-driver, and lazy-driver tests.
* Make runtime plugins lazy by default
* Document lazy plugin loader constraints
* Name plugin dependency checks after registration, not init
A satisfied `plugin:` dependency means the other manifest is registered, not that its
code is loaded. Rename the argument threaded through the dependency checks to say so, and
record where load ordering actually comes from for the two driver manifests that declare
one.
* Make a failed lazy-driver load retryable
Load the plugin before removing the placeholder `initialize!` method, so a load
that throws leaves the driver retryable instead of stranded on the no-op default.
Guard the removal so a driver that installs its own `initialize!` during load
keeps it.
Also clarify the `register-plugin!` contract (manifest plugins defer the classpath
add and code load; bare dependency JARs go on the classpath immediately), rename
`prune-provided-libs` to `prune-provided-libs!`, and mark its pure test parallel.
* Drop ^:parallel from prune-provided-libs-test
prune-provided-libs! logs via u/announce, so kondo's :metabase/validate-deftest
rejects the destructive fn inside a parallel test. Keep the ! name, drop the tag.
* Generalize bundled plugin loading to non-drivers; review nits
Rename bundled-driver-manifest-paths / load-bundled-driver-plugin-manifests! to
drop the driver-only framing -- the classpath glob already discovers any bundled
manifest -- and document that bundled plugins carry no add-to-classpath!, so their
code never comes from the writable plugins directory. Add a test asserting a
bundled plugin activates by running init steps only.
Also delay the plugin-uberjar core basis so requiring the namespace no longer pays
for a full dependency-tree resolution, and drop the unused init-plugin-with-info!
compatibility alias.
* Use with-dynamic-fn-redefs in the plugin init tests
Every binding here redefs a plain defn, so prefer the thread-safe dynamic redefs
(clears :metabase/prefer-with-dynamic-fn-redefs) and align each binding block's
values consistently.
* Register bundled manifests before plugins-directory manifests
Registration is first-wins, so a JAR dropped in the writable plugins directory
could shadow a bundled plugin's name and run its own code under that identity.
Load bare dependency JARs first (for class: deps), then bundled manifests, then
user manifests -- so bundled, root-owned code always claims the name.
* Reserve bundled plugin names so plugins-dir JARs cannot shadow them
A bundled plugin with an unmet dependency (Oracle/Vertica without their JDBC JAR)
never registers, leaving its name unclaimed -- a plugins-directory JAR could then
register under it and run as the bundled plugin. Reserve every bundled name up
front and refuse a colliding plugins-directory manifest.
* Reserve bundled names against the dependency batch too
Classification and registration read the writable JAR separately, so a JAR that
looks like a bare dependency but resolves to a bundled-name manifest could
register under it. Compute the reserved set once and pass it to both batches.
* Fail fast on concurrent plugin loading
* Align plugin framework forms
* Guard plugin registration and loading with one lock
Registration synchronized on the registered-plugins atom while loading claimed a second atom by compare-and-set, so
two different locks guarded two views of the same state.
Both paths now take one ReentrantLock: registration waits for it, and loading still fails fast when another thread
holds it.
Because that lock is reentrant, a plugin whose init step loads another plugin no longer trips the concurrency check
on its own thread -- nesting is not concurrency.
The registered and loaded name sets collapse into a single registry atom keyed by plugin name, which leaves
active-plugin-load as a diagnostic that names the loader in the rejection message.
* Reject plugin load cycles
A reentrant lock let a plugin load itself, directly or through a cycle like A -> B -> A.
`:loaded?` is only set once the init steps finish, so each pass through the cycle ran them again until the stack blew.
The lock now carries a stack of the loads in progress: nesting distinct plugins is still fine, re-entering one that is
already loading throws and names the cycle.
A rejected concurrent load no longer reports that plugin `nil` holds the lock when the holder is registering rather
than loading.
* Tidy the plugin cycle tests
Hoist the shared registration out of the first `testing` block, drop the closure that wrapped it, and share one uuid
between the two plugin names.
* Drop the temporary atom in update-unsatisfied-deps!
Everything here runs under plugin-lock, so a read and a reset does the job.
* Allow dropping items into sub-collections shown in the collection items table
* Trim comments in collection drop target
* Accept documents and metrics in collection drag-and-drop targets
* Improve collection drag-and-drop feedback
* code clean up
* Remove redundant React keys from drag source table row
* Retire redux navigation in favour of useNavigate
`push`/`replace`/`goBack` and the `CALL_HISTORY_METHOD` transport are gone.
Components navigate with `useNavigate`. Code that cannot call a hook (thunks,
the session middleware, a reducer, the permission and visualization config
objects) uses a module-level `navigate`, which is the navigator holder `AppShell`
already filled in, exported directly.
`metabase/router` exports its own `useNavigate`, which falls back to that
`navigate` when no router is mounted rather than throwing, because redux
navigation had no opinion on whether one was. The SDK mounts none, which is what
`useMaybeLocation` already exists for.
Four things this could not do mechanically:
* Relative targets resolved from the root under redux navigation, because
`AppShell` sits on a pathless layout route. `useNavigate` resolves against the
component's own match, so `SearchBar`, `SearchApp` and the command palette
needed absolute paths.
* A `LocationDescriptorObject` is assignable to v7's `To`, so passing one to
`navigate` type-checks and drops `state` silently. Six sites carried state,
including the QB card-state contract.
* `mapDispatchToProps: { onChangeLocation: push }` cannot hold `navigate`, since
`connect` dispatches what a bound creator returns. Five containers became
function components, the dashboard context supplies it in the provider, and
three `reference/` pages had it wired but never read it.
* A permission `actionCreator` returned an action to dispatch. It is now
`onSelect`, returning void, and `updateDataPermission` treats the presence of a
post action as handled rather than inferring it from a truthy return.
LOCATION_CHANGE and the location mirror stay: they are the router-to-redux
direction, feeding `isNavbarOpen`, `errorPage` and trace-id rotation.
* Hold navigations only when a router is on its way
A host that builds no router never registers a `navigate` for the holder to
flush against, so anything held is held for the life of the page. The SDK is
such a host and runs for a long time, so every `navigate` call from it retained
a closure and the `state` that closure captured.
`createAppRouter` and `createMemoryAppRouter` now say that a registration is
coming, because both put an `AppShell` above the tree. Without that, a
navigation with nowhere to go is dropped rather than queued, so a router-less
host retains nothing.
The queue stays a queue. Collapsing it to the latest navigation would change the
history stack: a push followed by a replace would apply the replace to the entry
the user arrived on, and lose the intermediate one.
* GHY-4251: Bound dependency backfill memory and honour its off-switch
The BackfillDependencies job OOM-killed instances with large warehouses. Native-query dependency
calculation resolved parsed table names by fetching the Database's entire table catalog and scanning
it in Clojure, so cost scaled with warehouse size rather than query size. Reading an entity also
attaches a MetadataProvider to its query, and the job bound no provider cache, so each entity in a
batch got a private provider holding its own copy of that catalog -- all of it retained for the
lifetime of the batch. At batch size 500 against a ~20k-table warehouse that is ~10M retained table
rows, which no amount of heap fixes.
Three changes:
- Look up only the tables a query names. New :name-ci metadata spec matches names
case-insensitively, since normalize-name lower-cases while sync stores whatever the warehouse
reported (Snowflake reports upper-case). Unlike :id/:name it does not widen the result set to
inactive or hidden objects, so it narrows an otherwise-unfiltered fetch without changing which
tables are eligible. The SQL narrows and find-table-or-transform still decides, so matching
semantics are unchanged. Transforms keep the existing scan -- their target name lives in a JSON
column with nothing to query -- which is fine at their cardinality.
- Scope a MetadataProvider cache to the backfill batch, spanning the select as well as the
processing loop, so entities share one provider per database instead of one each. This is the
invariant deps.findings/upsert-analysis! already enforces by throwing.
- Honour a non-positive batch size in schedule-run! for both dependency jobs. It is the supported
off-switch, but only task/init! checked it; the event-driven trigger did not, so entity changes
kept waking the jobs every second to do nothing. schedule-run! is the one function all three
scheduling paths share. The setting is env-var-only, so it cannot change while the process runs
and the job stays unscheduled until restart.
* GHY-4251: Record the failure when a fatal error kills a backfill entity
backfill-entity-batch! caught Exception, so an Error -- an OutOfMemoryError in the reported case --
escaped without recording anything. The next run then selected the identical batch and died the same
way, indefinitely, with no progress and nothing in dependency_status to show for it.
Catch Throwable instead and record the failure before letting an Error propagate, so a poisonous
entity backs off and eventually goes terminal and the batch can drain. Errors are still rethrown
rather than swallowed: the rest of the batch is abandoned and the job fails, because the JVM is in a
state we cannot reason about. Exceptions keep their existing per-entity behaviour.
* GHY-4251: Only let pending retries keep the backfill job scheduled when licensed
The job reschedules itself when a full batch was selected or when entities are in retry backoff. The
retry check ran outside the :dependencies feature gate that guards the work itself, so an instance
without the feature kept rescheduling hourly forever: a retry marker is only cleared by processing
the entity -- on success via upsert-status!, or by going terminal past max-retries -- and both are
gated on the feature, so the job could never resolve the condition keeping it awake. It took the
"No more entities to backfill for, stopping" branch never.
Gate the check on the feature. Requires the feature to be enabled, and gives up an accidental warm
standby: with the feature off and retries pending the job previously stayed scheduled and would pick
up work the moment the feature flipped. Recovery does not depend on that -- setting the token
publishes :event/set-premium-embedding-token, and every content-change handler triggers the job -- so
the remaining exposure is an upstream-only change to a token's feature set, which starts the backfill
on the next content change rather than immediately.
* GHY-4251: Correct the off-switch rationale in schedule-run! docstrings
Both docstrings claimed the batch size setting is env-var-only. It is not: get-raw-value consults the
application database after the env var, and :setter :none only blocks writes through the settings API,
so a stored value would be honoured if one existed. The conclusion is unchanged -- there is no
supported way to change it at runtime, so a cold stop until restart is right -- but the stated reason
was wrong, and it is the reason a future reader would rely on.
* GHY-4251: Stop the entity check job rescheduling when it cannot do anything
DependencyEntityCheck is a periodic checker with no terminal state, so it always queues another run.
That is correct while licensed, but check-entities! is gated on the :dependencies feature while the
reschedule was not, so an instance without the feature woke every 30-90 minutes forever to do nothing.
Same defect as the backfill job's retry check, reached by a different route.
Extract the reschedule out of the Quartz job body -- it only needed the scheduler, not the whole
execution context, which also makes it testable -- and gate it on the feature. If the feature is
enabled later the job restarts from the content-change event handlers, or from task/init! on the next
restart.
* Update docstrings
* GHY-4251: Record a failure for entities that have no dependency_status row yet
record-failure! only updated an existing row, so it silently did nothing for an entity that had never
been processed. Those are exactly the entities instances-for-dependency-calculation picks up first --
it matches a null status row -- so on a first backfill every entity is in that state. A failure
recorded nothing, the entity was reselected unchanged on the next run, and the crash loop the previous
commit set out to break stayed intact for the population most likely to cause it.
Upsert instead, via app-db/update-or-insert! like the other writers in this namespace. An inserted row
takes the column defaults for stale (false) and dependency_analysis_version (0); 0 is below
current-dependency-analysis-version, which is what keeps the entity eligible for the retry once
next_retry_at elapses.
This also fixes the "failure count: null" log line in the backfill catch path, which read back a row
that did not exist.
Found in review by escherize.
* GHY-4251: Make the dependency jobs' reschedule decisions licence-failure tolerant
Two problems with gating the reschedule on has-feature?, both introduced by the previous two commits.
The entity check job had no event wiring at all, while the backfill job derives
:event/set-premium-embedding-token. Stopping its periodic chain when unlicensed therefore left no way
back except a content change or a restart, because the content-change handlers that would revive it are
themselves gated on the feature. Enabling the feature on an idle instance left the checker dead. Mirror
the backfill job's wiring so setting a token restarts it.
has-feature? also collapses "no licence" and "could not check the licence" into the same false:
*token-features* returns #{} on any Throwable, so a network timeout or tripped circuit breaker reads as
unlicensed. Where that decides whether to schedule it is terminal, since nothing re-fires when the check
later succeeds -- the token never changed. Both inputs to the backfill job's decision consult the
licence, so a single blip stopped that job too.
Resolve the licence for scheduling with canonically-has-feature?, which reports nil when it could not
tell, and stop only on a definitive false. No token at all still answers false, so the common unlicensed
case still goes cold; a token that cannot be validated keeps the job alive. Doing the licensed work
still uses has-feature? and still fails closed -- fail closed on work, fail open on scheduling.
Tests for both jobs now drive all three licence states. The previous two-state tests expressed
"unlicensed" with mt/with-premium-features, which only rebinds *token-features* and so cannot produce a
definitive false; they were passing for the wrong reason.
Found in review by escherize and metamben.
* GHY-4251: Select the analysis batch inside its metadata-provider cache
analyze-batch! selected its instances before entering with-metadata-provider-cache, so each entity in
the batch got a private MetadataProvider attached to its query -- the same shape as the backfill bug,
same default batch size, other job.
Unlike the backfill job this is not a live leak: upsert-analysis! ignores the embedded provider, builds
its own from the entity's database id inside the cache, and passes it explicitly to check-entity, so
those providers stay empty. It is a trap rather than a bug -- the moment an entity is analyzed through
its own query, a batch of 500 retains 500 providers' worth of fetched metadata. upsert-analysis! already
throws when no cache is bound, so the cache was always meant to cover this work; only the select sat
outside it.
Moving the select inside the cache is behaviour-neutral today. The new test pins the intent by comparing
provider identity, since providers compare equal when they wrap the same database id.
Found in review by escherize.
* GHY-4251: Back the dependency test fixture's Tables with the application database
The shared fixture composed its Cards and snippets over metabase.lib.test-metadata, so its Tables existed
only in memory. That is fine for metadata the provider legitimately supplies, but resolving a table name
parsed out of native SQL is a lookup against the appdb, and an in-memory base has no rows to find.
Swap the base to the appdb-backed provider. The H2 test-data database already has the tables these tests
reference, so nothing needs creating; expectations move from mock ids to the real ones. Cards and snippets
stay mocked, since nothing looks those up by name.
These pass both before and after the implementation change in the next commit, which is the point: they
verify behaviour rather than a particular lookup mechanism.
* GHY-4251: Resolve table names with a t2 query instead of a MetadataProvider spec
Narrowing the catalog fetch was implemented as a new :name-ci MetadataProvider spec key. That put the
contract on every implementation for the benefit of one caller, and they did not agree: the overriding
provider ignored the key and fell into its fetch-everything branch, the composed provider took a
fetch-then-refetch path, and the cached provider keyed it by whole-spec so batch sharing was poor.
Resolving a table name parsed out of native SQL is a lookup against the application database, not query
metadata, so do it directly: lib-be/tables-by-name, case-insensitive because normalize-name lower-cases
while some warehouses report upper-case. The SQL still only narrows -- find-table-or-transform continues
to decide -- so matching semantics are unchanged.
Two consequences worth naming. sql-tools now uses lib-be, where the provider previously hid the appdb from
it. And a native query naming a transform's not-yet-created output table now resolves to {:transform id}
via the transform branch rather than {:table <invented-id>} from an override; a dangling invented table id
was the worse answer, but it is a change.
Verified with the OOM repro: 0 catalog fetches, completes in a 1200m heap where master OOMs. Per-card appdb
query volume is unchanged from the spec-key version.
Suggested in review by Alex.
* GHY-4251: Reschedule after a failed run, and keep a disabled job warm
Two changes to when the dependency jobs schedule themselves.
A failure no longer ends the chain. Both jobs are one-shot self-rescheduling with no cron backstop, so an
Error escaping the batch skipped scheduling entirely and the job stayed dead until a content-change event
or a restart. That is survivable for an OutOfMemoryError, which usually takes the process with it and lets
task/init! recover on boot, but not for a StackOverflowError from pathological SQL, which leaves a live
process behind. The backfill job body is extracted to run-and-reschedule! -- it only ever needed the
scheduler, not the whole execution context, which also makes it testable -- and reschedules on the way out
of a failure before rethrowing.
A non-positive batch size now suppresses only the event-driven trigger rather than all scheduling. The
1-second trigger fires on every entity change and never consulted the batch size, which is what made a
disabled job wake roughly once a second; that is the part worth stopping. The job's own periodic schedule
is left alone, so a disabled job keeps ticking slowly and resumes if the setting ever becomes positive
instead of staying cold until a restart. task/init! schedules regardless and logs that nothing will be
processed.
Note the resulting asymmetry is deliberate: scheduling now fails open on an indeterminate licence but still
stops on a non-positive batch size, because the batch size is definitively knowable and a token's status is
not.
Raised in review by metamben; Error case from escherize.
* GHY-4251: Cover the case folding tables-by-name depends on
The narrowed table fetch is a prefilter, not the decision: it selects with SQL lower(name) against names
lowered by u/lower-case-en, then find-table-or-transform matches precisely using the driver's
normalize-name. That is only correct while the prefilter returns a superset of what the precise match would
accept, and one of the two assumptions behind that is testable -- the appdb's lower() has to agree with
Java's.
Add a test that stores tables under ASCII and accented-Latin names and looks each one up by its own,
lower-cased and upper-cased spellings. It needs no per-driver harness: the backend workflow runs it on H2
and app-db.yml runs it on Postgres and MariaDB, so a collation that folds differently fails it there.
Deliberately out of scope, and noted as such: locale-dependent folds like U+0130, where lower() legitimately
differs between engines. The other assumption -- that a driver's normalize-unquoted-name is a pure case
fold -- cannot be tested from here, so it is recorded as a comment at the call site.
* GHY-4251: Cover the other assumption behind the narrowed table fetch
tables-by-name is a prefilter and find-table-or-transform makes the decision, so the narrowing is only sound
while the prefilter returns a superset of what the precise match accepts. The previous commit tested one of
the two assumptions behind that and claimed the other could not be tested from here. It can: "a driver's
normalize-unquoted-name is a pure case fold" is just the assertion that lower-casing the input and the
normalized output gives the same string. Trimming, stripping or substituting characters all break it, and
any of them would make the prefilter drop rows that should have matched.
Each implementation is invoked directly rather than through driver dispatch, so no driver needs registering
or initialising. The test also asserts which implementations it found, because `methods` only sees those
loaded in the run -- without that it would quietly keep passing while covering less and less.
Comment at the call site updated: both assumptions now name their test rather than one being described as
untestable.
* GHY-4251: Avoid the deprecated mbql-query in the new findings test
The full kondo lint that CI runs reports cross-namespace deprecation warnings that per-file runs do not,
and CI fails on warnings. The new provider-sharing test used mt/mbql-query, deprecated since 0.61.0 for
returning legacy MBQL 4.
The test only needs two cards with distinct queries, so use native ones, matching the equivalent test in
backfill_test. That avoids the deprecation rather than adding a namespace-level exclusion for it.
* GHY-4251: Resolve table ids with t2 in sql-tools, then fetch metadata through the provider
Replaces the lib-be/tables-by-name added in 88b011c527. Resolving a name parsed out of native SQL is a
lookup, so sql-tools does that itself against the appdb and gets back ids only; the metadata for those ids
then comes through the MetadataProvider via lib.metadata/bulk-metadata, which is the ordinary :id spec.
Better than fetching the metadata directly on three counts. lib-be grows no public API. Overrides and
caching still apply to the metadata, because it goes through the provider. And the :id spec is cached
per-id, so entities in a batch naming overlapping tables share those entries -- the whole-spec keying of the
earlier version meant {orders} and {orders, products} were separate cache entries fetching orders twice.
The active and visibility filter moves into the id query: an :id lookup does not apply it, and the catalog
fetch this replaced was active-only, so it has to happen where the ids are chosen. The case-folding test
moves with the function, and gains coverage of that filter.
sql-tools no longer depends on lib-be. The module config records :model-imports #{:model/Table} instead,
which scopes the new dependency to the one model rather than a whole module.
Verified with the OOM repro: still 0 catalog fetches, completes in a 1200m heap where master OOMs.
Review feedback from Alex.
* GHY-4251: Drop an incidental reformat from lib_be/metadata/jvm.clj
Left over from the :name-ci spec key that was added and then removed. Restoring the line takes lib_be out
of the diff entirely, so this PR no longer touches any Querying Platform owned path.
* Add backend granular-rerun collector
Scan hawk's JUnit output and emit the failed test vars as a hawk `:only`
vector so a failed backend/driver run can be rerun targeting only those
tests instead of the whole suite. Reuses the existing backend JUnit
adapter. Writes nothing unless every failure is attributable to a var, so
an unattributable failure falls back to a full rerun.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wire granular reruns into the driver test action
On a re-run of a failed backend/driver job, restore the tests that failed
on the prior attempt and run only those via hawk `:only`, instead of the
whole suite. After a failed run, collect the failed vars and upload them
as an artifact keyed by junit-name; a re-run downloads it and targets those
tests. Falls back to the full suite whenever no confident failed-test set
exists (first attempt, missing/empty artifact, or unattributable failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Exempt stress workflows from granular reruns
Add a `granular-rerun` input (default true) to the driver test action,
gating the restore/collect/upload steps on it. Stress workflows set it to
false: they intentionally run a fixed test set `:times N` to hunt
flakiness, and narrowing a re-run to only the failed vars would drop that
repetition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wire granular reruns into the non-driver backend tests
Extend the be-tests job with the same restore/collect/upload flow as the
driver action: on a re-run of a failed job, run only the tests that failed
on the prior attempt via hawk `:only`. The rerun drops the tag and
partition args, since the restored vars are already this partition's own
failures — re-partitioning the small set would drop half of them. Each
partition keeps a distinct artifact, so it reruns exactly its own failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Give app-db partition legs a per-job rerun artifact key
The granular-rerun failed-tests artifact was named solely from junit-name.
app-db's partition legs (Enterprise Tests, EE App DB Part 1/2) share one
junit-name, so they collided on a single artifact and a rerun could restore
the wrong leg's failed set. Add an optional rerun-artifact-key input to the
test-driver action and set a per-job-unique value for those legs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Collect hawk's var-less-errors JUnit file for granular reruns
hawk (PR #50) now writes fixture-init and namespace load/compile errors to a
fixed-name mb_hawk_var_less_errors.xml, with a testcase that has a name but no
classname. The backend collector only scanned *_test.xml, so it silently
ignored that file and the run's failing set stayed incomplete. Match the file
too; its classname-less testcase parses to a namespace-less failure, which
correctly forces a full rerun instead of a misleadingly narrow one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add a granular-rerun knob
* Scope the rerun artifact to the attempt that recorded it
The failed-tests artifact was named per job, so it outlived the attempt
that wrote it. An attempt that passed, or that failed with nothing
attributable to a test var, uploads no artifact, leaving the previous
one in place for the next attempt to restore -- narrowing a run that
should have gone wide.
Name it `...-attempt-N` on upload and read `attempt - 1` on restore, so
only what the immediately preceding attempt recorded is ever used and
anything else falls back to the full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Compute the previous attempt number in the shell
Workflow expressions have no arithmetic operators, so
`${{ github.run_attempt - 1 }}` is not a valid expression -- actionlint
rejects it, and the runner would not evaluate it either.
Resolve it with `$((GITHUB_RUN_ATTEMPT - 1))` in a preceding step and
read it back from the env context.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Clear pre-existing actionlint findings in the stress workflows
These workflows are only linted when a PR touches them, so the findings
below sat unreported until this branch edited both files.
`transforms-python-stress-test.yml` omitted the required
`previous-step-outcome` input on all eight `upload-test-results` steps;
each `test-driver` step already carries `id: test-driver`, so wire its
`test-outcome` output through.
`drivers-stress-test.yml` tripped shellcheck: an unquoted `$JAVA_HOME`
(SC2086) and three consecutive appends to an unquoted `$GITHUB_OUTPUT`
(SC2086, SC2129), now a single quoted group redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Rename logs-artifact-suffix to artifact-suffix and reuse it for reruns
The failed-tests artifact needs a per-leg discriminator, which test-driver
cannot derive on its own: `matrix` and `strategy` are unavailable inside a
composite action, and `github.job` is the job id, identical across legs.
`logs-artifact-suffix` already carried exactly that value, so reuse it rather
than take a second input for the same concept.
Name both artifacts from `github.job` + `junit-name` + `artifact-suffix`.
Neither of the first two is unique on its own once run-tests.yml pulls app-db,
backend, drivers and semantic-search into one run and one artifact namespace:
`be-tests-postgres` is a job id in two of them and both derive
`be-tests-postgres-ee`.
Fill in the suffix wherever a matrix runs concurrent legs but did not set one,
or set one that did not separate them. Those legs already collided on the test
logs artifact; without this they would also restore each other's failed-test
set on a rerun and re-run the wrong tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Correct the var-less error description in the rerun collector
hawk writes var-less errors to mb_hawk_var_less_errors.xml without a
`classname`; the header claimed they leave no JUnit trace at all. What makes
them unusable for a narrow rerun is the missing namespace, not a missing file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Let a colliding failed-tests upload fail instead of overwriting
The artifact name is scoped to the job and the attempt, so nothing legitimately
uploads it twice. With overwrite, a name collision would silently resolve to
last-writer-wins and a rerun would restore another job's failed set; without it
the upload 409s and says so. The step stays continue-on-error, so a collision
degrades to a full rerun rather than failing the job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Clear the restored failed-tests file when a rerun can't be narrowed
The collector signals "rerun everything" by leaving no file, but on a rerun that
path already holds the previous attempt's set, restored for the test step to
read. Declining to write left it in place, so the upload shipped last attempt's
list as this one's and the next attempt narrowed to it again — a namespace-load
failure could keep the full suite from ever running again.
Delete it on the no-confident-set path, and again in the CI step before the
collector runs, which also covers the collector dying before it decides.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move metabase:// protocol link helpers from metabot to urls
The metabase:// URL protocol helpers (parse/create functions, regex
constants, and entity model types) are a cross-cutting URL scheme used
by both metabot and rich_text_editing, so they belong in metabase/urls
rather than metabot/utils. Moved links.ts (and its spec) to
urls/metabase-protocol.ts, exported it from the urls index barrel, and
updated all importers.
* mysql mbql5
* clickhouse mbql5
* starburst mbql5
* sqlite mbql5
* presto-jdbc mbql5
* druid-jdbc mbql5
* vertica mbql5
* athena mbql5
* hive-like mbql5
* fix databricks
* fix path
* sqlserver mbql5
* oracle mbql5
* fix sqlserver tests
* fix test failures
* fix sqlserver test errors
* clean up
* kondo, test fix
* remove sql-mbql5
* remove from more drivers
* update empty string is null driver
* simplify helpers
* remove unneeded defmulti
* fix field clause
* sqlserver mbql5
* oracle mbql5
* fix sqlserver tests
* fix test failures
* fix sqlserver test errors
* clean up
* kondo, test fix
* small changes
* remove mbql-clause and mbql-clause-with-opts
* make fixes
* more fixes
* remove new mbql5 usages
* Require metabase.driver.sql.pivot so the [:sql :pivot] emitter registers
Removing the sql-mbql5 driver moved sql_mbql5/pivot.clj to sql/pivot.clj,
but the side-effect require moved nowhere: on master
metabase.driver.sql-mbql5 required its pivot namespace, and after the move
no production namespace loaded metabase.driver.sql.pivot at all. Without
that load the apply-top-level-clause [:sql :pivot] method never registers,
so native pivot queries compile to a plain GROUP BY with no pivot-grouping
column (breaking pivot results at runtime). Whole-suite CI masked this
because loading the pivot namespace's own test file registers the method.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* lower ratchets
* fix ratchets whitespace
* use map over mapv
* remove oracle :value
* fix bigquery
* add back comments, update change log
* move comments
* remove unneeded mbql4 cases
* move pivot to sql
* destructure after clause check
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* GDGT-2938: Review fixes
* Keep the broken-dependents column wide enough for its header
Capping the column truncated the 'Broken dependents' header, which turned on
the Ellipsified tooltip and left two copies of that text in the DOM once the
header was clicked. Its cells hold a short count, so auto width measures the
header and is already stable across pages.
* GDGT-2938: Address PR comments
* Load permission caches whole, with tables in a RoaringBitmap
Each of the three request-scoped permission caches now loads every
database on its first miss, instead of loading the scope it was asked
about and tracking which scopes it covers. That removes the reason the
prime functions existed: a caller no longer has to declare what it is
about to check, so `prime-table-perms-cache`, `prime-db-perms-cache` and
`prime-schema-perms-cache` are gone along with their twenty-odd call
sites, and with them the whole class of bug where a new call path silently
becomes a query per table.
Loading everything is only affordable because the table cache stops
storing a value per table. It now keeps, per permission type, one
RoaringBitmap per permission value holding the IDs of the tables that
resolve to it — a partition, so a lookup walks the type's values and stops
at the one that matches, and each table ID is stored once. Table IDs are
unique across databases, so nothing is keyed by database any more, and
only tables with a grant of their own appear at all.
The bitmaps are built by mutation while private to the load and never
written to once published.
Uncached callers — a check on a user other than the requester, or inside
`disable-perms-cache` — still load just the one table or database they
ask about, since no later check will amortize a wider load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Only use a permission cache that a request scope actually bound
The three caches are dynamic vars, and `use-cache?` checked only that the
user being asked about was the requester -- not that anything had bound a
fresh atom. Code running outside `with-relevant-permissions-for-user` read
and wrote the JVM-wide root atom.
That was survivable while a cache tracked which databases and tables it
covered, since an unseen one still forced a load. Now that a load covers
everything, the first check for a user writes "all of it, for good" into
an atom with no lifetime, and every later check for that user in the same
JVM is answered from it -- so a permission granted afterwards is never
seen. Tests, which set `*current-user-id*` freely outside any request
scope, poison it on the first check.
The root binding is now nil and `use-cache?` takes the cache, so an
unbound one reads through to the database. This is what bounds the
staleness each cache's docstring promises.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Record which databases a cache load covered
A cache that loads everything cannot tell "the user has no rows for this
database" from "this database did not exist when I loaded", and answered
both as no-permissions. Callers fall back to the least permissive value,
so a database created inside a request scope came back `:blocked` --
which is what broke the driver and app-db suites: `mt/dataset` builds a
database inside the scope `mt/with-test-user` has already opened, and
every check against it was denied.
Each cache now stores, per user, the set of database IDs its load
covered alongside the index. A question about a database outside that
set reloads. The set is read before the load, so a database appearing in
between counts as uncovered and forces a reload rather than being
wrongly marked covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Revert the bitmap cache, back to scoped permission caches
Loading every database on a miss traded per-call scoping for a cache that
then has to prove what it covered, and it cost two correctness bugs of
the same family -- the cache over-claiming, first across time (no
lifetime) and then across databases (no coverage record). The memory win
did not pay for that surface.
Back to master's caches, which load the table or database they were asked
about and record it. The next commit adds what this branch was really
after: making an unprimed check loud instead of silently expensive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Add strict cache-miss detection and prime the paths it found
Adds *perms-cache-misses-are-errors?*, bound true by
with-relevant-permissions-for-user in dev and test. A cache's first load
is ordinary; a later miss means an earlier load already covered a
different scope, which is the signature of an unprimed run.
prime-table-perms-cache now also primes the db cache and takes :db-ids,
since table-permission-for-user reads the database's own grant too, and
call sites pass :db-ids wherever they have them.
Genuine N+1s this found and fixed: readable-fields-only, metric
dimension filtering, and the schema listing's table filter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Throw on the fifth cache miss, not the second
Measured across the search, sandbox, dashboard and sync suites: at a
limit of three, 30 tests fail; at five, 18; and raising it to eight or
twelve changes nothing. So the cases between three and five are bounded
-- a dashboard whose parameters span two databases, an entity read plus
a chain-filter elsewhere -- and everything still caught at five is an
unbounded run. MB_PERMS_CACHE_MISS_LIMIT overrides it for re-measuring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Drop the miss-limit env var, keep the measured constant
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Correct the miss-limit measurement, revert the unfounded search prime
The 18 failures I measured against were pre-existing: the same batch on
the base commit produces them identically, so the apparent plateau from
five upward was those failures, not the limit catching anything.
Re-measured against that baseline: a limit of two flags 19 sites and
three flags 10, all bounded -- an entity read plus a chain-filter, a
dashboard spanning two databases -- and at four and above none flag. Five
stays, but as a backstop against runs that grow with the data rather than
a linter for every second check.
Also reverts the search prime, which was added on the assumption those
failures were permission misses. They were not, and it bought a query per
search for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Prime the db perms cache for search's per-table permission checks
`check-permissions-for-model` resolves `table` and `indexed-entity`
results against their database, so a search spanning N databases cost N
queries. Results are consumed as a stream, so the databases involved are
never known up front -- and a search ranges over all of them anyway, so
prime every database.
Caught by the strict cache-miss check: metabase.search.api-test's
permissions-test failed with 24 database-level misses, all from
search.impl/can-query?. Postgres-only because the appdb search engine is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Order broken pulses by id so notification order is deterministic
`broken-pulses` selected with no ORDER BY, so the order the notifications
went out in was whatever order the rows came back in.
handle-broken-subscriptions-due-to-bad-parameters-test asserts the second
email is the one about the slack pulse, so it fails whenever the two
broken pulses come back the other way round. Shard-dependent, since the
physical row order shifts as other tests churn the table -- it does not
reproduce standalone, and it fails on master too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Exclude destination databases from full-database selects
Destination databases are reachable only through their router and never
carry data_permissions rows of their own, so listing them just widens the
load. The permission graph in permissions_rest already filtered them;
admin-permission-graph and the new search prime did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Prime table perms, not just db perms, for search's table results
A `table` search result is checked with `can-query?`, which goes through
table-permission-for-user -- and that reads the table's own grant as well
as its database's. The database half short-circuits only when the db-level
grant is already maximally permissive, which is why the CI failure showed
database misses alone; a user with table-level grants would have gone on
to miss the table cache once per result.
prime-table-perms-cache covers both caches. Passing only :db-ids keeps it
affordable: the table cache holds data_permissions rows carrying a
table_id, so the load is bounded by the user's table-level grants rather
than by how many tables exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Point the cache-miss error at one prime function and one entity
The message listed all three prime functions and the ex-data carried only
a kind, so it said a check was slow without saying which check.
cached-database-perms takes the table-id its caller was checking, when it
has one. A miss reached that way is a table check -- table-permission-for-user
reads the database cache on its way through -- so it now reports kind
:table and names prime-table-perms-cache, which primes both halves;
prime-database-perms-cache would have left the table half unprimed.
The kind -> function map is spelled out rather than built from the kind, so
renaming one of those functions turns it up. Also renames db to database in
the function names touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Rename full-db-permission-for-user to full-database-permission-for-user
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Batch the db-ids table-perms load, and stop formatting ids as numbers
Two review findings, both real:
prime-table-perms-cache batched only its :table-ids branch. The :db-ids
branch passed the whole set to one statement, and search now hands it every
non-destination database, so a large enough instance would blow the
bound-parameter cap. Batched like its sibling and like
prime-database-perms-cache.
The miss message went through tru, so MessageFormat rendered table 1234567
as "table 1,234,567". It only ever throws in dev and test, so it does not
need translating -- plain format, no grouping. The test used id 42, which is
below the grouping threshold and so passed against the broken format; it now
uses ids past the thousands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The query_execution_partitioned migration failed on stats because the primary key
constraint had an inconsistent name; it originally came from h2 so it didn't match
the constraint name we saw in development. This migration should do nothing unless
the DB is in the same situation as stats where it has a bad constraint name.
`=?` applies an expected function to the actual value, so `complement` --
`clojure.core/complement`, the higher-order function that negates a
predicate -- evaluates to `(complement <actual-value>)`, which returns a
new function. Functions are truthy, so those checks passed for every
input and asserted nothing. `:hawk/key-not-present` is the assertion the
lines were reaching for: the key is absent from the spec.
Hawk also stopped feeding absent keys into the expected predicate as a
truthy sentinel, and now reports a missing key as a diff outright, so
the remaining checks over keys that are not present no longer pass
vacuously either.
In `can-change-from-password-test` that exposed two assertions the test
DB could never satisfy: it authenticates with an uploaded private key
and sets `use-password false`, so `:password` was absent from both the
details and the resulting spec, while the first two cases assert that a
password is present and takes precedence over a key file. The details
now carry a password to switch away from, which is what the test is
about.
In `normalize-use-password-test`, `normalize-details` only ever assocs
`:use-password true`, and only when no private key is configured. The
DB under test has a private key id, so the key is never added -- absent
rather than false.
* Validate OAuth token scopes for MCP
* Test MCP tool calls are refused outside token scopes
* Clean up scoped MCP token test
* Use scoped credentials for MCP UI
* Use MCP public API for UI credentials
* Fix credential test regex, auth-method arity test, reflection warnings
- api_test: the uiCredential extraction regex was over-escaped (matched
literal backslashes) and could never match the rendered HTML, so
mcp-ui-credential-validation-test failed on every backend job
- middleware session_test: auth-method grew a 5th arg (mcp-ui-info) but
auth-method-test still called it with 4, throwing ArityException; also
cover the new "mcp-ui" auth method
- session.clj: type-hint the signature comparison so Eastwood's
reflection check passes
- align let/map bindings per style guide; mention the MCP UI credential
path in wrap-current-user-info's docstring
- frontend: scope the no-literal-metabase-strings disable to the header
line with a reason; drop now-unused setSessionTokenHeader and update
stale comments that referenced it
---------
Co-authored-by: Bryan Maass <bryan.maass@gmail.com>
The "Failed to cancel sync" toast used the app-wide 5s default, which is
too short to read and screenshot, especially since the same handler
dismisses the sync modal underneath it.
Fixes#79029
BOT-1937: Restore the tool call details popup in Metabot chats
Moving the AI analytics views to Monitor replaced the self-contained tool
call modal with an onSelect callback, wired up only by the Monitor
conversation detail page. Normal Metabot chats pass no callback, so
clicking a tool call in a /debug chat did nothing.
Bring the modal back for callers that don't render details themselves,
composed from the existing ToolCallTitle and ToolCallDetailsContent so
the chat and the Monitor sidebar keep sharing one details renderer.
* GDGT-2931: Make Alerts Management tabs visible by default
Removes the full-page blocking loader on Alerts Management so tabs,
filters, and search render immediately, matching the pattern used by
Dependency Diagnostics and CLI/MCP analytics. The grid now shows an
animated skeleton while loading instead of blocking the whole page.
All three tabs (All/Failing/Ownerless) always render with a live count
badge instead of hiding tabs with zero results.
* Add positive assertion for the tab count loading skeleton
Give the skeleton badge a test id so the loading-state test can assert
it's actually rendered, not just that the count text is absent.
* Drop the visually-hidden loading status for the table skeleton
Reverting a restore I mistakenly applied earlier — this simplification
was an intentional manual edit, not something to undo.
* Remove the tab-switch refetch test and its listGate scaffolding
* GDGT-2931: Address PR comments
* Use react-router's own <Navigate>
* Interpolate redirect targets with generatePath and resolve them like v7
* Justify queryToSearch's encoding on its own terms, not history@3's
* Re-export react-router from the barrel instead of a file each
* Flatten the v7 folder into metabase/router
* Drop the V7 suffixes and merge the two router providers
* Use react-router's own hooks and types instead of mirroring them
* Drop the dead function form of a Link target
The subscription save path borrowed getActionErrorMessage from
metabase/actions/utils, whose fallback text ("Something went wrong
while executing the action") is wrong for this call site. Call
getResponseErrorMessage from metabase/utils/errors directly with a
subscription-specific fallback, and drop the actions import.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Put CI MySQL/MariaDB datadirs on tmpfs
A full app-db migration chain on a stock mysql:8.0 container is ~59s of
which ~44s is fsync waiting: MySQL 8 images default to full durability
with the binlog on (log_bin=1, sync_binlog=1, flush_log_at_trx_commit=1),
every DDL statement implicitly commits, and DDL additionally fsyncs the
table files it creates — one measured setup-db! run performed ~50k data
fsyncs + ~37k redo-log fsyncs across 1,464 changesets. No statement is
hot; it is a uniform ~30ms/changeset disk tax, paid for a database CI
deletes minutes later.
Durability knobs cannot recover it: the dynamic ones
(flush_log_at_trx_commit=2, sync_binlog=0) win back only ~20s because DDL
data-file fsyncs are not governed by them, and --skip-log-bin needs a
server command-line flag GHA service containers cannot pass. --tmpfs fits
the one field they do accept (options:) and neutralizes every fsync class:
measured 59.3s -> 15.8s per setup-db!, reproduced.
Applied to every mysql/mariadb service container (app-db, drivers,
semantic-search, cross-branch-migrations). Expected effects: the :db init
timeouts that outran #79014's 120s bump within a day become structurally
impossible (~5-16s init), the ~25min of Liquibase inside a mysql app-db
job mostly evaporates, and job duration stops tracking runner disk
contention. Cost: the app db lives in runner RAM (test DBs are MBs) and
images with pre-baked datadirs re-initialize at container start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCo9W7tgK13nNSfWTtQoUc
* Add required description to the cross-branch-migration-test action
Pre-existing actionlint error surfaced by touching
cross-branch-migrations.yml (actionlint scopes to changed files).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCo9W7tgK13nNSfWTtQoUc
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Run route-leave guards on native useBlocker
* Chain the route-leave guards and scope the registry to the provider
* Keep the prompted destination when another navigation supersedes it
* Keep the held navigation in the ref, not resynced from state
* Resume the captured navigation instead of reissuing it
* Revert the supersede handling, keeping the guard fan-out
* Stop the dashboard syncing its URL while a leave prompt is up
* Partition the query_execution table when using Postgres AppDB.
The `query_execution` table gets very big on large installs and poses
performance problems. Partitioning it will allow us to get those under control.
This will only apply when using Postgres as the AppDB. If you have an H2 AppDB,
it's probably not big, and if you have a MySQL AppDD, then, uh, well, too bad.
The basic plan is to rename the existing `query_execution` to
`query_execution_old` and then create a new `query_execution` partitioned table
with the same schema. Once that's done, we can attach `query_execution_old` as
a partition to the main table, and all the data should be available transparently.
Unfortunately partitioned tables have gotchas that complicate things. This
means the migration plan is split into different steps.
The partitioned table must not have a primary key that doesn't include the
partition key. In order to do this, we have to stop using `id` as the primary
key and replace it with a composite key of `id, started_at`.
The first migration swaps the primary keys around, dropping the ones
based on the id alone and replacing them with ones based on `id` and `started_at`.
The second migration renames `query_execution` to
`query_execution_old` and then creates the new `query_execution` table
which is based on `query_execution_old` but partitioned by
`start_at`. Then it takes the `query_execution_old` table and attaches it as a
partition into the full `query_execution` table. This partition covers up to
the current month, plus one more after, in order to handle the case where the
migration runs at the end of the month and query executions need to be written
to disk before the job to create the next partition gets a chance to run.
The table's IDENTITY has to be dropped since this can't be done in a partition;
it should be handled by the higher level table.
The v_query_log view references the old query execution (now `query_execution_old`)
table. We need to re-create the view to point it at the new table instead.
Finally a migration dynamically calculates a new name for `query_execution_old`
to turn it into `query_execution_2026_08` or whatever, depending on
the date of the data that the table will actually contain.
* Add a metabase.app-db.partitions jobs for managing old/new partitions.
The migration puts us in a good state for the present, but as time
goes on, we'll need new partitions for new months. The new
`metabase.app-db.partitions` namespace handles this by defining a new
`ManagePartitions` job that runs daily to see if any new partitions
need to be created or whether any existing partitions need to be
detached.
Each run checks the current month's partition and the next month's
partition and creates them if they haven't been created yet. It also
checks for the retention cutoff period to see which partitions to
detach.
The individual logic functions and the `manage-partitions` function
that tie them together are tested, but the `defjob` is not; I don't
know if there is a way to test jobs.
There is no mechanism for actually deleting the old data once it
passes out of the retention period; it merely becomes detached. We may
need to document this somewhere so that users know they can clean it
up to save disk space. (It's unclear to me where that would be
documented.)
* Disable truncate-audit-tables on query_execution when using postgres.
* Address review suggestions.
* Set sequence on new partitioned table to match old sequence value
* Ensure partitioning job is loaded at startup
* Fix off-by-one error in partition boundaries for dropping
* Use locale-independent yyyy year formatting
* Move to metabase.audit-app.task.partitions
Still needs to address the fact that rollback only converts a single
table instead of all partitions back into one table.
* More review suggestions:
* Fix rollback sequence restoration to target old identity sequence.
* Fix rollback of table renames to use the oldest partition.
* Make partition management cron fire immediately in cases when it's been missed.
* No need to export retention days setting in audit-app module.
* Don't recreate primary key with id, started_at; just drop the primary key.
As far as I can tell, the primary key is not used any more since the
field_usage table got dropped; all reads of this table are just counting.
* Drop partitions instead of just detaching when cycled out of retention.
* Fix rollback to drop view first, then recreate at the end.
* Avoid relying on max(id) in partition migration.
The index will be gone by the time this runs, so it would result in a
full table scan of a HUUUUUUGE table.
* Bump `hawk` to `1.0.18`
This corresponds to
c17e2b9b4e
* fix bad tests
* select data key
---------
Co-authored-by: Riley Thompson <rileythompson99@gmail.com>
* Retry the quarantine list fetch before giving up
A dropped socket or a 5xx from ci-conductor made the gate report COULD NOT
CHECK on the first try. Transport errors, timeouts, 429s and 5xx now get three
attempts with exponential backoff and jitter; a 4xx returns straight away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* improve retry api
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <iethree@gmail.com>
* Add allowedCustomVisualizations setting to EAJS embeds
This commit adds the plumbing necessary to pass down the
value of the allowlist for custom viz.
It warns when wrong names are passed (ie: not starting with `custom:`),
and forces [] for guest embed as the feature is not supported there.
* Move sandbox-host to its own namespace to remove +auth from it
The donor document is loaded as an iframe src, which cannot carry session
headers, so the endpoint must be reachable without authentication. Auth is
applied per-namespace by ns-handler in custom-viz-plugin.api and cannot be
lifted for a single endpoint, so the endpoint moves verbatim to its own
namespace, mounted without +auth next to the session-authed routes (both
still behind the :custom-viz premium gate). No behavior change other than
dropping the auth requirement.
* Serve the custom viz sandbox donor to EAJS embeds
Makes the donor iframe-able as long as either a valid session is passesed
or the correct sec-fetch-site headers are passed.
* Allow blob: images on the EAJS embed page for custom viz icons
Custom viz icons in the chart type picker are served by the SDK asset
manager as blob: URLs (a cross-origin img cannot carry the session
header, and EAJS has no cookies). The modular embedding page runs on the
Metabase origin under a strict img-src that omits blob:, so the browser
blocked the icons and left a broken image in the picker. Add blob: to
img-src only for the /embed/sdk/ entrypoint, gated on a URI predicate so
the main app and static or public embeds keep the stricter policy.
* chore: tidy the SDK release dispatch forms and bump output
The dispatch form renders an input's description as the control's label,
which is a single plain text node: newlines collapse to spaces and no
markdown is parsed. Both SDK release workflows had multi-line
description blocks written as if they rendered, so release_type's
occupied ten rendered lines and pushed the select below the fold.
- release_type: one-line description keyed by option, options reordered
into release-cycle order, and default: alpha pinned so the reorder
does not move the preselection. Option values are unchanged.
- Detail that no longer fits the form moved to the file header.
- Shortened the two read_first option strings that overflowed the
collapsed select at 44 and 81 characters.
- Gave the version-bump PR body's table a real header instead of an
empty one (EMB-2167).
- Capture the new PR's URL and append it to the job summary; the
existing summary step runs before the PR exists (EMB-2168).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo
* chore: capitalise release type names in the dispatch label
The label renders as plain text, so no markdown emphasis is available.
Caps makes each option findable when scanning the wrapped paragraph.
Values stay lowercase - this is display emphasis only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo
* chore: say what each dispatch input is for, not what it isn't
prerelease_id read as an output description, so it never said what to
type. Phrase it as a mapping: enter data-apps, get 0.62.5-data-apps.0.
Same length, same six rendered lines.
The release workflow's label opened with a double negative - use it
'when neither the bump PR nor re-running the failed jobs will do it' -
which left the reader to infer the purpose. It is a manual override, so
say that first. Retry guidance stays in the file header, where there is
room for it. Eight rendered lines down to six.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo
* chore: drop a redundant comment above the dispatch label
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENnTtrTcig1vH3QADbsGgo
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependency upgrade: align both drivers that ship org.apache.thrift/libthrift on the latest release (0.24.0).
hive-like: bump the existing direct pin 0.16.0 → 0.24.0. Since 0.19.0, libthrift declares runtime deps (httpclient5, httpcore5, jakarta.servlet-api) that only serve its THttpClient/TServlet classes; hive-jdbc is built against httpclient 4.x (already pinned in this module) and cannot use them, so they are excluded to keep the driver jar lean. Verified by scanning every class in the built jar: the only classes referencing those packages are libthrift's own five HTTP/servlet classes - nothing in the hive/hadoop stack touches them.
databricks: pin 0.24.0 over the 0.23.0 bundled with databricks-jdbc-thin 3.4.1, following this module's existing pattern of pinning newer transitives (lz4, bouncycastle, jackson, commons-configuration2).
Previously the two driver jars bundled different libthrift versions (0.16.0 and 0.23.0) into the shared plugin classloader; this aligns them on a single version.
Also updates the Spark SQL connection form's "additional JDBC options" placeholder from ;transportMode=http to ;transportMode=binary - binary is the default and supported transport; hive-jdbc's http transport mode is not compatible with libthrift ≥ 0.19 (it passes an httpclient 4.x object to a constructor that now takes httpclient 5.x, see #46793), so the form should not suggest it.
Routine dependency upgrade: bumps the pinned at.yawk.lz4/lz4-java (maintained fork of the discontinued org.lz4:lz4-java) to the latest release, 1.11.1, in the ClickHouse and Databricks driver modules.
Upstream changes between the pinned versions and 1.11.1 are additive only (new fast-reset compressor classes, a linux-riscv64 native binary, bugfixes) — no classes removed, same net.jpountz packages, so it is a drop-in update for both JDBC drivers. Neither driver module calls the library directly.
* GDGT-2884: Move AI analytics to Monitor (#78222)
* GDGT-2874: Relocate AI analytics source under monitor/ai-auditing
* GDGT-2874: Add Monitor URL helpers for AI Auditing
* GDGT-2874: Repoint hardcoded legacy paths to Monitor URL helpers
* GDGT-2874: Swap MetabotAdminLayout for MonitorMain in AI Auditing pages
* GDGT-2874: Fix stale audit_app import in metabot-analytics test-support mock
Plan gap: this file wasn't in Task 1's moved-tree scope and wasn't listed as an
external consumer to fix in a later task. Caught during Task 4 verification
via type-check-pure.
* GDGT-2874: Add AI Auditing sections to monitor_section_clicked event
* GDGT-2874: Add canAccessAiAuditing selector and route guard
* GDGT-2874: Add combined AI Auditing routes getter and plugin index
* GDGT-2874: Move AI Auditing plugin wiring from PLUGIN_AUDIT to PLUGIN_MONITOR
* GDGT-2874: Mount /monitor/ai-auditing route block
* GDGT-2874: Add AI Auditing tab to Monitor nav
* Add regression tests for AI Auditing getActiveSection ordering
Lock in the order of the mcp/conversations/usage-stats route-matching
clauses in getActiveSection: the bare Urls.monitorAiAuditing() path
string-prefixes both sub-paths, so the more specific clauses must be
checked first (ts-pattern's P.string.startsWith is first-match-wins).
Without this, a future reorder would silently break active-tab
highlighting for the two sub-routes.
* GDGT-2874: Remove Auditing folder from Admin AI nav
* GDGT-2874: Add legacy redirects for /admin/metabot/usage-auditing
* GDGT-2874: Fix moved AI Auditing spec mocks and path assertions
* GDGT-2874: Repoint slash-command URL helper off the legacy admin path
The Metabot /inspect slash command still built its target URL via
metabase-enterprise/urls.ts's adminMetabotUsageAuditingConversation,
which hardcoded the old /admin/metabot/usage-auditing/conversations
path. It only worked because it bounced through the legacy redirect.
Delete that helper (its only caller) and use the canonical
Urls.monitorAiAuditingConversationDetail directly.
* GDGT-2874: Update E2E specs to canonical /monitor/ai-auditing URLs
usage-auditing.cy.spec.ts and mcp-analytics.cy.spec.ts predate the
Monitor migration and still visited/asserted on the legacy
/admin/metabot/usage-auditing* URLs, which only worked via the legacy
redirect. In-app navigation (row clicks, breadcrumbs, back button) now
lands on /monitor/ai-auditing* URLs, so any post-navigation URL
assertion in these specs was exercising stale paths. Point every
cy.visit()/pathname assertion at the new URLs, and add a small
regression test asserting the legacy URL still redirects, since the
main specs no longer exercise it directly.
* GDGT-2874: Apply AI Auditing cross-review fixes
* GDGT-2874: Add AI Auditing availability placeholders
* GDGT-2874: Gate AI Auditing pages with settings placeholders
* GDGT-2874: Keep unavailable AI Auditing links navigable
* GDGT-2874: Prioritize global AI settings for MCP availability
* GDGT-2874: Fix empty screen layout
* GDGT-2874: Use semantic AI Auditing navbar icons
* GDGT-2874: Restore Audit plugin ownership
* GDGT-2874: Test legacy redirects in app routes
* GDGT-2874: Add canonical Usage stats route
* GDGT-2874: Cleanup
* GDGT-2874: Address PR review feedback
- Rename AiAuditingAvailabilityLayout -> AvailabilityLayouts: the file
exports two layout components (Metabot and MCP), neither named after
the file/folder.
- Use !!useSetting(...) for the enabled/configured flags, matching the
convention used everywhere else these settings are read.
* GDGT-2883: Restyle AI Auditing to match Monitor (#78356)
* GDGT-2874: Fix moved AI Auditing spec mocks and path assertions
* GDGT-2874: Apply AI Auditing cross-review fixes
* GDGT-2883: Restyle AI Auditing to match Monitor
* GDGT-2883: Fix inconsistencies
* Fix issues surfaced by restacking onto gdgt-2874
- Remove index.unit.spec.ts: orphaned artifact from a superseded
gdgt-2874 draft, testing a ./index module that was never actually
relocated here (registration logic still lives in audit_app/index.js,
already covered by routes.unit.spec.tsx).
- Un-mock ConversationHeader in ConversationDetailPage.unit.spec.tsx:
the blanket mock predated the conversation-title test and was
hiding the real header it needs to assert against; the endpoint
mocks setup() already provides make the real component safe to
render.
- oxfmt fix in ConversationsTable.tsx.
* GDGT-2883: Fix Monitor layout consistency across AI Auditing pages
- Contain the Tool calls grid to the viewport (MonitorMain only clips
when its parent is a flex container, so wrap it in a Flex h="100%"
on the events tab; the charts tab keeps natural page scroll)
- Do the same for the Conversations grid page
- Hide the events table's pagination until it has loaded once, instead
of showing a nonsensical "1 - 0 of N" range
- Add showTotal to the Conversations page's pagination, which was
missing it entirely
- Match tabs/filters/content spacing (md) to Background Tasks on Usage
stats and MCP analytics, which had drifted to lg
- Vertically center the ellipsis menu button against the conversation
title in ConversationHeader
* GDGT-2883: Make all AI Auditing grid columns sortable, fix non-clickable cursor
- TreeTable: only show the pointer cursor on rows that actually respond
to a click (onRowClick or getRowHref set), instead of unconditionally
on every row. Fixes MCP analytics' Tool calls grid looking clickable
when it isn't, and the same latent bug on several other display-only
TreeTable consumers.
- MCP analytics Tool calls: wire up sorting for the remaining columns
(ID, Client version, Tenant, IP address, Error type, Error message).
The query builder already supported ordering by any valid view
column, so this is purely frontend wiring.
- Conversations: add Title as a sortable column, including the backend
allow-list/schema entry it needs. Queries and Searches stay
unsortable — they're computed in-memory per page from message
content, not real SQL columns.
- Fix a stale field name (summary -> title) in ConversationsPage's
test mock that was failing type-check.
* GDGT-2883: Show tool call details in a sidebar on the Conversation Detail page
Replace the tool-call-click modal with a Monitor sidebar, matching the
rest of the AI Auditing views. The live Metabot chat widget keeps its
modal — AgentToolCallMessage now takes an optional onSelect callback
that, when provided, defers to the host instead of opening the modal.
Also style the sidebar's Request/Response copy buttons with the same
Copy/Copied! tooltip used elsewhere in the conversation view, and fix
a pre-existing bug where copying re-stringified the already-formatted
JSON text, copying an escaped string instead of clean JSON.
* GDGT-2883: Toggle the tool call sidebar closed on a second click
Clicking the same tool call record that's already selected now closes
the sidebar instead of re-opening it with the same content.
* GDGT-2883: Constrain conversation content to an 800px column, fix page scroll
Wrap the whole page body (header, stats, feedback, conversation, and
generated queries) in a single horizontally-centered 800px column
instead of leaving it full width.
Fix the page-level scroll along the way: the Flex wrapper added for
the tool call sidebar's width measurement gave MonitorMain's parent
display:flex, which made MonitorMain's flex+overflow:hidden CSS
actually take effect and clip content instead of letting the page
scroll. This page has no internal scroll container of its own (unlike
table-driven Monitor views), so drop the bounded height and let it
scroll naturally.
Also add the missing Copy/Copied! tooltip to AgentToolCallMessage's
own copy button, matching the one already added to the sidebar's
Request/Response copy buttons.
* GDGT-2883: Make AI Auditing pagination abortable, sync page in URL instantly
Every paginated AI Auditing grid (MCP analytics Tool calls, Conversations)
kept a stale in-flight request running when the user paged again before it
resolved, and the URL's page param lagged behind clicks by the shared
useUrlState hook's 300ms debounce — unlike Dependency Diagnostics, where
page changes are both abortable and instant.
- Swap McpEventsTable's and ConversationsPage's queries onto
useAbortableQuery (the same hook DD already uses), so switching pages
cancels the stale request instead of letting it complete in the
background.
- Add an opt-in `immediate` option to useUrlState's patchUrlState that
bypasses the debounce for a single patch, and use it for pagination
clicks specifically. Filter/sort changes keep debouncing as before —
this only affects callers that pass the new option.
* GDGT-2883: Sync pagination to the URL instantly across other Monitor list pages
Same delayed-URL-on-pagination-click bug as AI Auditing, found on a sweep
of every useUrlState consumer with a paginated grid: TaskListPage,
TaskRunsPage, NotificationsAdminPage, and ErrorOverview. Pass the new
patchUrlState immediate option on their pagination handlers too.
TaskListPage's pagination test asserted the old debounced timing (URL
unchanged immediately after a click, then advance the fake timer); updated
it to assert the new instant update instead. The other pages' tests only
asserted the eventual URL state, so they needed no changes.
* GDGT-2883: Use the empty-dashboard illustration for the disabled MCP analytics page
Swap the generic metabot icon for the EmptyDashboardBot illustration and
make the CTA a secondary button, matching other Monitor empty states.
Scoped to the MCP-disabled case only — the AI-features-disabled and
not-configured states elsewhere in this file are unchanged.
EmptyState gains an optional actionVariant prop (defaults to "filled",
preserving every other caller's current look).
* GDGT-2883: Apply cross-review a11y/correctness fixes
- Empty-dashboard illustration + secondary CTA now apply to all four
AI Auditing unavailable states (AI disabled, not configured, MCP
disabled), not just MCP-disabled — that was the intended scope; a
narrower revert from a review pass was itself reverted here.
- Give the tool call copy buttons accessible names (were icon-only).
- Make TreeTable sort column headers keyboard-operable.
- Clear the selected tool call when navigating to a different
conversation, so the sidebar can't reopen with stale data.
- Move focus into the tool call details sidebar on open and back to
the trigger on close; label it as a region.
- Ignore a hidden/PII-gated sort column from a stale URL instead of
querying by it.
- Add a Title-sort UI test and a one-shot immediate-bypass regression
test.
* GDGT-2883: Fix code-review findings in TreeTable and AI Auditing
- Stop sortable header keydowns from bubbling to the treegrid, which
could also activate a stale keyboard-focused row
- Give every populated TreeTable header the columnheader role, not
just sortable ones
- Drop the redundant onRowClick push() on conversation rows, which
double-navigated alongside the row's own link
- Render the MCP analytics loading/empty states inside the active
Tabs.Panel so the selected tab's aria-controls stays valid
* GDGT-2883: Trim redundant comments in McpEventsTable and ConversationDetailPage
* GDGT-2883: Cleanup
* GDGT-2883: Fix e2e tests
* GDGT-2883: Address PR comments
* GDGT-2919: Migrate CLI usage analytics to Monitor (#78900)
* Project MCP events query to curated PII-aware columns
Restricts buildEventsQuery to exactly the columns the events table
displays, keyed off tenant/PII retention settings, instead of shipping
the view's full row (including ip_address/error_message) to the
browser regardless of the setting.
* GDGT-2919: Migrate CLI analytics to Monitor
* GDGT-2919: Drop ai-features-enabled? gate for CLI analytics
The gate was new, not preserved: the old admin route only checked
audit_app/admin access, with no ai-features-enabled? check, and CLI's
v_agent_api_calls data is independent of Metabot/LLM configuration.
* Fix oxfmt formatting
* GDGT-2919: Bring CLI analytics UI in line with the rest of Monitor
- Move the calls filter bar under the tabs and use MCP's spacing values,
keeping tabs/filters mounted (and visible) through loading and empty
states instead of swapping the whole Tabs block out.
- Replace CliEventsTable's AdminDataTable (admin-only, fixed
maxBodyHeight) with TreeTable, matching McpEventsTable's structure so
the Calls tab fills the available height like the rest of Monitor.
* Replace withRouteProps with useRouter across Monitor pages
withRouteProps is documented as a temporary v3-router migration shim
that reads location/params via useRouter() and re-injects them as
props. Have the Monitor pages call useRouter() directly instead and
mount them as plain route elements, dropping the wrapper.
* GDGT-2919: Make all Calls tab columns sortable
Matches MCP's events table, where every column (including the PK and
PII columns) is server-sortable. call_id/tenant_name/ip_address/
error_message are all plain columns on v_agent_api_calls, so ordering
by them needs no query changes beyond adding them to the sort list.
* GDGT-2919: Project CLI events query to curated PII-aware columns
buildEventsQuery shipped the view's full row (including ip_address and
error_message) to the browser regardless of the tenant/PII retention
settings; the frontend only hid those columns from the rendered table.
Add cliEventColumnKeys as the single source of truth for both the
query projection (Lib.withFields) and the display columns, mirroring
the same fix already applied to MCP analytics.
* Use absolute imports for cross-module paths in ai-auditing
Replace ../ relative imports (mcp-analytics/cli-analytics reaching
into metabot-analytics, and cross-directory imports within
metabot-analytics) with the metabase-enterprise/... absolute alias.
Same-directory ./ imports are left as-is.
* GDGT-2919: Show an error instead of spinning forever in CliEventsTable
useCliEventsQuery didn't surface the query's error, so a failed
per-page fetch left data undefined and the table stuck on the
skeleton indefinitely. Surface the error and render
LoadingAndErrorWrapper instead.
Also drop useRetainedValue: RTK Query's data (unlike currentData)
already stays sticky across page/sort changes until the next result
resolves, so the manual retention was redundant.
* GDGT-2919: Show an error instead of spinning forever in McpEventsTable
Mirrors the CliEventsTable fix: useMcpEventsQuery now surfaces the
query error, and McpEventsTable renders LoadingAndErrorWrapper instead
of leaving the skeleton up indefinitely on a failed fetch. Drops
useRetainedValue there too, for the same reason (RTK Query's data is
already sticky across page/sort changes).
Also trims redundant comments in both event tables.
* GDGT-2919: Address PR comments
* Revert gratuitous DependencyDiagnosticsPageOwnProps rename
Keep master's DependencyDiagnosticsPageProps naming so this Graphy-owned
file isn't touched by the master merge for no functional reason.
* Fix jest.mock("metabase/AppComponent") to match its named export
Master's router refactor changed AppComponent from a default-exported
connect() component to a plain named export (App). The mock still
returned a default export, so import { App } resolved to undefined and
broke every route render in routes.unit.spec.tsx.
* GDGT-2874: Fix usage audit e2e test
The treemap label model imported VisualizationGridSize from the
enterprise custom-viz package, a cross-boundary type import. A
structurally identical type already exists in
metabase/visualizations/types, so use that instead.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Resolve relative navigation against v7's own match
* Resolve redirect targets off the v7 match branch
* Drop the router argument from useDashboardUrlQuery
* Free Palette and McpAnalyticsPage from the router context
* Read location and params from v7 instead of useRouter
* Scope route-leave hooks from the match instead of an injected route
* Delete RouterBridge, the router contexts, and the v3 route types
HoneySQL munges dashes to underscore in a keyword's namespace, but not in its name. The code was relying on HoneySQL default behavior to turn namespaced keywords into table references, but the inconsistency bit me in some new development work.
Two paths currently pass schema-qualified keywords in cases where a table might leak:
- `drop-table!` targeted a nonexistent object and silently "succeeded" (not dropping the table)
- `compile-transform` (the SQL variant) could target a nonexistent database.
Both `drop-table-sql` and `create-table-sql` are updated (the latter via `quote-table`) to keep them in sync.
Collapsing the namespace into the keyword's name keeps everything verbatim as you'd expect. Callers already passing in `"schema.name"` weren't impacted.
* Fix QB perf
* Forbid loading every field via GET /api/database/:id/metadata
Make skip_fields a required `true` on the FE request type so no caller can ask
for field-level metadata, which grows with every column in the database.
The two callers that needed fields now scope their fetch to a single table:
- Data Reference field pages fetch the table's own query metadata (which also
pulls FK target tables and their fields)
- useAuditTable resolves the table from the plain database response, then loads
/api/dataset/query_metadata for that table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Withhold the audit table until its fields load
ConversationStatsPage renders charts straight from useAuditTable's table
without checking isLoading, so handing back a table built from the field-less
database metadata made it paint once and re-render when the ad-hoc query
metadata arrived, detaching the chart nodes mid-assertion.
Also make the segment field list spec's database mock honour skip_fields, so
unit tests can catch a caller that stops asking for fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix be
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* BE Add Mistral metabot provider
* BE Add prompt-cache-key to LLMRequestOpts for Mistral prompt caching support
Mistral requires a `prompt_cache_key` to be set to enable prompt caching, so add it as an optional key to
`LLMRequestOpts` and thread it through `self/call-llm` and `self/call-llm-structured`.
* FE Add Mistral metabot provider
* Review feedback: fold all provider selectable tests into a single test
* Review feedback: use findPutRequests to reduce verbosity
* BE Add Z.AI metabot provider
* FE Add Z.AI metabot provider
* Move generic chat-completions code to metabase.metabot.self.openai.chat-completions
And use it in both the openrouter and zai providers.
hashSearchTerm and shouldReportSearchTerm are analytics reporting
policy (settings read + SHA-256 hashing), not search UI, so they move
from common/search/term.ts to the lib-tier analytics module and are
exported from its barrel. Consumers now import them from
metabase/analytics, and the common/search barrel no longer re-exports
them.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Relocate the isVisualizerDashboardCard type guard from
metabase/visualizer/utils to metabase-types/guards/dashboard, alongside
the other dashboard type guards, and update all importers to the new
path. Remove the re-export from the visualizer utils barrel and delete
the old file.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add parseSearchQuery and a useNavigationType facade hook
* Move the location.action readers to useNavigationType
* Move the location.query readers to search params
* Drop query and action from the facade Location
* Replace connect() with hooks in the app shells and permissions pages
* Rewrite the reference containers as function components
* Delete withRouteProps
* Pass the props reference children read but do not select
* Cover every reference container page with a render test
* Fetch reference container data before the child's first render
* Add Snowplow event tracking to the metric Dimensions tab
* Update events' definitions to comply with new approach
* Update analytics-events claude skill to account for new approach defined in #71631
TableBrowser only needs the table list, but GET /api/database/:id/metadata
returns every field of every table. Pass skip_fields=true so the response
stays proportional to the number of tables.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
HoneySQL inlines true/false as TRUE/FLASE tokens even when the query is otherwise parameterized, and not every driver target has a boolean literal. SQL Server parses a bare TRUE as an identifier and `insert-into!` fails with `Invalid column name 'TRUE'`. Wrapping boolean row values in `[:lift v]` binds them as paremeters, which is what we do for other value types.
Impacts every `:sql-jdbc` driver's `insert-into!`. Drivers with boolean literals are not impacted.
Fix quadratic :is_personal hydration that made GET /api/collection take ~23s
On an instance with 28k collections — 6k of them personal — `GET /api/collection`
took 23.5s while making only 13 DB calls. Nearly all of it was the `:is_personal`
batched hydration: for every collection it scanned the full set of personal
collection IDs, building a prefix string with `format` on each step, so a request
did ~20k x 6k = 125M `String/format` calls.
Personal Collections only ever live in the Root Collection, so a collection is
inside one exactly when the *first* ID of its location path is a personal
collection. Test that single ID against the set instead of scanning.
Measured on a seeded app DB matching the reported shape (28.6k collections, 6,011
personal roots, 13.6k live non-personal, 7.8k archived, depth to 13):
GET /api/collection 11.2s -> 1.9s
:is_personal hydrate 9.2s -> 75ms
The remaining hot spot is `:can_write` (~1.1-1.5s), which linearly scans the
user's permission set per collection — left for a separate change since it
touches the shared permission check.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Convert the store factory and whitelabel color bootstrap to TypeScript
* Name the deliberately loose reducer map type
* Condense the reducer map comment
* BigQuery: reject oversized queries client-side with a humanized error
BigQuery rejects jobs whose SQL exceeds 1 MB (1,048,576 characters,
including comments and whitespace) with a raw 400 INVALID_ARGUMENT,
which Metabase surfaces to the user as "Error executing query: <raw
message>" -- accurate but technical and not actionable.
Check the final compiled SQL (including the appended remark) before the
BigQuery client/job is created and throw a localized, actionable
:invalid-query error instead. The raw DB error path remains as a
fallback if this limit ever drifts from BigQuery's own.
Quota reference: https://cloud.google.com/bigquery/quotas#query_limitsCloses#79019
* Update error message for SQL query length validation
* Address review: express limit as (* 1024 1024), slim the tests
- max-sql-query-length-chars: write the value as (* 1024 1024) so the
1 MiB intent reads off the page.
- validate-query-length-test: collapse to the two boundary cases (at the
limit allowed; over the limit throws :invalid-query), error type only,
per review feedback to keep it simple.
* Address review: trim noise from max-sql-query-length-chars docstring
Drop the cross-reference to validate-query-length! and the "not ^:const"
justification — commentary, not contract. Leaves what the constant is,
how BigQuery counts, the failure mode, and the quota link.
* Add AI provider setup to onboarding
- Insert AI config step into setup flow
- Track connect and skip analytics events
- Update onboarding tests and schema version
* Preserve existing AI provider during setup
- Wait for the saved provider before mounting the form
- Show the connected provider in the setup step and cover it in tests
* Defer AI provider sync until after setup
- Keep the AI config step visible during onboarding
- Skip legacy Metabot provider migration on fresh instances
* Add AI provider setup to onboarding
- Hide the step when AI features are disabled
- Support Anthropic onboarding setup in e2e
- Mock Anthropic model catalog for credential validation
* Match license step layout in AI setup step
* Fix modules config and flaky setup toast assertion
* Add free_units to managed AI e2e add-on fixture
* Condense AI step ordering comment
* Make AI setup opt-in from the completed step
The AI provider step is no longer part of the wizard by default. The
"You're all set up!" screen offers a "Set up AI" button that appends
ai_config as the last step; connecting or skipping returns to it.
* Scope the settings-list mock to the test that needs it
settingOverrides was only used by one embedding test, so drop it from the
shared setup helper and let that test override the settings-list route.
2026-07-30 17:15:21 +00:00
2958 changed files with 109018 additions and 37231 deletions
@@ -27,9 +27,28 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
- Format using Markdown conventions
- Reference other vars with `[[other-var]]` not backticks
A docstring states the *contract*: what it returns (including when it returns nil), what its
params mean, what it throws versus swallows, and any precondition or invariant the caller must
respect. A params block for non-obvious or optional args is good documentation — length is not
the problem.
These seven things are, and each is a reason to cut or relocate:
| Anti-pattern | Why | Where it goes instead |
| --- | --- | --- |
| **Facts you don't own** — how a *callee*, another namespace, or another repo works internally | Drifts silently; nothing fails when it goes stale | Cut. Name the callee and let its own docstring say it |
| **Said twice** — ns docstring restating its vars', a caller restating its callee's, sibling fns repeating boilerplate | Two copies means one is already wrong | Cut the copy. One fact, one owner |
| **Design rationale** — why this approach, why it lives in this module, what the alternative was | The caller can't act on it | Commit message, or an inline comment on the specific line it constrains |
| **Development history** — "matching master", "the X branch", "currently", "for now", "used to" | Stale by construction | Cut |
| **Instructions to the reader** — "callers should trust this", "don't bother checking", "you should use X" | State the contract; the caller decides | Cut, keeping any actual guarantee as a plain statement |
| **Invented precision** — score bands, thresholds, or tables no code enforces | Reads as spec, isn't | Cut, or make the code enforce it |
| **A note on the wrong var** — describing how overrides behave, on the base/default impl | Nobody reading the override sees it | Move to the overrides it describes |
**Comments:**
-`TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
- Prefer an inline comment over docstring prose for anything that explains *the body*. Put it on
the line it explains, not at the top of the function.
- Run targeted tests, and lint continuously during development
- Prioritize understanding existing patterns before implementing
- Don't commit changes, leave it for the user to review and make commits
- Minimize comments. Document only non-obvious intent, trade-offs, or constraints that the code itself cannot convey. Do not narrate what the code does — keep code self-descriptive through naming and structure.
@@ -10,23 +10,23 @@ This skill helps you add product analytics (Snowplow) events to track user inter
## Quick Reference
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
Analytics events in Metabase use Snowplow with typed event schemas. Simple events are declared **where they are used** — `trackSimpleEvent` is generic and validates the payload at the call site.
**Key Files:**
-`frontend/src/metabase-types/analytics/event.ts` - Event type definitions
msg="🚨🚨 Backport failed because \`double-backport\` and \`triple-backport\` labels are **deprecated**! Use the \`backport\` label to port changes to all supported releases instead. 🚨🚨"
⚠️ To cut a release, use the "SDK Version Bump PR" workflow instead — it bumps the version and opens a PR; merging that PR publishes.
This publishes the version already committed in package.template.json. It never bumps. Pick the branch in "Use workflow from" above.
Use it only when neither the bump PR nor re-running the failed jobs will do.
description:"⚠️ Manual override. Publishes whatever version is already committed in package.template.json; it never bumps. Normally you want the SDK Version Bump PR workflow, where merging the PR publishes."
type:choice
required:false
options:
- Publish the committed version from the branch picked in "Use workflow from" above
- Publishes the branch picked above
# Step 2 of the new release process: merging a version-bump PR (opened by
# sdk-version-bump-pr.yml, labeled `sdk-release-trigger`) triggers a release
# of whatever branch that PR targeted. See `prepare-release` below.
• custom — one-off branch (esbuild, data-apps): publish under its own prerelease id
# Option names are capitalised for emphasis only - the values stay
# lowercase. The label renders as plain text, so caps is the only
# emphasis available (same reason prerelease_id below shouts FIRST).
description:"BETA - before gold, on a release branch. PATCH - at gold and after, on a release branch. PREMINOR - at gold, on master. ALPHA - any time, on master. CUSTOM - any time, on a one-off branch."
type:choice
required:true
# Pinned so the release-cycle ordering below does not decide the
# preselection: without a default, GitHub preselects the first option.
default:alpha
options:
- alpha
- beta
- patch # one entry, two phases:the version decides 0.N.0 vs 0.N.1
- preminor
- patch
- alpha
- custom
prerelease_id:
description:"release_type=custom only, and only on the FIRST cut of a one-off branch (e.g. data-apps). Becomes the prerelease id in the version (0.62.5-data-apps.0) and the npm dist-tag (62-data-apps). Leave empty on later bumps - the id is reused from the current version."
# Left empty on later bumps, the id is reused from the current version.
description:"For release_type=custom only, and only on the FIRST cut of a one-off branch. Enter a name like data-apps: the version becomes 0.62.5-data-apps.0 and the dist-tag 62-data-apps. Leave empty on later bumps."
type:string
required:false
@@ -155,7 +162,10 @@ jobs:
# --reviewer tags the team so a release is visible, not because anything
# waits on a review. It needs the automation PAT: GITHUB_TOKEN cannot
# resolve an org team.
gh pr create --base "$BRANCH" \
#
# gh prints the new PR's URL on stdout; capture it so the job summary
# can link the PR that this run opened.
pr_url=$(gh pr create --base "$BRANCH" \
--assignee "${GITHUB_ACTOR}" \
--reviewer metabase/embedding \
--label sdk-release-trigger \
@@ -163,7 +173,7 @@ jobs:
--body "$(cat <<'BODY'
Bumps the Embedding SDK version in `package.template.json` and updates its `sdkRelease` metadata.
| | |
| Setting | Value |
|---|---|
| Branch | `${{ github.ref_name }}` |
| Release type | `${{ inputs.release_type }}` |
@@ -177,4 +187,14 @@ jobs:
_Opened by the [SDK Version Bump PR workflow](../actions/workflows/sdk-version-bump-pr.yml), triggered by @${{ github.event.sender.login }}._
BODY
)"
)")
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
# Separate from "Append workflow summary" above: that step runs before the
# PR exists, so the link has to be appended after this one opens it.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.