Generate Clojure query fns with Malli result schemas from .sql files, using
sqlc for SQL parsing and type inference and sqlc-gen-json to expose its
analysis as JSON.
Verified end to end: migrations against Postgres 16 (1427 changesets, 190
tables), pg_dump fed to sqlc unmodified, real queries ported from the
glossary/sso HugSQL spike branches, and the generated file compiles with
schemas that accept valid rows and reject bad ones.
See dev/src/dev/sqlc/README.md for open issues (no H2 engine, weak param
type inference, no dynamic SQL).
Fixes https://github.com/metabase/metabase/issues/65493
If you have a `details.schema` property set in your connection (this was
removed in v42 in favor of schema filters), you can't change it in the
UI in the current Metabase version. If you wanted to specify a schema
through the "Additional JDBC connection string options", the old
buried schema property would take precedence anyway.
This makes it so the deprecated field is removed when the schema is
set in additional-options.
I've verified this using a real connection and querying `SELECT current_schema()`
as suggested in the bug report.
The test read `:future-ref` straight off the registry entry as soon as the
fetch signalled it had started. But `detached-fetch!` registers the entry
before the fetch begins and only stores the future just after submitting it,
so the thunk can deliver `started` before the `reset!` lands. When it does,
`stalled-future` is nil and `future-cancelled?` throws an NPE.
Under 300 spinning threads a probe caught the window 1-4 times per 20000
fetches; inserting a 5ms sleep between the `future` and the `reset!` makes
the old test fail every run.
Poll for the future instead. Only the test is affected: the sweep acts on
entries older than *fetch-max-age-ms* (an hour), so it can never observe one
whose `:future-ref` is still unset.
Claude-Session: https://claude.ai/code/session_01J1yFPpYiWH2ik8LB9rs7BT
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* multiple jvms per runner for bigquery
* trigger CI
* add failing test
* lower timeout
* remove debug stuff
* clean up comments
---------
Co-authored-by: Claude <noreply@anthropic.com>
* BigQuery: use calendar days for datetimeDiff day unit
Fixes#82193
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Avoid :raw for date part in BigQuery day diff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* clean up
* add timezone to test
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* add saved question extraction prototype
* fix additional failure cases
* refactor: reconciler
* revert me: manual skill file edit for source swapping
* set data permissions to view for databases referenced in the query
* rename data app collection and group
* prevent agents from removing savedQuestionSourceId
* docs: clarify data app query location
* chore: cleanup query-sync canonical module
* refactor: split query tests into different files
* Swap query source for data app query hooks and apply dynamic filters on top of it (#79584)
* code improvements and bug-fixes for data apps permissions
* Add data apps model imports
* address reviews on data apps permissions
* run sync queries before vite build
* Add data app resource link (#79878)
add button to view data app resources
* Show permission error when users cannot access a data app (#79874)
show permission error page when user do not have permission to view data apps
* Add data app group link in overflow menu (#79873)
Add data app group link
* Store and reconcile data app entity ids in data_app.yaml (#79833)
* Store data app resource entity IDs
* make entity ids in data_app.yaml authoritative
* add top-level value after path key
* simplify data_app.yaml manifest parser
* only allow changing data app collection if empty
* consolidate backend test helpers and remove redundant tests
* chore: restore README changes
* ci: fix failing tests
* Add support of actions to Data App reconciler (#79842)
Add support of actions to Data App reconciler
* Fix bugs/inconsistency and add more e2e for data apps reconciler/syncing (#80002)
* Update semantic schema skill about preventing segments and measures in dynamic parts of a query in Data Apps (#80288)
* Grant view data access on a table-by-table basis in data app groups (#80189)
* grant view data on table instead of database level
* refactor table scan in reconciler
* update e2e tests for permission grants
* Grant data app view permissions per table
* Skip unchanged data app permissions
* add e2e data app test for sandboxing
* reset host app sources after runs
* Preserve sandboxing for data app users (#80406)
* add e2e data app test for sandboxing
* improve e2e tests for data apps
* Remove sandbox E2E from table permissions change
* preserve existing sandboxing from sandboxed groups in data apps
* assert that we grant non-sandboxed tables
* Explain data app sandbox exclusion
* Add data app sandbox E2E coverage
* Exercise sandbox preservation in data app E2E
* Disable saved question sources in data apps (#80139)
* Disable saved question sources in data apps
* Preserve question-only schema scope
* Remove the saved question schema parameter
* Copy table-sourced metrics into data app collection and do not codegen card-sourced metrics (#80086)
* copy table-sourced metrics into the data app collections
* copy table-sourced metrics into the data app collections
* Address metric sync review feedback
* Accept keyword metric display in query response
* Fix data app metric reconciliation ordering
* Remove old lockfile format
* exclude card-sourced metrics from schema generator
* do not use card terms in metrics to avoid ambiguity with card-sourced metrics
* fix model imports
* Fix data app metric reconciliation
* refactor: cleanup reconciler unit tests
* Fix duplicated metric client helper
* add safe metrics path
* Handle unsupported metric dependencies
* Fix data app CI test regressions
* Fix "Re-adding removed query fails to adopt deleted card" (#80341)
* Clean-up data apps permissions endpoints (#80825)
* Remove entity ids from data_app.yaml (#81317)
Remove data app entity ids from data_app.yaml
* Adjust data apps permissions code (#81093)
* Remove the data app table permission grants (#81584)
* Add permissions_group.is_data_app_group and hide data app permission groups from UI (#81705)
* Add data app user management and access warnings (#81839)
* Address data app user access review feedback
* additional e2e test cases for bugs in manage users page
* fix failing frontend type check on unit test
* fix migration id collision for data apps
* combine migration changes into one file
* fix data app metric sync after trashing copied metrics
* fix cli runtime dependency packaging for embedding sdk
* avoid loading data app bundle during resource setup
* prevent redundant data app collection permission updates
* allow admins to remove data app members after token expires
* update docs and add e2e test for missing resource collection case
* isolate data app sync using transactions
* do not leave user on an empty page when removing the last member on the page
* reserve sandbox-host slug in data app config
* ignore generated data app action fixtures
* move queries into db.clj
* preserve legacy permissions when adding data app members
* regenerate the kondo modules config
The data-apps module reaches for :model/Permissions and :model/DataPermissions,
neither of which was recorded in :model-imports, so metabase.core.modules-test
fails on the current tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* reject data app queries whose metrics read another card
Sync copies a referenced metric but rewrites nothing inside the copy, and
copies nothing that copy in turn reads, so those references still point at
the originals. The app publishes successfully and then fails for every
viewer without access to the originals' collections.
Checking while resolving the query, rather than at codegen, also catches a
metric edited into that shape after its schema was generated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* exclude metrics that read a saved card anywhere from generated schemas
source-card-id only reads stage 0, so a table-sourced metric that joins a
saved question passed the codegen filter. The CLI checks the whole query and
rejects such a metric, aborting sync-resources for any app that uses it.
Checking the whole query here keeps codegen consistent with what sync
supports, and removes the database round trip that asked which of the
referenced cards were metrics -- the step that let a saved-question join
through in the first place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* resolve copied resources' table dependencies through the server
Numeric source-table entries miss implicit joins: a copied model querying
Orders and reading Products.Category through a foreign key names Products
nowhere, so it was left out of the app's permission warnings.
Run the same metadata-based lookup the query endpoint uses, through a new
endpoint the CLI calls for copied models, actions and metrics, and drop the
JSON walk it replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* cover the create-queries grant the database defaults leave on app groups
An app group that predates the first non-audit database is given
create-queries :query-builder-and-native by set-new-database-permissions!,
alongside the :blocked view-data it special-cases for app groups. A user with
data access through another group would gain native query authoring just by
joining the app.
The reconciler already resets it, but nothing failed when :perms/create-queries
was dropped from its list. The pairing cannot be produced through
set-database-permission!, which cascades view-data to :unrestricted for any
create-queries value other than :no, so the test writes the row the way the
defaults insert it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNKkhQjjiUSM7qwLReyfP
* prevent models based on saved questions from being copied
* return to a valid member page after overlapping removals
* do not resolve query objects when either enabled flag is false
* prevent shorthand properties and spreads from overriding generated ids
* prevent adding duplicate members and retry only members that are failed to add
* define schema for data app query endpoint
* reconcile only the current data app group during resource setup
* expand the schema fingerprint
* add data app group field in permission group schemas
* prevent new databases from granting query creation to data app groups
* throw validation errors when invalid query spec fields are passed
* do not fail validation on columns metadata
* add enabled metadata to query definition schema
* accept more data app specific query metadata and binning options
---------
Co-authored-by: Timofey Kachalov <timofei@metabase.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(ui): add PasswordInput Storybook story
* test(ui): add PasswordInput to Loki as baseline for restyle
* feat(ui): restyle PasswordInput from Figma and add input-background token
* test: cover PasswordInput visibility toggle in unit and e2e tests
* fix(PasswordInput): use highlight border color in focused error mode by overriding Input styles
* Update Loki Snapshots
* test(ui): add Loki reference for PasswordInput Overview story
* fix(PasswordInput): address review feedback
- keep border width constant and use an inset ring for focus/error to avoid layout shift
- tie right padding to the toggle button size so text clears it in lg
- restrict size prop to sm | md | lg
- order color keys alphabetically
- explicit visibility assertion in the e2e spec
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Use JetBrains Mono as the code font
Ship JetBrains Mono (regular + bold) and point the monospace font family
at it, replacing the hardcoded Monaco/Menlo stacks scattered across the
codebase.
- Add the woff2 files, @font-face declarations, and the type, fallback
map, docs and backend test entries that keep the admin font picker in
sync with the fonts directory.
- Route every monospace call site through
--mb-default-monospace-font-family, and define that variable in the
embedding SDK's variable set, where it was previously missing. With
the variable always defined, the per-call-site `, monospace` fallback
is redundant and is dropped.
- Inline the variable at the CodeMirror theme, which was the only
consumer of monospaceFontFamily, and delete typography.ts.
- Set the Code component to 13px/22px per the design spec.
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Bedrock takes an AWS access key pair and Google Gemini Enterprise a service
account key file, so the AI settings page can't keep calling every provider
connection an API key.
The tests pass ids like 789 for entities they do not create. format-entity looks the
id up, and the test asserts on the fallback rendering that a 404 produces. When a
parallel test happens to hold that id, read-check gives a 403 instead, which renders
nothing, and the test fails.
Use Integer/MAX_VALUE, which no fixture allocates.
Claude-Session: https://claude.ai/code/session_01GCoSa7L7KrXh42rDRYR3Nk
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Read a card's UI parameters off its question
* Drop a tombstone comment
* Pin the agreement between getCardUiParameters and Question.parameters
* Type the unsaved card in the agreement test
* Format the agreement test
* Move the application permissions selector slot into current-user
PLUGIN_APPLICATION_PERMISSIONS.selectors was only read by current-user, so the slot now lives there as PLUGIN_APPLICATION_PERMISSIONS_SELECTORS with its OSS defaults, and the EE application permissions plugin registers into it from metabase/current-user. getRoutes and tabs stay in metabase/plugins for the admin permissions editor.
* Translate viz nouns and formatting operators on demand
`noun` was the one field on a visualization definition still translated at
import. `get noun()` matches what ObjectDetail and TreemapChart already do,
and the registry stores a definition by reference, so the getter survives.
The table-formatting operator names were four module-level objects. They are
functions now. `ALL_OPERATOR_NAMES` becomes `getAllOperatorNames()`, which
its three readers call.
25 of the 124 sites that translate before a catalogue can be applied.
* Translate settings titles on demand
`title` in a settings definition follows the same rule as the rest of the
object. 152 settings already use `get title()`; these 23 were the laggards.
The group-managers confirmation held two frozen strings, so it becomes a
function rather than an object with getters.
49 of 124.
* Lift translated containers into functions
Where a whole array or object is translations, the function goes on the
container so consumers keep a plain array or object: PieChart's ring titles,
the whitelabel illustration options and descriptions, PivotTable's partitions
(which held JSX built at import), the model-caching options, the cloud
migration messages, and the geo filter placeholders.
Where only a field or two of a mixed config is translated, and call sites read
it by property, the getter goes on the field instead. That covers the
collection configs, the permission option records and the loading messages,
none of which are spread anywhere.
Four files carried a whole-file disable rather than one per site, hiding 20
sites behind 4 directives. The real total is 140, not 124.
118 of 140.
* Clear the last module-scope translations
Validation schemas become functions, matching getOidcFormSchema and the other
lazy schemas already here. Module-local error strings become functions called
at their use sites. Three exported constants follow: NULL_DISPLAY_VALUE,
BEFORE_UNLOAD_UNSAVED_MESSAGE and NO_X_AXIS_VALUES_ERROR_MESSAGE, across 37
files.
The two Mantine `aria-label` defaults get a getter. Mantine merges
`defaultProps` by spread on each render, so the getter is read every time.
`ttag/no-module-declaration` now passes across all five trees it covers with
no opt-outs left, and `--report-unused-disable-directives` confirms none were
stranded.
* Stop spreads from flattening lazy translations
A spread or a rest destructure reads every getter it copies. Where an object
holds `get title()` so the string translates when it is read, spreading that
object at module scope translates it at import instead, which is the bug the
getters were meant to avoid.
Adds `mergeLazily` / `omitLazily`, which copy property descriptors instead,
and uses them where the flattening happened at import:
- `viz-core/lib/settings/utils.ts` flattened twice, once in the rest
destructure and once in `...def`, so every setting built through
`fieldSetting` / `metricSetting` / `dimensionSetting` froze at import.
- `palette/shortcuts/index.ts` spread five shortcut maps into one.
- `PaletteShortcutsModal` spread each shortcut again while grouping, so that
now happens per render.
Measured with a test that imports every source module with `ttag` mocked:
40 modules and 370 translation calls at import, down to 35 and 256.
* Build parameter, admin and graph settings when read
* Build option lists when they are rendered
* Guard import-time translation with a test
* Make the instance analytics message a function so the plugin registry can replace it
* Resolve admin path names when the navbar renders them
* Give the enterprise caching tabs a lazy name too
* Skip generated cli-snippets from the side-effect scan
* Key the null slice by its untranslated label
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Fail the import-time guard on unlisted import errors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Read lazy translations once per render, not per lookup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Let jest transform the MCP ext-apps package
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
* Skip the SDK cli scripts in the import-time guard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUuaopJ7ZtMVvdwwxF6qyx
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Namespace the table name BigQuery's compiled native form carries as :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update the BigQuery compile tests for :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update the remaining BigQuery compile tests for :qp/table-name
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Refuse to create new tables in static datasets with Snowflake.
Table creation will always succeed on non-static datasets, but if it's
static then the table being created has to be a table that's in the
dataset definition.
Ensure all tests which alter the dataset by creating new tables
declare a non-static dataset. The transform-related tests switch over
to using transforms-dataset/transforms-test which most of them are
already using.
Upload tests have their dataset defined in a fixture so we can do it
all in one place instead of editing 60 different deftests.
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>
* Update map tile server settings with suggested copy from DOC-301
And also include a link to the relevant page in the metabase docs.
* Add a note about not including private keys to the docstring for map-tile-server-url
so that the warning also shows up in the env var docs.
* BigQuery: use PERCENTILE_CONT for median and percentile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* update docs
* update doc
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Fixes UXW-4714.
The static viz for tables uses a fixed width, but it was too big for the
PDFs, causing tables with long text in columns to lay out badly.
This PR disables the fixed width of `780px` on tables for PDFs, so that
the tables now lay out correctly.
* Fix collisions on metabase.notification.payload.core-test namespace name. Lint for future collisions.
* Include .clj-kondo/{src,test} in backend check triggers
* Expect the new project test namespace
* Symbolically reference project test suite namespaces
* Include resources, test_resources, test_config dirs in project checks
* Return [namespace relative-path] pairs.
Skip local/src
* Catch read-file errors and explain why some files may not have ns forms
* Sort paths that collide
* Copy the transform tables in dump-to-h2 and load-from-h2
None of the nine transform_* models were in copy/entities, but metabase_table is,
and it carries a transform_id FK to transform. A table row reached the target
referencing a transform row that never arrived, and the deferred FK check
rejected it at commit.
transform_run_cancelation also goes in entities-without-autoinc-ids, since its
primary key is run_id rather than an autoincrement id.
Adds copied-tables-have-copied-fk-parents-test, which migrates a throwaway H2
database and fails if any copied table's FK parent is not copied.
* Simplify the copy foreign-key regression test
* Clarify the foreign-key coverage test names
* Make defendpoint reject request schemas that reach an open map, a keyword-keyed map-of or :any
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the MBQL 5 query and column schemas and declare the keys the query processor adds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Type the shared schemas requests reach and drop ms/Map and ms/JSONString
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close every map schema a request can reach
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Regenerate the modules config
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep action parameters string-keyed and type lens params and permission graph ids
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the sandbox coercion keys a column's options can carry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the alias-info and Mongo keys a column can carry and strip internal keys from legacy column metadata
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Carry the Mongo column keys in the serialize test fixture
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the keys the frontend and query processor put on columns, parameters and card embeds
- column metadata: :remapping, a typed :lib/options, the nest-breakouts option key
- query parameters accept the rest of a stored filter declaration
(sectionId, isMultiSelect, values source config etc.)
- static-list values may be one-tuples; raw-value template tags may
carry a nil widget-type
- card embed attrs declare chart_href, child_target_id and host_data;
explore filter refs accept the legacy field_ref the chart column has
- notification card echoes a metric's query_description
- serialization deep-sort orders mixed keyword/string keys
- viz-settings column refs are normalized before matching; a Field
hydrated with its table can be fingerprinted; broken segment
definitions read back as nil
- test fixtures: real fingerprints, snippet tag type, http channel
details, comment content, no min_duration_ms on duration strategies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Wire the defendpoint check, keep every map single-keyed, and apply the review findings
- defendpoint now calls closed-schemas/check! on every request schema;
the walker no longer lets :any leak through collections, treats
map?/:some as :any, skips :=/:enum/:fn values and never skips the
default entry
- no map mixes keyword and string keys: ms/string-keyed-object gives an
opaque JSON object string-keyed declared entries; ProseMirror attrs
(documents, comments, explorations embeds), CSV upload parts, search
weights and testing query specs use it and their readers use string
keys; the serdes deep-sort comparator is back to master
- the Metabot context is fully typed from the frontend types instead of
an opaque default; a draft transform query without a database is
accepted
- permission graphs, settings PUT and SCIM PATCH reject a body whose
map-of entries the decoder dropped; ad-hoc query endpoints drop a
client-sent cache strategy; action parameter values keep collections
out without nil-ing them; SCIM meta.resourceType is optional; sandbox
user attributes are LoginAttributes; a metric filter clause carries its
offset options; explore filter refs are the legacy field_ref the
chart column has; result_metadata written by older releases and
hasVariableTemplateTagTarget on parameters are declared; a bare
map-of on a request path is guarded against scalars
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Close the endpoints tests define and let a column's target and name field be an id reference
The check runs on every defendpoint, the ones test namespaces define included. A column's
:target and :name-field are hydrated Fields on the backend but id references in the
metadata the frontend hands over; the closed column schema nil'd the reference, which
lost the search field of a model's primary key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Hand an action's query only the parameter values, and fix two fixtures the closed schemas caught
A query action's stored parameters carry the frontend's widget settings; the query gets
the value keys. The Metabot viewing-context tests use ids no card can have, and the
Mongo cache test spells the strategy key the schema does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Declare the table name BigQuery's compiled native form carries on a native stage
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep a BigQuery RECORD's sub-field sync metadata off its result column, and fix a binning fixture
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Move Snyk scans into the Lint Security workflow
Fold snyk.yml's three jobs into lint.security.yml as snyk-generate-poms,
snyk-frontend-projects, and snyk-maven-projects, and delete snyk.yml.
Snyk now runs on the existing daily 11:00 UTC cron instead of its own
05:00 cron and dependency-file push trigger. The notify job reports Snyk
failures to Slack alongside CodeQL and Trivy, and Snyk's upload-sarif is
bumped from v3 to v4 to match the rest of the workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* add timeouts
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* GDGT-3132 fix misaligned undo toast
* migrate UndoListing to from styled components to CSS module
* absolute px -> rem, compact -> compact-md
* fix inconsistent toast height, use line-height instead of measuring the DOM to handle single vs multi line layouts
* strip comments
* GDGT-3195: Fix inefficiencies in formatValue
formatValue is fine per-cell but pathological in bulk; the "Copy results"
button made this visible, with a 100x2000 grid spending ~4.5s in this path.
- Drop a dead dayjs strict parse. The format literal 'T' is not valid dayjs
escaping (dayjs wants [T]), so isValid() returned false for every value.
It sat above the string/number/boolean branches, so every non-date cell
paid for it.
- Only JSON.parse the value when a markdown_template will consume it,
instead of on every call.
- Memoize the per-column type predicates (isURL/isEmail/isTime/isDate/
isNumber/isCoordinate/isBoolean) in a WeakMap keyed on the column. Each
fans out into recursive type-hierarchy lookups that cross into CLJS, and
they only depend on the column. Entries snapshot the type fields they
were computed from, since the custom-viz API hands third-party code
mutable column objects.
- Skip new URL() construction for values with no ":" in getUrlProtocol.
- Cache Intl.NumberFormat by its constructor options, bounded at 100
entries with oldest-first eviction.
Also make the value-formatting signatures honest: formatUrl, getLinkText,
getLinkUrl and getRemappedValue take unknown, which is what they actually
receive. getUrlProtocol keeps its string signature, with getLinkUrl
narrowing before the call. This removes both `value as string` casts.
Benchmarked with a synthetic 100x2000 copy path (medians of 5):
full table 4512ms -> 440ms, text 1231 -> 12, currency 637 -> 20,
integer 535 -> 15, boolean 343 -> 5, email 101 -> 4.
Claude-Session: https://claude.ai/code/session_01KUcocQj2bJPwVBi4dXKyEP
* Cleanup
* Revert the createPlainCellFormatter change
Hoisting the options spread out of the per-cell closure is not worth the
churn. Measured in isolation with a realistic 22-key columnSettings, the
spread costs 0.023us per cell against 0.002us to reuse a hoisted
reference, so it saves ~4ms per 200k cells — a rounding error next to the
dead dayjs parse at ~13.5us per cell, and nothing at all for columns with
click behaviour, where both shapes spread per cell anyway.
* Address review feedback
Guard the markdown_template JSON.parse with a typeof check instead of
carrying the placeholder cast comment.
Cover the two changes the review called out as untested:
- numberFormatterForOptions returns the same formatter for equivalent
options, a separate one for different options, and evicts the oldest
entry once the cache is full. The eviction test resets the module so
the 100-entry boundary does not depend on what earlier tests cached.
- formatUrl passes strings and numbers through untouched and renders
booleans and objects rather than returning them raw.
Both were checked by mutation: breaking the coercion and stubbing the
cache lookup to always miss fails exactly those assertions.
* Move the DataReference library pane slot into querying
DataReference is the slot's only reader and querying owns its props type, so the slot lives next to it as PLUGIN_DATA_REFERENCE.LibraryPane.
The enterprise library plugin registers into it at boot, and plugins/oss/library.ts no longer imports querying's DataReference types.
* Move the verified search filter slot into search
SearchSidebar is the only reader of PLUGIN_CONTENT_VERIFICATION.VerifiedFilter, so the field becomes PLUGIN_SEARCH_FILTERS in metabase/search/plugin, next to the other search filter components. The EE content verification plugin registers the filter there from initializePlugin, and the rest of PLUGIN_CONTENT_VERIFICATION stays in plugins without the search import.
* Declare the search filters slot with definePluginSlot
* Import definePluginSlot from the plugins entry point
* Describe the verified filter default without naming its consumer
* Cover enterprise feature gates and plugin reset behaviour
* Remove redundant plugin registration tests
* Move the useGetIcon plugin slot into hooks/use-icon
The hook is the slot's only reader, so the slot and its OSS default live next to it as PLUGIN_ICON.
The enterprise collections plugin registers into it at boot, and plugins/oss/collections.ts no longer imports metabase/hooks/use-icon.
* Declare the entity icon slot with definePluginSlot
* Import definePluginSlot from the plugins entry point
* Cover enterprise feature gates and plugin reset behaviour
* Remove redundant plugin registration tests
* Trigger the stats workflows from Run tests, gated on the uberjar artifact
Both workflows have recorded nothing since 2026-09-03. PR #81238 took the push
trigger off `uberjar.yml`, so master merges reach it only through
`workflow_call`. A reusable workflow reached that way raises no `workflow_run`
event, and both watchers went quiet. 143 master commits went unmeasured.
They now watch "Run tests", which is what builds the jar on a master merge.
Its conclusion is not a usable gate, because it is `failure` on master more
often than `success`, from jobs that have nothing to do with the jar. The
uberjar artifact is the right gate: a new job looks it up without downloading
it, and a docs-only merge that skipped the build finds nothing and stops.
Calling both from `run-tests.yml` would have been lower latency, but a stats
failure would then turn the master run red, and `rerun-workflows.yml` watches
that run and would re-run master CI over it. Keeping them in their own run
keeps a stats problem to itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Let a PR run the size benchmark against its own uberjar
A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. This matches the trigger
Bundle Load Stats already carries. A called run measures and reports; the
upload step stays gated on the `workflow_run` path, so it writes no row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Trim the trigger comments in the stats workflows
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Give the visualizations a question builder instead of the metadata object
* Let the visualizations read the question builder themselves
* Read the store when a chart brush builds its question
* Drop the single-use question helper
* Give the map and object-detail views the question builder too
* Drop the last v1 metadata reads from the visualization tree
* Send the Field columns a parameter widget reads in param_fields
* Stop re-resolving a parameter widget's fields through the store
* Keep the empty default a parameter widget's fields prop had
* Read the driver-dependent Field columns in param_fields expectations
* Add a harness for measuring bundle load time
* Report the second load and make the benchmark safe to run in CI
* Run the load benchmark across network and CPU conditions
* Sign the load benchmark in to a real instance
* Track page load times on every master merge
* Backfill load times for recent master commits
* Rewrite the load-time backfill in TypeScript with a tested core
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Launch the benchmark jar the way we ship it, and lint the .ts scripts
* Measure the whole load, not just DOMContentLoaded
* Do not let the performance marks gate a reading
* Drop the backfill workflow
The goal is a global trend, which the per-merge job builds on its own after a
few days. Backfilling only bought a readable chart sooner, at the cost of a
second workflow and the largest script in the PR.
The commit date stamp goes with it. It existed so a backfilled row could claim
its commit's date rather than today's, and the per-merge job never set it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Let a PR run the load benchmark against its own uberjar
A `workflow_run` workflow always runs the copy on the default branch, so a
change here cannot be tried out before it merges. The `workflow_call` trigger
lets CI hand the workflow a commit and reuse the uberjar it already built. A
called run measures and reports; it does not write rows to Stats.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Report each state from one load, not a median per reading
Every reading was medianed on its own, so a row described a load that never
happened. It could also put the readings out of order and print a page-ready
that precedes the TTFB beside it.
`measure.js` now picks the run whose DOMContentLoaded is the median of its
series and reports that run whole. The second visit happens once per browser
profile, so it stays the run itself, and its page-ready now comes from that same
load rather than from a median that included the series' first, uncached run.
`Cold spread %` still comes from all the cold runs. A spread is a property of the
series rather than of one load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Read the largest paint with an observer, and cut the timeout
The first CI run reported a zero largest paint in all four conditions, and would
have done so forever. These entries never reach the performance timeline, so
`getEntriesByType` cannot see them. Only a `PerformanceObserver` can, and it has
to exist before the paint. Paint entries are buffered, which is why
first-contentful-paint needed none of this.
The browser keeps revising the value until the user interacts, and the reading is
taken once the page reports ready. It is a lower bound rather than the final
figure, which is what makes it comparable between commits.
That same run took 4.4 minutes, of which the measurement was 164 seconds. The
timeout drops from 25 minutes to 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01171Lkmug15JzrcN2aMBLMk
* Move the bench to frontend/test and write it in TypeScript
---------
Co-authored-by: Fraser <frasermcintosh1@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Take a metadata provider in the detail-view query helpers
* Select the detail-view queries instead of building them
* Take the provider for the table's own database
* Guard the table query selector's memoisation
* Type the table query guard against the API table
* for EE/white_label - do not use hardcoded metabase in filenames
* test coverage includes test for special characters in application-name
* also use gibberish names in tests
Closes QUE2-820
Closes#80557
There are two changes:
1. handle `nil` nodes gracefully during traversal, and
2. don't add `nil` nodes to the graph in the first place.
Both of these changes alone fix the reported issue, but both make sense on their own.
* Stabilize collection type-filter options via an items-metadata endpoint, default the type checkboxes to unchecked, and hide the filters for collections of 10 or fewer items
* Show every type in the filter popover, disabling the ones without matching items
* Use kebab-case params on the metadata endpoint and adapt toolbar tests to the visibility threshold
* Scope the items metadata to the models the list shows, name its count total_items, and align root permissions with the items list
* Assert the restricted root metadata count against the items list instead of a fixture-dependent number
* Code review improvements
* Cleaned up kebob usage
* Adapt the branch to master's show-exploration-documents param and re-record kondo budgets
* Cover the items-metadata endpoint's exploration-document scoping
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every provider behind Metabot now streams reasoning to the chain-of-thought UI,
with one predicate per adapter deciding the settings gate, the request directive,
stream forwarding and replay together.
* BOT-1895: Shared groundwork. Drop the pre-registry saved-model fallback from
Azure and Google; make tool_calls outrank reasoning in delta classification,
which previously lost a tool call's only id/name chunk; consolidate
reasoning-part coalescing into one helper.
* BOT-2027: Bedrock. Request thinking per model via the shared builders. The
openai-family gate answers false — probes across all four GPT models confirm
Bedrock never emits reasoning summaries.
* BOT-2028: Azure. Gate derived from the admin-typed deployment name, matched
case-insensitively, which also fixes a latent temperature 400 on cased Claude
deployments. Negative paths verified live; the positive path rests on docs.
* BOT-2025: Z.AI. Forward reasoning_content and send the thinking directive
explicitly — GLM was already thinking invisibly on every chat. Thinking-only
models get reasoning_effort and a 2048 floor on forced tool calls, flagged on
the supported-models row so the sets cannot diverge.
* BOT-2024: OpenRouter. Forward flat delta.reasoning and classify all 26 models
by live probe; the gpt-5.5/5.6 family streams under the server default, where
an explicit enable verifiably suppresses it. Forced tool calls floored on
mandatory-reasoning models.
* BOT-2026: Google Gemini. One catalog drives context windows, directive and
gate. Thought summaries on chat, thinkingLevel LOW on structured (no off
switch), and a 2048 floor — probed, a real prompt at 512 spent the whole
budget thinking and returned no tool call.
* BOT-2030: Mistral. Net-new cost: the default sends no thinking and only
"high"/"none" are accepted. Flattens vector content chunks into the shared
xf's events, and adds the opt-in reasoning replay hook Mistral's docs require.
* BOT-2046: Moonshot. kimi-k3 always thinks — explicit effort, mandatory in-turn
replay, 2048 floor; kimi-k2.6 is optional and disabled under a forced tool
choice, which it rejects while thinking.
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Take a metadata provider in the shared query helpers
* Select the segment query instead of building it
* Take the provider for the segment definition's database
* Guard the segment query selector's memoisation
* Guard the segment query assertion against a vacuous pass
* Add a jest project for heap retention specs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Scope the parsed x value cache to one getXValues pass
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Assert heap retention on the settled pass, not the first touch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Key the log timestamp cache on the log object
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Key the schema viewer flow graph on the response object
The memo key serialized every table and field of the schema, so each distinct
schema held a megabyte-scale string forever. RTK Query returns a stable
reference per cache entry, so a WeakMap keeps the identity that useGraphSync
depends on without retaining the schema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Key pie.rows on the dataset instead of a JSON copy of it
The memo key serialized every row and column, so each dataset the user viewed
held roughly its own size in cache key for the life of the tab. A WeakMap on
the dataset with a short settings subkey keeps the same hit rate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Ban new uses of underscore's memoize
The 17 remaining call sites are allowlisted. The list may only shrink.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Narrow the memoize rule to module scope
A cache built inside a function, a component render, a class field or a
constructor is released with whatever owns it. Only a module scope cache lives
for the life of the tab, so the rule now flags just those.
This drops the allowlist from 17 files to 5, and each remaining entry carries
the reason its set of keys stays small.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Remove the memoize allowlist
Three of the five module scope caches are gone:
- getScrollBarSize takes no arguments, so it is a lazy value, not a cache.
- getScheduleExplanation returns a string that no caller compares by identity.
- cronToBuilderValue moves to the one caller that needs a stable identity.
Schedule takes its value as a useMemo dependency, so StrategyForm now holds
that memo itself. A user can write any number of cron strings, so a module
cache for them was unbounded.
The remaining two carry an eslint-disable with the reason their set of keys
stays small.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Follow renderer_utils into the viz-core module
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Cover metabase/utils/memoize in the module scope rule
That memoize roots its WeakMap on the function it wraps, so it only releases
once that function does. At module scope the function never does, and the
nested Maps hold every argument ever passed. No call site does this today, but
the rule could not have caught one.
The rule now covers both sources, so it drops "underscore" from its name.
memoizeClass stays allowed: it keys on the instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop re-reselect from the dashboard selectors
createCachedSelector keyed getQuestionByCard and
getDashcardParameterMappingOptions on the card id, a primitive, in a
FlatObjectCache. That cache is a plain object with no eviction, so a session
kept one Question per card id it ever saw, and each Question pins the whole
Metadata snapshot it was built with.
reselect v5 defaults to weakMapMemoize, which keys on the card and the metadata
objects themselves. Entries are released when their card leaves the store or
the metadata is replaced, and identity still holds for every card in the
working set, which is what connect and useSelector compare on.
Measured over 300 cards, with the states dropped: 6.2 MB retained before,
0.0 MB after. Per entry, 21462 B before and 51 B after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop re-reselect from the lockfile
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Back metabase/utils/memoize with reselect's weakMapMemoize
The hand-rolled implementation had two problems. It allocated two arrays on
every call, including cache hits: 225 ms against 20 ms for weakMapMemoize over
two million hits. And its WeakMap was keyed on the wrapped function rather than
the wrapper, so a cache built around a module-level helper outlived the wrapper
that owned it. A plain Map would have been safer.
weakMapMemoize matches on the same terms, identity for objects and value for
primitives, so no call site changes. It also weak-keys object arguments, which
the old one held strongly.
definition-cache passed weakMapMemoize as the memoize and argsMemoize options.
reselect v5 already defaults to both, so those are dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Route all memoization through metabase/utils/memoize
underscore's memoize is gone from the app. Its 16 call sites now use the house
helper, which is reselect's weakMapMemoize. Three of them were in metabase-lib,
which cannot import metabase/utils, and all three took no arguments, so they
became plain lazy fields instead.
Two rules keep it that way:
- no-direct-memoize-import bans memoize from underscore and weakMapMemoize or
lruMemoize from reselect, in any scope. Only metabase/utils/memoize may reach
for an implementation.
- no-module-level-memoize keeps its job, now that the house helper is the only
one left to police.
measureTextWidthSafely took a style object that was rebuilt on every render, so
identity keying would never have hit. Its third argument is now the font family
string, which is the only field any caller ever set.
useMemoizedCallback returns the callable shape rather than casting to T, since
the memoized function also carries clearCache and resultsCount.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Build memoizeClass on the house memoize
It kept a module-level WeakMap and walked a nested Map trie keyed on the
instance, the method, the argument count and then each argument, assembling
that path into an array on every call.
Keying weakMapMemoize on the instance and then the arguments gives the same
per-instance caching. One cache per decorated method replaces the shared trie,
so the method no longer needs to be part of the key, and neither does the
argument count. The file is down to 61 lines from 128.
A leak spec covers the property this rests on: an instance's results are
released with the instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Measure memoizeClass retention under load
2,000 instances per pass, 20 calls each, across a method with no arguments and
one taking a primitive and an object. Nothing is retained once the instances
are dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Delete memoizeClass
It decorated a prototype method by rewriting the property descriptor, and put
the memoization 600 lines away from the method it applied to. Its one consumer
memoized a method whose body uses no `this`, so the same caching comes from
assigning a memoized arrow to a class field. The field is created per instance,
so the cache is released with the instance, and the memoization is now visible
at the definition.
metabase/utils/memoize is 14 lines, from 128.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Run the heap specs from their own jest config
They were a project inside jest.config.js, and a bare `jest` runs every
project, which is what CI does. So the sharded unit run picked them up, without
--expose-gc, and they failed as designed.
They now live in jest.memory.conf.js, the way the timezone specs live in their
own config. The shared base moves to jest.base.conf.js so neither file has to
duplicate it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Reduce the heap specs to one collection assertion each
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
* Follow the store mocks into test support
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012b67ubbXJVMuQrpuEksbeh
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A settings manager could change a connection's base URL without entering its
masked secret again. The update merged the stored secret back in, and
credential verification sent it to the new host before anything was persisted.
A network policy cannot tell a legitimate provider from a public host the
caller controls.
Changing a connection's effective base URL now requires freshly submitted
credentials for every sensitive field that would otherwise carry a secret to
the new destination. Environment-owned credentials must be repointed through
deployment configuration. Trusted provisioning such as config.yml is
unchanged.
Generic writes to the llm-providers setting run the same field validators, so
PUT /api/setting cannot bypass the connection API's checks.
* Enforce a network policy on LLM provider base URLs
A settings manager could point any provider's base URL at an internal
host; the model listing behind the admin Connect button then fetched
GET /v1/models from it with bare clj-http, and a 4xx body preview came
back in the error. Semi-blind SSRF.
New llm-allowed-networks setting, same shape as warehouse-allowed-networks:
external-only on Cloud, allow-all self-hosted (vLLM/Ollama on localhost
or a private network is the normal case there). Admin-only, so the
settings-manager class can't loosen it.
Every base-url registry field validates against it on write, so the
API rejects a forbidden URL before verify-credentials! makes the
request. core/request and llm.anthropic/chat-completion check again at
request time and resolve DNS through the policy resolver, which closes
the rebinding gap. The AI proxy URL is operator config and is exempt.
e2e pins MB_LLM_ALLOWED_NETWORKS=allow-all next to the warehouse pin,
since the mock LLM server is on localhost.
* Cover the embedding and Google token sinks too
Semantic search posts embeddings to llm-openai-api-base-url and to the
settings-manager-writable ee-embedding-service-base-url with bare
clj-http, so the same writer class reached the same kind of sink. Those
now run the llm-allowed-networks check and DNS resolver; the operator's
ai-service-base-url is exempt, and ee-embedding-service-base-url is
validated on write.
A Google service account key carries its own token_uri, and the
credential library posts to it to mint an access token before any
request we guard. Keys are now refused unless token_uri is one of
Google's OAuth hosts.
* Use var links in LLM policy docstrings
* Keep SSRF rejections out of the embedder breaker
* Normalize connection-time LLM policy errors
* Link LLM timeout settings in request docs
* Fix reflection warnings in LLM URL validation
* Use explicit policies for managed AI endpoints
* Address final Copilot review feedback
* Reject non-global special-purpose addresses
* Align network policy terminology
* Make external-only the LLM network policy everywhere
llm-allowed-networks is now environment-only (:internal, no setter)
and defaults to external-only on every instance. A settings manager is
who it defends against, and on Cloud a customer admin loosening it
would be reaching for our own infrastructure, so nobody sets it through
the API. Self-hosted operators with a vLLM server on a private network
opt in with MB_LLM_ALLOWED_NETWORKS; a value that is not one of the
three policies fails closed with one warning.
The rejection message says what to do: self-hosted, it names the
setting and which value fits; on Cloud, that private networks are out
of reach. The vLLM field's placeholder and help text no longer
advertise a private host as the default setup.
The managed endpoints' :allow-private is now a floor rather than a
replacement, so an explicit allow-all keeps an AI service on localhost
reachable. An embedding service URL supplied by the environment gets
the same floor, since the setter cannot vet it.
* Enforce the LLM network policy at connection time only
The request-time check resolved the host with a blocking lookup that
the connection's DNS resolver then repeated, and in the embedding path
it ran ahead of the breaker's fast-fail. Requests now run only the
I/O-free syntax check up front; the resolver decides on the addresses
the connection actually opens, and its refusal maps to the same 400
the set-time check gives. The set-time check still resolves, so a bad
URL is refused when it is entered.
One helper, llm-request-opts, replaces the three hand-copied
sequences; the refusal is thrown from one constructor. It carries
:status 400 beside :status-code because the semantic-search dead-letter
queue files errors by :status and was retrying a refused endpoint on
the fast schedule.
A base URL with a username or password is refused outright, and the
refusal names only the host, since the message reaches Metabot chat
users and the API's 400 body. A connection-time refusal is logged; it
is the only trace a host that rebinds leaves.
* Read llm-allowed-networks from the environment alone
get-value-of-type falls back to the application database, so a value
stored there before the setter went away kept working. The getter now
reads only the environment variable, and the setting carries a :doc
string so the generated environment-variable docs keep it now that it
has no setter. On Cloud the default applies; the operator's environment
is still honored, which the e2e runner's pin relies on.
* Disable redirects for LLM requests
* Normalize embedding service base URLs
* Normalize configured embedding URLs on read
* Clarify LLM network policy documentation
* Revert "Clarify LLM network policy documentation"
This reverts commit 8f2cff7c4963efba6756c117927ca56492cd49c5.
* Polish new LLM security comments
* Clarify embedding API key fallback
* Treat unallocated IPv6 space as non-global
* Grant private-network policy floors to environment-supplied URLs only
* Refuse IPv6 space IANA has not allocated
2000::/3 is the global-unicast space, but everything in it from 2d00:: up
is reserved: no ISP routes it, so a name resolving there is either a
mistake or an internal network squatting on it. `3000::1` passed as public
before.
* Pin Google's token endpoints by whole URI
The transport posts to whatever `token_uri` says, so checking only the
scheme and host let through another path, port, or query string on
accounts.google.com.
* Validate connections written as the llm-providers setting
The connection endpoints check a base URL against the network policy, but
the list they write is a setting in its own right: PUT /api/setting or a
config.yml entry saved a loopback URL straight past them. Run the field
validators in the setter too, on the connections a write changes.
* Hold the token-cache invalidation to the LLM network policy
/api/premium-features/token/refresh POSTs to ai-service-base-url with the
premium token in the URL path. That setting is admin-writable, so the
request needs the same syntax check, redirect ban and DNS policy as any
other LLM request; only an environment-supplied URL earns the private
floor.
* Check the target host when a JVM proxy is in the way
clj-http's route planner honours -Dhttps.proxyHost, so the connection is
opened to the proxy and the :dns-resolver is handed the proxy's hostname:
the target is resolved by the proxy, out of reach, and a private egress
proxy is itself refused under external-only. Treat an operator-configured
proxy as the trusted boundary it is and check the target host up front
instead.
* Judge a NAT64 address by the IPv4 it reaches
64:ff9b::/96 is globally reachable, but its low 32 bits are the IPv4 a
gateway translates to, so 64:ff9b::7f00:1 passed external-only and reached
127.0.0.1. RFC 6052 forbids the prefix carrying a non-global IPv4; nothing
on this side of the gateway enforces that.
* Validate only the connection fields a write changes
Comparing whole connections meant rotating an API key re-ran the base URL
check, so a private URL stored before the check existed made its own
connection unwritable.
* Name a network policy that would change the outcome
A refusal under a deployment endpoint's allow-private floor advised
setting MB_LLM_ALLOWED_NETWORKS=allow-private, which was already in force.
The resolver records the policy it refused under; use that for the message
and the log line.
* Refuse an unresolvable host behind a JVM proxy
Everywhere else a host that will not resolve is allowed, because the
connection then fails on its own. Behind a proxy there is no connection to
fail: the proxy resolves the target, so a split-horizon or cluster-internal
name went out with nothing checked at all.
* Keep proxy-only DNS working
* Document and test the LLM proxy enforcement boundary
* Match JVM proxy detection to Apache routing and fix tile fixture
* Build query builder questions from the store's builder
* Build document questions from the store's builder
* Build SDK and document questions from the store's builder
* Build queries from a provider, not the raw Metadata object
* Build questions from cards through the metadata store
* Type the dashcard menu against the question dashcards its callers pass
* Name the guard the callers actually use
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNvsg9cPuNQ6BHiDwa1bif
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The illustrations switch only appeared next to a custom icon, so turning illustrations off
and then removing the icon left no way to switch them back on. It now appears whenever
illustrations are off as well.
Clackbot answered every failure with "Something went wrong", so a user denied by AI
permissions or an admin with a misconfigured provider had nothing to act on. Both the
streamed error part and the thrown-exception path now look the failure up by error
code and reply with copy that names the problem, and usage-limit errors pass their
server-authored message through. Anything unrecognised keeps the generic line, so raw
provider errors stay out of shared Slack channels.
* Serve the metabase-lib provider from the metadata store
* Serve metric metadata providers from the store too
* Serve providers for metrics and for per-item database lookups
* Take a provider lookup in the transforms query helpers
* Resolve SDK query sources from the RTK cache, not the mirror
* Delete the unused isSourceEmpty helper and the dead tableId prop chain
* Canonicalise getMetadata's options so both call shapes share one Metadata
* Build v1 Questions through the metadata store (#81666)
* Use explicit hierarchy for action keywords
The :table.row/* -> :table.row/common and :data-grid.row/* -> :data-grid.row/common
relationships lived in Clojure's global hierarchy, sharing one mutable namespace with model
keywords, hook traits and every other subsystem. Move them into metabase.actions.hierarchy,
mirroring metabase.events.hierarchy: a defonce var holding a hierarchy map, mutated through
derive!.
Four multimethods consume those parents -- action-arg-map-schema, normalize-action-arg-map,
validate-inputs! and default-mapping -- and now dispatch on the new hierarchy. Driver-level
action methods are untouched: they dispatch on [driver action] through the driver hierarchy
and only ever name leaf action keywords.
The enterprise action_v2 module derives through metabase.actions.core, which re-exports
derive!.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* State the real reason driver action dispatch is unaffected
The hierarchy docstring said driver-level action methods are safe because they only ever name leaf action
keywords. That describes an incidental convention -- `[:postgres :model.row/create]` happens to be the only
such method -- and implies a fragile dependency that does not exist.
`driver/hierarchy` is its own `(make-hierarchy)`, and `isa?` against an explicit hierarchy never consults the
global one for keyword parents, so the derives this namespace moves had no effect on `prepare-query*` before
the move either. Say that instead, and note it at `prepare-query*` too, where someone adding a driver method
will actually be reading.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <chris@metabase.com>
* Use explicit hierarchy for auth providers
Provider relationships (:provider/saml -> ::provider/provider, etc.) lived in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move them into metabase.auth-identity.hierarchy, mirroring
metabase.events.hierarchy: a defonce var holding a hierarchy map, mutated through derive!
and underive!, read through isa? and descendants.
The three provider multimethods (validate, authenticate, login!) now dispatch on that
hierarchy via methodical's :hierarchy option.
Callers outside the module go through metabase.auth-identity.core, which re-exports the four
operations; namespaces inside the module use metabase.auth-identity.hierarchy directly, since
core requires them.
derive! is whitelisted as :parallel/safe because each test registers its own distinct
:provider/test-* keyword, so the additions never overlap. The plain `derive` calls it replaces
were equally global but escaped the linter by not ending in `!`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Register test providers at namespace level, not inside parallel tests
Each of these eight ^:parallel tests registered its provider by calling derive! and
methodical/defmethod in the test body. Both mutate process-wide state, so the tests were never
actually parallel-safe -- only derive! was visible to the linter, because defmethod does not
end in `!`.
Hoist both registrations above their deftest, alongside the test providers already registered
at the top of the namespace. The deftests are now pure assertions over providers that exist by
the time the namespace has loaded, which is what ^:parallel claims. No linter exemption for
metabase.auth-identity.core/derive! is needed, so .clj-kondo/ratchets.edn is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Truter <chris@metabase.com>
Domain entity types derived from :DomainEntity/* in Clojure's global hierarchy, sharing one
mutable namespace with model keywords, hook traits and every other subsystem. Specs are loaded
from YAML files, so adding a spec file was writing into that shared space.
Move them into metabase.xrays.domain-entities.hierarchy, mirroring metabase.events.hierarchy:
a defonce var holding a hierarchy map, mutated through derive!. A leaf namespace rather than
an in-file var because both specs (which registers types and validates them) and core (which
ranks candidate entities by ancestor count) read it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Key-value namespaces registered by defnamespace derived from ::registered-namespace in
Clojure's global hierarchy, sharing one mutable namespace with model keywords, hook traits and
every other subsystem. Namespaces are loaded from EDN files on disk, so an operator adding a
file was writing into that shared space.
Move them into a hierarchy var private to this namespace, mutated through derive! -- the same
shape as metabase.events.hierarchy, kept in-file because defnamespace is the only writer and
known-namespaces the only reader.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fail the mage suite when a test namespace is not required
`mage -test` runs `run-all-tests`, which only sees namespaces that are already
loaded, and loading happens solely through this namespace's `:require`. A test
file missing from it never runs, and nothing says so.
`mage.quick-test-runner-test` and `mage.start-db-test` were missing that way.
They are required by `mage/test/mage/runtests.clj`, but CI runs `mage -test`,
which does not read that file, so their four tests had not been running.
* Drop the dead mage test runner and a test that cannot fail
Nothing referenced mage/test/mage/runtests.clj. It required three namespaces
directly, which made mage.quick-test-runner-test and mage.start-db-test look
wired up while `mage -test` never loaded either.
mage.start-db-test asserted a string literal, so it passed unconditionally.
Wiring it in would have added a green test covering nothing. start-db still
deserves real tests; this makes their absence visible.
* Fix EditableDashboard collapsing when entering edit mode without a sized parent
* Also fix EditableDashboard collapsing while sharing
* Revert "Also fix EditableDashboard collapsing while sharing"
This reverts commit be2f86c738.
GDGT-2028: Fix wrong column headers in alerts with a remapped foreign key
The header titles and the header row were built separately and zipped by
index. Only the header row dropped the remap target column, so every title
after it shifted by one. Both now derive from `visible-columns`.
An H2 dump made by an OSS build kept the metabot_permissions rows that migration 061 seeds into the fresh target instead of copying the source's own rows, because the table only appears in cmd.copy/entities when the Enterprise backend is on the classpath. The seed references the four magic groups at ids 1 to 4, which on the source are often different groups or no groups at all, so restoring that dump into Cloud either failed on a foreign key or handed Metabot permissions to whoever happened to sit at ids 3 and 4.
The table is now copied on every edition. The load side also skips rows whose group is missing from the dump, so the dumps customers have already sent restore once Cloud picks this up, and magic groups left without rows get the 061 seed re-run by magic_group_type so a restored instance matches a fresh one. Grants that landed on a real custom group cannot be told apart from a deliberate grant and stay a data fix, tracked in CLO-5999.
* Start subprocesses with ProcessBuilder
Runtime.exec's three-arg form has been deprecated since JDK 18, and it forced the command and environment
through String arrays with array type hints to match. ProcessBuilder takes both as collections, so the
call site is one line and the array juggling is gone. `:env` still replaces the parent environment rather
than adding to it, which is what the docstring promises: that is `.clear` then `.putAll`, not a bare
`.putAll`. read-lines now hints the BufferedReader the namespace already imports.
* Spend one timeout budget across a command's three waits
sh* waited for the exit code, then stdout, then stderr, giving each a fresh timeout-ms. A command that
dribbled output could take three times its budget, and the exception still claimed the single figure, so
"Timed out after 200 ms." could be thrown 600 ms in. The three waits now measure against one deadline
taken when the process starts.
* Measure the backend health-check wait with nanoTime
check-backend-health! polls for five minutes against a wall-clock start, so a clock adjustment mid-poll
could cut the wait short or stretch it. The interval literal is now written as a duration too.
* Kill the command however the wait ends, not only on timeout
Three fixes around sh*'s failure handling.
The catch was for ExceptionInfo carrying :timed-out?, so any other way out left the command running: a
reader future failing on the pipe, or the calling thread being interrupted. It now catches Throwable and
asks whether the process is still alive, which is the question that actually decides whether to kill.
:or does not fire on an explicit nil, so a caller computing :timeout-ms from an env var and handing us nil
got a NullPointerException building the deadline rather than the default.
The test's 200 ms budget left the shell to fork twice and write both pids with no margin. On a loaded
runner the file could come up a line short, failing the descendant assertion for the wrong reason. A
second is still far below the bound the test asserts.
* Stop waiting on a command that orphaned our output pipe
sh* could sit for as long as an orphan lived, whatever its timeout said: 62 s against a 500 ms budget in
the case that prompted this. with-open closed the BufferedReader, and that close takes the same lock a
blocked readLine holds, so it waited for EOF -- which a process the command left holding our stdout never
delivers. Closing the pipe underneath ends the read at once, so the streams are now closed directly.
Closing the pipe means a cut-off read reports EOF rather than an error, which would make truncation
invisible. Two things guard against that. The readers get a floor of their own once the exit code is in,
so a command that finished a moment inside its budget is not robbed of the output it had already written.
And if the pipe is still held when that floor runs out, sh* says so and throws, because half a command's
output returned as though it were all of it is worse than a failure.
The orphan itself is still running afterwards. Reaching it needs process-group handling, which is a
larger change and no mage command leaks one today.
* Rewrite the comments and docstrings in mage.shell
The docstrings now state the caller's contract and nothing else: what sh* returns, what it throws, what
the timeout actually covers now that one budget spans the whole command. Rationale about how the body
works moved down to the lines it explains -- why the descendants are listed before anything is signalled,
why the deadline is read off a monotonic clock, why the pipes are closed rather than the readers.
* Tighten the comments once more, and drop the duration glosses
A second pass over the comment wording, and a correction: a DST change does not move the epoch
milliseconds `currentTimeMillis` reports, so it was never one of the things nanoTime protects the deadline
from. NTP corrections and manual clock changes are.
The `; 5 seconds` style glosses are gone throughout. Writing the constant as `(* 5 1000)` already says
which unit it is in, and the name carries the rest.
* Serialize mage shell output and fix test namespace loading
`println` writes the text and its newline separately, so the stdout and
stderr readers could land halves of two lines on one line of the terminal.
Lock on the destination, which also keeps two commands streaming at once
from splitting each other.
`mage -test` runs `run-all-tests`, which only sees namespaces that are
already loaded, and nothing required mage.bot.pr-env-test or
mage.bot.repl-eval-test, so eight tests never ran. The `(comment ...)`
block meant to hold these requires open only referenced an alias that
existed for its own benefit; kondo does not flag alias-less requires, so
it was protecting nothing.
A Bedrock connection with no access key pair now signs with whatever the AWS
SDK default credentials chain resolves (environment, IRSA, EKS Pod Identity,
ECS task role, EC2 instance profile), and the SDK refreshes those on its own,
so a pod that already has a role needs no long-lived keys. Explicit keys still
override the chain, and half a pair or a lone session token is rejected.
On Metabase Cloud the chain would resolve our own identity rather than the
customer's, so a keyless connection is rejected there, and a token status the
license service could not confirm counts as hosted.
The streamGenerateContent xf walked each event's parts with a plain
`reduce`, which strips the `reduced` wrapper a cancelled consumer
returns. The stop never reached the outer reduction, so every event
that followed was still translated and pushed downstream — Google's
response was read to the end, and billed, after the client had gone.
The other adapters propagate the stop; only this one dropped it.
Use u/reduce-preserving-reduced so the early stop survives, and pin it
with a test that cancels on the first text delta: before the fix the
consumer kept receiving text from later events.
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Only serve tenant attributes and member_count to superusers
* Scope pulse recipients and the Slack directory by tenancy
* Update cross-tenant recipient test for non-superuser scoping
* GDGT-2467: Restrict Paper padding/radius to curated token options
* Update Loki Snapshots
* GDGT-2467: Update entity picker width e2e expectation for Paper padding
* GDGT-2467: Keep Paper default padding at 0, add no-padding option to stories
* GDGT-2467: Keep all padding options in Paper stories, only curate radius
* Update Loki Snapshots
* GDGT-2467: Type PAPER_RADIUS_OPTIONS against RadiusScaleKey
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Resolve the under-test transform's target live in cycle detection
* Split the change-target overwrite e2e test into the two cases it conflated
Retargeting onto the transform's own source table is a self-cycle (400), not a
target conflict, so exercise the conflict case with a table produced by another
transform and add a dedicated self-cycle case.
* Make the transform under test win target collisions in get-transform-cycle
* remove live-target-table-id
* Make fix-sql-params regex linear-time
The tag-body class excluded only `}`, so `\s*[^}]+\s*` had three overlapping
quantifiers and backtracked cubically on an unclosed `{ {` followed by
whitespace. Exclude `{` from the body and drop the redundant \s* so every
position matches in linear time.
* Use :standardsql dialect in test so no kondo ignore is needed
Previously it was just picking a table at random and using that row,
but in redshift it turns out that table's row doesn't actually exist,
causing a bunch of failures. Let's look specifically at the people
test-data table instead of just using whatever.
Sweep orphaned search_index__* tables at the start of reindex! instead of at activation, so a rebuild
that dies partway is cleaned up by the next run instead of leaving its table behind. The sweep is best
effort and never aborts the reindex.
* add test
* Cast DATE inputs before timezone conversion on Oracle and SQL Server
`convertTimezone` on a `DATE` column failed on both drivers:
- Oracle: `FROM_TZ` only accepts a `TIMESTAMP` and refuses to implicitly
promote a `DATE` (ORA-00932: inconsistent datatypes: expected TIMESTAMP got
DATE). Cast date-typed inputs to `TIMESTAMP` first.
- SQL Server: `AT TIME ZONE` rejects a `date` argument (error 8116: Argument
data type date is invalid for argument 1 of AT TIME ZONE function). Cast
date-typed inputs to `datetime2` first.
Fixes `convert-timezone-date-column-test` for both drivers (#27186).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYP35BadwdEL9LAC9zHWQZ
* Derive expected DATE column values from the results timezone in test
`convert-timezone-date-column-test` hardcoded the raw `d` column as
midnight `+07:00`, which is only right for drivers that support
`:set-timezone`. SQL Server can't set a session timezone, so its results
timezone falls back to the database timezone (UTC in CI) and the column
formats as `Z`. Compute the expected values the same way `format-rows`
does so the assertion holds on every driver with `:convert-timezone`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYP35BadwdEL9LAC9zHWQZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
We want to drop as much usage of legacy-mbql as we can. This function
is never used internally, but it's exported in the driver-api. We
should mark it as deprecated since everything else in this namespace
is deprecated, so we can remove it when the time comes.
Need to get this in today so it's included in the 64 branch.
* Replace Bigquery/Snowflake dataset tracking table; track by name instead.
The current system of using a tracking table was based on the
assumption that all datasets would need to be tracked by access
time. In fact, datasets fall into one of two buckets, either they are
static and immutable, or they are temporary one-offs. Static datasets
change *very* rarely and do not necessitate automated tracking. If we
neglect to clean them up, we could go for decades before the
accumulated datasets pose a real problem. But non-static datasets are
used once and accumulate *very* rapidly, needing to be cleaned up.
All datasets defined using `defdataset` are considered static.
Rather than using a database to track which ones must be cleaned up,
the logic can be much simpler; use the date embedded in the dataset's
name. It turns out it's even simpler because the logic to track
datasets based on their name has existed this entire time since 2023
in `metabase.driver.sql.test-util.unique-prefix`; we just stopped
using it in Snowflake/Bigquery in favor of the overly-complex tracking
table which A) leaked like mad and B) mistakenly marking static
datasets as old and causing them to get deleted while they were still
in use.
Redshift already uses the `unique-prefix` namespace generating its
schemas, so it only needs a few minor changes to adapt to the changes
in the gc job and the `unique-prefix` functions.
This also simplifies the gc nightly task; rather than having two
separate settings, we only need one setting for how many hours old a
non-static dataset has to be before cleaning it up. Also adds a
`dry-run?` option for manual testing.
* Address review feedback.
* Update workflow yaml to refer to single :hours parameter
* Remove 256-row limit from bigquery/snowflake
* Fix snowflake dataset :name in report data
* Get rid of with-write-stmt helper.
* Accept tracked? arg for manually GCing tracked datasets.
* Keep the data model in sync when a change is undone
* Extract shared TableSection update handlers into a hook
* Undo a custom field order with a single request
* Guard the optimistic table patch by table id
* Reproduce the dropped refetch scenario in the middleware test
* Link the upstream RTK issue from the dropped-refetch middleware and test
* Accept "all" as a collection id in the browser public API
Adds the public api plumbing
* refactor: own the ItemsTable container query inside ItemsTable
The CSS container was defined in CollectionContent.module.css, which meant it only worked on ItemsTable when it was rendered inside CollectionItemsTable. The virtual "all collections" root renders ItemsTable on its own, so the container moves onto ItemsTable.
* refactor: split the collection breadcrumbs into a view and its data
We'll need to customize what breadcrumbs will be rendered for the virtual root.
This commit extracts the view that we'll use directly.
* Clear the browsed collection while the next one loads
RTK Query keeps serving the previous collection from `data` while the next one
loads, and keeps serving it once the argument becomes skipToken. A caller that
reads `can_write` during the switch therefore offers to create content in the
collection the user just left.
`currentData` drops to undefined on any argument change, so it covers both
cases and the manual clearing from 397a3e82e04 goes away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy
* Don't let the popped stack entry override the clicked breadcrumb
Clicking a collection breadcrumb from inside a question landed on the
collection the browser was mounted on, not on the one the user clicked.
Opening a question, a metric or a model pushes a navigation stack entry, so the
back button keeps working. That entry carried an onPop that reset the view to
the mounted collection. The breadcrumb handler sets the view to the clicked
crumb and then pops the stack, so onPop ran last and overwrote the click.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy
* Render the virtual "all collections" root in the collection browser
collectionId="all" now lists everything the current user can reach, as flat
siblings, in the same order the entity picker uses: "Our analytics", the shared
tenant collections, "Our data" and the personal collection.
useAllCollectionsItems builds those rows:
- A 403 on GET /api/collection/root means the user cannot read "Our analytics"
itself, so the hook falls back to /api/collection/root/items, which has no
read check on the root. The models=collection argument is required: without
it the endpoint returns no models at all.
- "Our analytics" needs a numeric id for the table, so it gets 0. Real
collection ids start at 1, and the caller maps 0 back to "root".
- The shared tenant collections come from the collection tree, gated on the
use-tenants setting and on the tenants plugin. shallow=true keeps the
backend from building a nested tree the caller never reads.
- The backend excludes the tenant collection from /api/collection/root/items,
so the hook adds it from the current user.
CollectionBrowser renders the rows with ItemsTable directly, without the
archive column: archiving resolves its permissions from a parent collection,
and the virtual root has none.
The breadcrumbs put a static "All collections" crumb in front of
CollectionBreadcrumbsView. The personal and the namespaced collections lose
their "Our analytics" crumb, because the API reports them as its children
while the virtual root shows them as siblings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy
* Key the SDK breadcrumbs by type and id
Breadcrumb ids come from separate id spaces, so a collection and a dashboard
can both be 5. On an instance where collection 5 holds dashboard 5 the trail is
[all-collections "all", collection 5, dashboard 5], and two children of the
same list share the key 5. React warns about the duplicate key in the host
application's console.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdru2a8afSo6y24e99W6hy
* fix sdk package type
* e2e tests
* Unit test the virtual "all collections" root
* "all collections" -> "all-collections"
Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
* pageSize support
* Assert the error message, not the error container
The virtual root error tests only checked that the sdk-error-container
testid rendered. That proves an error appeared, not which one, so any
other failure rendering into the same container would keep them green.
They now look for "Failed to load collections".
Verified by mutation: changing that string in CollectionBrowser turns
all four tests red, where the testid version stayed green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9MrpBnd3VEiXVvYf15Qqd
* change root id from 0 to -1 and wrap the mapping
* import already existing defer
* not.toHaveLength(0) -> toBeGreaterThan(0)
* clarify that the root is 403 in the test
* `SdkBrowseCollectionId` -> `SdkBrowserCollectionId`
* docs(sdk): list the accepted collectionId values as bullets
The CollectionBrowser `collectionId` doc described five accepted values in
one running paragraph, and adding "all" pushed it past readable. Split it
into one bullet per value, and document the entity ID string, which the
paragraph never mentioned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UTu6CuVJnRjzS1HrmrJQT3
* else if
* rename the virtual root identifiers to all mode root
* refactor root status into union
* clarify when the root could be empty
* fix eslint
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
* Warn when startup or a migration encrypts data that was stored unencrypted
The settings backfill reports that settings saved by an older version are being
converted, the startup sweep reports each column whose plaintext values it
encrypted, and the migrations that encrypt existing rows report how many they
touched, so an operator can see when and why data changed at rest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Find legacy unencrypted values first and hand them to a handler before encrypting them
`legacy-unencrypted-value?` decides what counts as legacy plaintext, the startup
sweep and the encrypting migrations filter their rows with it, and
`handle-legacy-unencrypted-values!` is called with the count before anything is
encrypted, so it can turn from a warning into something else later. The
migrations keep private mirrors of both, as they do for the encrypted JSON
helpers, and read their rows eagerly: reducing a reducible query closes the
transaction's connection.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Call the handler's location parameter title
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Make the encryption migrations no-ops and move the app-db module's queries to db.clj
The Encrypt* custom migrations did the same work `encrypt-plaintext-columns!` does
on every startup, so their forward bodies are gone; the reversible ones keep their
decrypting rollback for downgrades. The handler now takes only a `table.column`
location. The encryption and setting namespaces run their Toucan 2 queries through
the new `metabase.app-db.db`, like the other modules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Drop the decrypting rollbacks of the no-op encryption migrations
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Make a fresh install come out fully encrypted from the migrations alone
The encryption-touching data migrations that only rewrote existing rows are no-ops
now that the startup sweep covers their columns, and MigrateMetricsToV2 goes with
them. Two v58 changesets left rows a fresh install would then see as legacy: the
SQL copy of password authentication into auth_identity is retired from the
changelog and redone in Clojure with the credentials encrypted, and sample content
is created by CreateSampleContentV3 once setting.value_with_aad exists so the
example-dashboard-id row is written whole. The settings backfill skips the
encryption-check sentinel, which the encryption tooling rewrites itself. The
fresh-install test asserts that setup logs no app-db warning and that the sweep
afterwards finds nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the sample-content migration tests in version order
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Stop inserting the plaintext encryption-check marker on new databases
The v53 changeset that inserted it is retired from the changelog: the encryption
tooling writes the sentinel itself, whole, and reads a missing row the same way, so
the settings backfill no longer needs to special-case it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Delete the plaintext encryption-check marker instead of retiring its changeset
The v53 changeset inserted the marker on every new database, encrypted ones
included, so it never said anything about the actual state. It stays in the
changelog as a checksum-agnostic no-op, since a rollback to a release that still
has it must not re-run the insert, and a new changeset deletes the marker rows: with
no row the state is unknown and judged from the content, which the encryption
tooling already does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Leave the v53 encryption-check changeset untouched
Its checksum is recorded on every existing database and validCheckSum: ANY does
not lift the check, so it keeps inserting the plaintext marker on a new database
and the v58 changeset deletes the row right after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the data migrations, retire the Encrypt* changesets, and never change a row's encryption state
The migrations that rewrite encrypted JSON columns are back with their original
bodies, but `encrypted-json-in` now writes a row back the way it was stored:
encrypted only when the stored value decrypts with the current key, so a
migration never turns plaintext into ciphertext or the reverse. The Encrypt*
migrations are removed outright, with a changeset retiring their changelog
records, since encrypting legacy plaintext is the startup sweep's job.
CreateSampleContentV2 is untouched again and a small migration fills its
setting's value_with_aad from the strictly decrypted value; the LLM providers
migration writes both columns too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Address the review: decide by decrypting, stream the sweep, and write NULL rather than an empty SET
The settings backfill now treats a value as encrypted only if it decrypts, so a
plaintext value that merely looks like ciphertext is filled in instead of being
skipped forever. update-setting-values! takes the columns to set as a map, so a
value that encrypts to nothing is written as NULL as before instead of compiling to
an empty SET. The startup sweep streams each column again, warning once before the
first row it encrypts. The unused bytes predicate is gone, the string one is
private, and the two changelog-retirement changesets are one, using the changelog
table property.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the SQL copy of password authentication; the startup sweep encrypts the credentials
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Give the internal user no password
It is inactive and can never log in, and a password would be copied into
auth_identity bare by the v58 SQL changesets, leaving a fresh install with one
row for the startup sweep to encrypt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* add deterministic data_sensitivity rules classifier
* fill data_sensitivity rule table to full enum breadth
* add data_sensitivity scan step and opt-in setting
* register classify-data-sensitivity as the last analyze step
* document PUBLIC as scanned-or-reviewed for data_sensitivity; add end-to-end tests for the data_sensitivity scan
* label non-Text base types and integer/band variants in the data sensitivity classifier
* add reset-data-sensitivity! to clear classifier-written labels
* Add test for cumulative sum of a literal number (#27807)
Adds a QP test verifying that a custom aggregation expression of
CumulativeSum(1) on the Products table, broken out by Created At by
month and ordered ascending, runs successfully instead of failing
with 'Field 1: Field does not exist'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kKRbqUbfSKNViTEzS82ss
* clean up
* undo
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Metadata for plain-`:field` expressions should propagate Field ID and Table ID
* Fix 🔧
* Add tests 🔧
* Test fix 🔧
* Test fix 🔧
* Test fixes 🔧
* Test fix 🔧
* add nullable data_sensitivity column to Field and its user-settings mirror
* Expose data_sensitivity on PUT /api/field/:id
* add tests for data_sensitivity survives sync, reactivation, and base-type changes
* add serdes round-trip tests for data_sensitivity on Field and FieldUserSettings
* Expose data_sensitivity in Lib column metadata
* tweak wording
* Keep on-demand FieldValues fetches alive past request cancellation
On-demand FieldValues are fetched on the request thread, so a canceled HTTP
request threw the warehouse scan away: every retry started the scan over and
nothing ever landed in the app DB, which is what makes these requests feel
like they take forever.
Run the fetch on a detached thread instead. It runs to completion and persists
what it fetched even when the caller stops waiting, and concurrent callers for
the same field (and sandboxing/constraint context) share the one in-flight run
rather than each kicking off their own scan.
Fixes GHY-2937 (#67843)
* Log detached FieldValues fetch failures
When every caller has walked away — the case detached-fetch! exists for —
nobody derefs the promise, so a thrown exception was dropped without a trace.
* Don't wipe cached FieldValues when the warehouse scan fails
`distinct-values` catches and logs warehouse errors, then returns nil — but
`create-or-update-full-field-values!` destructured that nil into empty values
and handed it to `persist-field-values!`, which reads empty as "this field has
no values" and calls `clear-field-values-for-field!`. So any failed or
timed-out scan deleted the values the field already had cached, and a field
slow enough to hit the QP timeout could never keep a cache at all.
Distinguish the two cases: nil means the scan failed, so leave whatever is
cached alone and return ::fv-fetch-failed. A scan that genuinely comes back
empty still clears, as before. The sync path already made this distinction
(see fetch-distinct-per-field's :failed-fields).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Guarantee detached FieldValues fetches always complete
A registry entry left behind with its promise undelivered is worse than a
leak: every later caller for that key parks on it forever, holding a request
thread each. Two paths could do that.
The `log/warnf` added for failed fetches sits inside the catch, so an appender
that throws skips both the registry cleanup and the delivery. And the registry
entry is claimed before the work is submitted, so a rejected submit — which is
what `future`'s pool does once the JVM starts shutting down — orphans it.
Route both through `complete-fetch!`, which still drops the entry before
delivering so a woken caller starts a fresh fetch rather than re-attaching to
a finished one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Sweep and cancel stalled detached FieldValues fetches
Nothing bounded the in-flight registry except every code path remembering to
clean up after itself, which is a property that keeps not holding — the
logging call has now stranded entries twice.
Give each entry a timer and its future, and sweep on the way into
detached-fetch!, which is the only moment the registry can grow. The sweep
cancels the work and *completes* the entry rather than just forgetting it:
callers park on the promise, so dropping one undelivered leaves them blocked
forever, each holding a request thread.
Only a fetch wedged outside the query processor can reach the max age — the
warehouse query is already capped by *query-timeout-ms* (20 minutes in prod).
Removing an entry is now conditional on it still being the registered one, so
a swept fetch that later finishes can't evict its own replacement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Filter stalled fetches in the doseq rather than up front
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Cap the in-flight FieldValues registry at 100 entries
Callers park on their fetch, so the number in flight is today limited by the
request thread pool — but that is an accident of how these endpoints are
served, not something this namespace can guarantee, and it stops holding the
moment anything frees a caller early.
Past the cap, detached-fetch! stops detaching and runs the work on the calling
thread. That degrades to what happened before detaching existed — no
single-flighting, no surviving cancellation — rather than failing the request,
so a ceiling that should never be reached can't take a filter dropdown down
with it. Capacity is checked inside the swap, so the ceiling holds exactly
under concurrent claims.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Refuse FieldValues fetches past the in-flight cap
Reaching the cap means something is wrong — callers park on their fetch, so
the number in flight is bounded by the request thread pool unless that stops
holding. Absorb it by running the work inline and the ceiling stops being a
signal; throw a 503 and it says what it means.
Raised to 200 now that hitting it fails the request rather than degrading it:
an instance has to have raised MB_JETTY_MAXTHREADS well past its default of 50
before this is reachable at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Pin down that joining an in-flight fetch bypasses the cap
The behavior was already right — claim-fetch!'s `contains?` short-circuits
before the count check — but it reads as incidental, and a caller that only
attaches to existing work adds no entry, so the ceiling has no reason to
refuse it. Say so, and test it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Bound how long a caller waits on a detached FieldValues fetch
sweep-stalled-fetches! only runs when a request arrives, which is exactly what
the case it guards against prevents: callers parked on a wedged fetch are
request threads, so enough of them and nothing reaches the sweep to cancel
anything. The watchdog was only reachable while the system was healthy.
Bound the waiter's deref by the same max age. The expensive thing here is the
parked request threads rather than the registry entry, and giving those back
lets requests flow again — at which point the next one sweeps the corpse. No
scheduler needed.
fetch-max-age-ms stays a backstop rather than a request-latency bound: a value
nearer a sensible HTTP timeout would return the thread far sooner, but
answering before the values are ready changes what the endpoint promises, and
that wants deciding on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Report scan failures from POST /api/field/:id/rescan_values
The endpoint discarded the result and always answered {:status :success}. That
was already misleading, and the cache-wipe fix made it worse: a failed scan now
leaves the existing FieldValues untouched, so there is no observable trace of
it at all — the caller is told the scan succeeded and sees the values it had
before.
Return a 500 when the scan failed. The button already toasts on error, so this
is the difference between "Scan triggered!" and a visible failure.
Unlike the table and database endpoints, this one runs the scan synchronously,
so it is in a position to know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Serve warm advanced FieldValues without a detached fetch
The advanced branch wrapped the whole select-or-insert in detached-fetch!, so a
cache hit — one SELECT — paid a thread handoff and a registry sweep for a plain
read. field-id->field-values-for-current-user walks advanced fields serially,
so a sandboxed user with N filters paid N of them.
Look the row up on the calling thread and detach only on a miss, which is the
only case that scans the warehouse. This is what the full-values path at
get-or-create-full-field-values! already did.
The miss path gains one extra SELECT, since select-or-insert! still does its
own read before inserting. That is noise next to a warehouse scan, and keeping
select-or-insert! there is what makes two callers racing a genuine miss safe —
detached-fetch! only single-flights within one JVM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
* Stop the FieldValues tests mutating JVM-global state
Two tests could only be trusted because of a test-runner setting. The cap test
stuffed 200 fake entries into the shared in-flight registry, so anything else
asking for field values during that window would have been refused with a 503;
three tests swapped distinct-values with `with-redefs`, which rebinds the root
for the whole process. Under the current runner nothing else is running, but
that is a property of `:multithread? :vars`, not of the tests — flip it and the
failures land in other people's namespaces.
Make the two knobs dynamic so the cap test can bind the ceiling to 0 and drive
everything through the public API instead: one real fetch held in flight, a new
key refused, and a joiner admitted. That tests more than the fake entries did.
Use the thread-local `mt/with-dynamic-fn-redefs` where the work stays on
threads that inherit bindings. Where it delegates to the original, capture it
with `mt/original-fn`: `patch-vars!` installs its proxy as the root for the
life of the JVM, so a bare symbol captured after some earlier test patched the
same var would have recursed into the proxy.
The rescan test keeps `with-redefs` — `mt/user-http-request` is served on a
Jetty worker that inherits nothing, so only a root rebind is visible there.
Commented so nobody "fixes" it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwGt3aocjPgJeGXTuEjujp
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The three system prompts, the two usage limit settings and the quota message no
longer read their environment variables, so they are set through the admin page,
the API or a config file like every other setting. Name, icon and
show-illustrations keep theirs, which matches the MB_APPLICATION_* whitelabel
settings that self-hosted deployments set once at startup.
Demoting a setting used to drop it from the config file template as well, because
the template generator shared the environment variable docs filter. That filter
now sits with the docs alone, which puts use-tenants back in the template.
transforms-setup-complete is derived and never read back, so it is marked
read-only and the template stops advertising a key that does nothing.
An `:error-code` means the throw site wrote this as an API response rather than
hitting an unexpected failure, so the client gets the message and the error data
and nothing else. The Metabot token-limit 402 and the transforms trial-limit 402
both returned a full backend trace before this.
Validation errors, real 500s and the older `:error_code` spelling are untouched.
* MCP v2 prep: extract query guards + pulse helpers; additive shared-ns changes v2 needs
Behavior-preserving groundwork for the v2 MCP surface, landed while v1 is
untouched and v2 is not yet present:
- NEW metabase.agent-api.query-guards (+ its test): query safety checks
extracted so REST and MCP run identical guard logic.
- metabase.pulse: recipient-filtering and sensitive-metadata-stripping
logic moved from private fns in api/pulse.clj into models/pulse.clj and
exported via pulse.core / pulse.api, so REST and MCP share it. Pure move;
existing REST pulse tests are the check (+ new direct tests).
- metabase.channel.urls: adds metric-path, transform-path, collection-path
helpers (additive).
- metabase.metabot.scope: adds agent:{content,query,sql,delivery}:* scopes
and collapses agent:viz:mcp-ui:{query,drill-through} into
agent:viz:mcp-ui:* — a proven superset, backward-compatible with v1's
gating (REPL-verified: old concrete scopes still match). Also regates
agent:metric:* from SQL-generation to NLQ permission (scope_test).
- metabase.mcp.settings: adds mcp-v2-disabled-tools and
mcp-query-handle-ttl-hours (additive; v1-safe; tests included).
- metabase.models.serialization.resolve{,.mp}: adds *numeric-ids-allowed?*
dynvar; resolver miss messages no longer name a specific discovery tool
(the calling surface owns the recovery sentence) — mp_test updated.
- metabase.server.streaming-response: optional :executor option threaded
through do-f-async; default path (shared pool) unchanged. Needed by
long-lived SSE keepalive streams so they can't exhaust the fixed pool.
Review question for this PR: does REST/v1 behavior stay identical?
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Review fixes (2026-08-27 deep review of #81244):
- native-marker? requires a truthy :native value (an explicit-null key from a
JSON round-trip is not a marker); docstrings state the guard/agent-api
sequencing explicitly (live v1 path untouched; call sites migrate at retirement).
- check-mcp-ui-native-query! accepts agent:sql:execute (v1's concrete scope)
alongside agent:sql:run, and documents that scope claims arrive with the v2
session rework — until then native-over-UI-credential fails closed.
- mcp-query-handle-ttl-hours getter no longer rethrows on an unparseable env
var (falls back to the default instead of killing the GC task).
- *numeric-ids-allowed?* docstring rewritten in future tense naming its binder
(the v2 query pipeline) and reader (the dialect rework PR).
- viz-scope comment corrected (v1 resources still register those strings until
retirement; wildcard keeps them satisfied); mp.clj database-id tru args
str-wrapped against MessageFormat locale grouping; streaming-response
:executor docstring notes the Future.cancel(true) interruption requirement.
Follow-up (review round 2): the guards are now genuinely shared — agent-api's
private copies are deleted and its execution paths call query-guards directly.
Deliberate tightening: raw-scan native detection replaces normalize-then-inspect
(fail-closed on unparseable payloads). Full agent-api api-test green.
Re-review round: agent:sql:execute compat branch now tested; TTL getter's
catch path tested ("forever"); reject-native-query! no longer echoes the
payload in ex-data (matches the old live rejection shape); viz-scope comment
typo fixed; pure predicate tests ^:parallel; check-token-query-permissions!
docstring states the stage-0-fast-path/QP-backstop contract.
--- Close-out review response (slice 00's first adversarial round) ---
CRITICAL (native-query false-positive on legacy MBQL with a field/expression/tag named `native`)
FIXED. native-query? scanned the whole payload via tree-seq, so a legit query whose :expressions or
:template-tags map had a key named "native" was rejected (400) though it ran fine on master. It now
scans only the structural query-nesting edges (:stages/:joins, :query/:source-query), never
descending into caller-named :expressions/:template-tags; a malformed value off a structural edge is
still deep-scanned to stay fail-closed. Proven: a 17-case REPL matrix — every crafted false-positive
now false, every genuine native (legacy top/nested, MBQL5 stage, joins, string-keyed, malformed)
still true.
SECURITY (v1 MCP bearer authenticates a deactivated user) FIXED. resolve-access-token — the single
resolver shared by the v1 transport and the core session bearer bridge — now gates on
(t2/exists? :model/User :id user-id :is_active true). v1's transport dispatched straight on :user-id
with no active check; v2 was already refuse-only. Proven fails-when-broken: reverting the guard lets
a deactivated user's token resolve; restored, it is refused. v1 mcp.api-test (598 assertions) green.
IMPORTANT fixed: agent:delivery:write now granted by the metabot-other-tools bucket (was dead
config → alert_write would 403 everyone); metabot-nlq grants agent:content:read not :* (was
over-granting write to NLQ-only users); update-pulse-with-perm-checks! validates :cards against
CoercibleToCardRef (a bad string id was a 500, now a 400); check-mcp-ui-native-query!'s docstrings
now state it is dormant until the v2 session-rework slice wires it (was claiming to be live).
MINOR fixed: thrown-status test helper distinguishes no-throw from throw-without-status; kill-switch
check moved after the scope check (a scope-denied caller no longer learns the kill-switch state);
the query-handle-ttl default is one const, not two literals; mp_test gains positive assertions.
Re-verified (fresh JVM, run separately): query-guards + scope + settings + mp 328 assertions;
oauth + v1 mcp.api-test green. The one red in pulse-test is create-csv-xls-test = GDGT-3161, a
pre-existing EE-app-db bug (empty {} dataset_query -> download-perms 500) in the CREATE path (not
this fix's update path), reproducing on plain master — not introduced here. kondo 0/0; cljfmt clean;
modules-config regenerated (oauth-server :model-imports gains :model/User).
--- master parity sync (user-approved Option A) ---
Carries master 8c3ea6f40b (#80554 Remove Explorations dimension anchored blocks) + master search/appdb/index.clj (isolate-write! savepoint isolation). Stack forked before both; without them the dropped exploration_block.type migration is absent -> Liquibase changelog-integrity failure on real app-DBs (App-DB Part2 CI). Synced at base to restore parity. 41 files, all slice-untouched.
* Fix save-access-token! call arity after master merge
Master gave the test helper a 5th arg (client-id via
oauth-server.tu/with-oauth-client); the new
resolve-access-token-deactivated-user-test still called the 4-arg form.
Wrap it in with-oauth-client and pass client-id, matching every other
call site. Verified: full session-oauth-test namespace green locally
(10 tests, 27 assertions, 0 failures).
* Fix native-guard snake_case gap and make the tenant recipient filter work end-to-end
- native-map-edges gains :source_query/"source_query": legacy MBQL
normalization canonicalizes the snake_case spelling too, so a payload
like {"query" {"source_query" {"native" ...}}} reached the query
processor as a native stage while native-query? said false — an
MBQL-only-scoped caller could run raw SQL through /v1/execute,
bypassing the scope split and the execute-sql kill switch. Regression
tests cover both the string and keyword snake forms.
- The cross-tenant recipient filter compared :tenant_id on recipient
maps that the :recipients hydration never populates, so a tenant
caller lost every user recipient and a tenantless caller was filtered
by nil = nil. Tenant membership is now looked up from the app DB
inside the filter, and the filter only engages for a caller who
belongs to a tenant — a tenantless user sees recipients unfiltered,
as before tenants existed. The test now builds real users in real
tenants (production-shaped recipient maps, ids only) instead of
fabricating :tenant_id keys, and pins the tenantless-caller
passthrough.
* Match native-SQL markers case- and separator-insensitively
`native-query?` matched edge names and markers case-exactly, but the QP
normalizer canonicalizes keys case-insensitively and treats `_` and `-`
alike. `{:query {:SOURCE_QUERY {:native "select 1"}}}` therefore scanned
as not-native and then executed as a native stage — REPL-confirmed, along
with `:Source_Query` and an uppercased `:NATIVE` marker.
That is a live bypass rather than a latent one: since
`+refuse-unscoped-native-sql` went live on /api/dataset, this scan is the
only gate between an unrestricted-stamped MCP UI credential and raw SQL.
The same scan backs `reject-native-query!` on the agent-api execution
paths, so those inherit the fix.
All key and value matching now goes through one `token` fn — namespace
kept, lowercased, `_` folded to `-`. Nodes are walked as `[token value]`
pairs rather than by fixed lookup, so a node carrying two spellings of
one edge has both scanned instead of one silently shadowing the other.
* Deep-scan a non-map stage element instead of throwing
`native-query?`'s `scan-map` iterated the node's entries without checking it was a map, so a
junk payload like `{:stages [1]}` threw "Don't know how to create ISeq from Integer" and
`/v2/query` answered 500 where shape validation should have answered 400
(`combined-query-rejects-malformed-payload-test`). A non-map where a stage belongs is now
deep-scanned like every other malformed sub-tree: never throws, and a marker after it still
trips the guard.
* Address round-3 review findings on the extraction slice
- The bearer resolver's is_active gate is defense in depth, not the primary control: deactivating
through the model already revokes the user's tokens. The docstring now says so, and the test
deactivates with a raw core_user UPDATE (the path the gate exists for) and asserts the token was
not revoked, so the gate is what the test exercises.
- Pin that a tenant-scoped caller sees only same-tenant pulse recipients (tenantless users hidden).
- scope.clj comments described a wildcard collapse that is not in this diff; say what the slice
does: adds scopes, removes none.
- Correct the guard test docstring: the /api/dataset gate is wired by the next slice.
- Pin the guard's deep-scan fallbacks and the streaming response's :executor option with tests.
* Widen the recipient merge-back to cover the tenant filter
Addresses review feedback on the tenant recipient filter: a filtered read
that is submitted back as a write silently deleted the recipients the
caller was never shown.
The `:channels` submitted to an update are authoritative --
`update-notification-channels!` deletes any recipient not present -- so
every recipient hidden on the read path has to be restored on the write
path. Master already did this for one read filter (sandboxed and
impersonated users, via `maybe-add-recipients`), but the tenant filter
added in this slice had no corresponding write-path compensation. A
tenant-scoped caller doing GET then PUT of the returned body therefore
dropped every other-tenant and tenantless recipient row.
Rather than add a second parallel mechanism, the tenant visibility rule
is now a single predicate (`recipient-visible-to-tenant-caller?`) used by
both sides: the read path filters with it, and the write path merges back
exactly what it excluded via `hidden-cross-tenant-recipients`.
Two supporting fixes:
- `maybe-add-recipients` now normalizes `:channel_type` the way
`email-channel` does. The old `(= "email" ...)` string comparison only
matched the REST shape, so a direct call with a hydrated pulse (keyword
`:email`) skipped the merge entirely -- which also made the pre-existing
sandbox protection REST-only.
- Merged recipients are de-duplicated by `(some-fn :id :email)`, the same
key `update-pulse-with-perm-checks!` uses to diff recipients, so a
caller matching both filters cannot get a recipient appended twice.
Verified in a REPL: the new endpoint-level round-trip test goes red
without the merge-back (two recipient rows destroyed) and green with it.
It asserts on `pulse_channel_recipient` rows rather than the API response,
since the response is filtered either way and cannot distinguish "hidden"
from "deleted". Pulse, pulse-channel and enterprise sandbox pulse suites
show no new failures (`create-csv-xls-test` fails identically on the
unmodified branch).
---------
Co-authored-by: Eric Normand <ericwnormand@gmail.com>
In https://github.com/metabase/metabase/pull/80957 we trimmed this job
down so it no longer doubles the tests run. But it's still pointless
to have it as a separate job; it should be tested inline with the main
suite.
We still run some individual tests twice using the
`with-and-without-multi-level` macro when we need to cover both the
multi-catalog behavior and the non-multi-catalog behavior, but this is
only a handful of tests now instead of the entire suite or the entire
namespace.
Removal of `when-not (tx/db-test-env-var :databricks :multi-level-schema false)`
causes a lot of indentation changes here, so it's best to review with
whitespace diffs turned off.
* Split the SSO auth settings pages into card sections
* Deflake modal escape close in data-model e2e specs
* Move docs changes to a separate PR
* Finish token-scale translation in the merged css modules
* Deflake dark mode toggle assertions in palette spec
* release JDBC savepoints when nested transactions succeed
* tighten inline comment
* pr feedback
* Release savepoints only when no later scope is still running
* Fix sqlglot parse failure for ?::type placeholder casts (#81373)
With connection impersonation enabled, native queries are parsed by
sqlglot after template-variable substitution, when {{var}} has already
become a JDBC `?` placeholder. sqlglot's base tokenizer treats `?::` as
a single token (DuckDB's try-cast operator) in every dialect, and only
DuckDB's parser can consume it, so `{{var}}::date` -- a very common
pattern on Postgres/Redshift/Snowflake -- failed with "Unable to parse
native query".
Fix: before parsing in is_single_stmt_of_type, rewrite `?::` to `? ::`
(which sqlglot parses fine, emitting CAST(? AS type)). Occurrences are
located via the tokenizer's own QDCOLON tokens, so `?::` inside string
literals, dollar-quoted strings, and comments is left untouched, and
placeholder order is preserved in the re-emitted SQL. Multi-statement
rejection and the rest of the impersonation validation are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkBejATyUcaq2R74xFd5M1
* comments and tests
* Assert all placeholders survive; fix QDCOLON dialect guard (databricks, not duckdb)
Review feedback: the placeholder-cast test now asserts that every ? in the
input survives into the re-emitted SQL, not just that one does.
While adding duckdb coverage, it turned out the dialect guard in
_split_placeholder_casts named the wrong dialect: in sqlglot 28.6.0 the
?:: (QDCOLON) token is emitted by the *base* tokenizer but only
Databricks' parser consumes it (the expr?::type try-cast column
operator); duckdb's parser does not, so duckdb was still failing on
placeholder casts. The guard now skips databricks instead, duckdb gets
the same ?:: -> ? :: rewrite as everyone else, and tests cover both:
duckdb placeholder casts parse with placeholders preserved, and
databricks' native try-cast is left intact (re-emitted as TRY_CAST).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* clean up
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Make ratchet shrinking explicit, and check it in one place
The ratchet test fixture ran `fix!` on every non-CI run, so `project-tests
ratchets` rewrote .clj-kondo/ratchets.edn in your working tree before
asserting against it. A check that edits what it checks leaves a diff nobody
asked for, in the file every PR conflicts over, and its only reachable
failure was a bug in the fixer that had just run.
Removing the auto-fix forces the dev/CI split to go too: a strict local test
that no longer tightens would fail on every branch that removes an ignore.
So over-budget is now the rule everywhere, and shrinking is only
`./bin/mage kondo-ratchets-shrink`, which the shrink workflow runs on master.
That left two implementations of nearly the same check. The babashka
`check-report` gains the justification finding — the one assertion the JVM
suite had that it lacked — so `./bin/mage kondo-ratchets` covers over-budget,
config over-budget, unjustified ignores, normalization and unknown linters on
its own. CI runs it instead of booting a JVM, and the five now-duplicated
in-repo assertions leave the test namespace, which drops from 6.2s to 0.8s
because it no longer walks the source tree.
Task names follow: check-kondo-ratchets (which nothing called) becomes
kondo-ratchets, fix-kondo-ratchets becomes kondo-ratchets-shrink, and the
merge task becomes private -kondo-ratchets-merge, since only the git merge
driver invokes it. bin/merge-kondo-ratchets keeps its name; installed merge
drivers point at it.
* Polish kondo ratchet guidance
* Prime the ratchet shrink workflow
Intentionally raise the :case-symbol-test budget from 1 to 2 without adding a suppression. The read-only check allows the unused budget; after this PR merges, the shrink workflow should restore it to 1 through a Tighten ratchets automation PR.
* Make the ratchet suite check the project
Keep the ratchet tooling unit tests in the backend suite, but make project-tests ratchets run the read-only repository check. Include it in the bare project-tests command so local project-level checks still validate the checked-in ratchet policy.
* List project-test suites once
* Address review findings on the ratchet check
check-report threw NPE on a ratchets map without :comment-exempt, while
tolerating a missing :ignore-counts or :config-counts. Default it, so the
three policy keys behave alike.
The run-project-ratchet-checks description and the step comment both still
described the old wiring. The check now runs alongside the backend checks,
which no longer include it, and "no JVM" is not quite true: resolving
clj-kondo on a cold cache shells out to the JDK prepare-backend installs.
* Warn about stale comment exemptions
The only check that a :comment-exempt entry is still needed was a local-only
test, which went vacuous when the dev and CI semantics were unified, so
nothing named a stale exemption any more. The check now reports one the way
it reports an :unlimited policy nobody uses: informational, never a failure,
since only the shrink workflow removes them.
* Say why the merge driver finds mage through its own path
The script runs against whatever repository holds the conflict, which is not
always the one it lives in -- the tests merge stages in a throwaway repo. A
relative bin/mage looks plausible and fails there, so record the reason.
* Stop wrapping two comments that did not need it
One fits on a single line once it says less. The other breaks at its sentence
boundary rather than mid-clause.
* Break the ratchets header on its sentences
Four sentences started mid-line and wrapped mid-clause, one of them trailing
"in the PR." onto a line of its own. Same words, one sentence per line.
* Keep comment exemptions until someone removes them
fix! dropped a :comment-exempt entry as soon as every ignore of its linter
carried a comment, or the last uncommented one was deleted -- so an exemption
could disappear as a side effect of unrelated work, and getting it back is a
change to defend in review.
An exemption records a decision about a linter, not a count, which is the
reason :unlimited policies are already kept at zero and only warned about.
Treat the two alike: the check and the shrinker name a stale exemption, and a
person deletes it.
* GDGT-2486 Add elevation, radius, and spacing scale variables
* GDGT-2486 Remap scale tokens in files merged from master
* Address review
Review fixes for the GDGT-2486 scale migration:
- Revert double-shifted tokens from the merge-remap pass: it re-applied
the old-to-new mapping to 22 files already remapped by the first pass,
shifting spacing values one extra step (e.g. xl 24px -> xxl 32px).
- Fix codemod escapes where old key names kept their name but changed px:
Modal default padding (lg->xl, 24px), MultiSelect radius (sm->xs, 6px),
MetricSearchInput radius-sm, ThemeEditor PreviewPanel radius-md,
ColumnInput theme.spacing.xs->xxs, ChartTypeSettings Space h props,
TableRowForCacheableItem icon placeholder w (md->lg, 16px),
ResizeHandle/ResizableBoxHandle grip h (xs->xxs, 4px).
- Pin Group/Stack default gap to "lg" (16px): Mantine's default is
"md", which is 12px in the new scale (was 16px in the old scale).
- Allowlist the runtime-defined --mantine-spacing-xxs/-xxl and
--mantine-radius-xxs CSS variables in find-never-defined-css-variables
(fixes fe-lint CI).
- Add theme.unit.spec.ts covering the spacing/radius/shadow scales,
dark-mode shadow set, whitelabel independence, and a guard asserting
every scale-based component default resolves to a defined key.
- Derive Card/Paper story argTypes options from the real scale.
- Augment MantineThemeSizesOverride so the custom scale tokens appear in
editor autocomplete for p/m/gap/radius/shadow props.
- Update the emotion-migrate and metabase-ui-component-from-figma skill
token tables to the new scale (spacing/radius values, shadow tokens,
snap tables).
* Update Loki Snapshots
* Address review: fix h/w style props missed by the scale codemod
The codemod did not remap height/width style props, so every bare h=/w=
scale-key value silently changed px with the new scale. Remap each to the
px-preserving key (xs 4px->xxs, md 16px->lg, lg 24px->xl, xl 32px->xxl):
- PaneHeader breadcrumb row h xl->xxl (32px, restores data studio
pane header height)
- BrowseDatabases logo boxes h/w xl->xxl (32px)
- DatabaseConnectionStringField rows h lg->xl (24px)
- DatabaseEngineList logo Center h/w lg->xl (24px)
- Metabot chat chevron flexes h/w md->lg (16px)
- MetricsFilterPill h lg->xl (24px)
- DatasetNotebook divider h xs->xxs (4px)
- DataReference NodeList icon w md->lg (16px)
- transforms-python ResizableBoxHandle grip h xs->xxs (4px)
Audited: no remaining bare h/w/maw/mah/miw/mih old-key props whose px
changed (verified line-identical against master for every occurrence).
* Address review: fix bdrs and responsive-object size tokens missed by the codemod
Two more escape classes from the scale codemod, found by a full sweep of
every touched-token form (string style props, responsive objects, CSS
vars, theme accessors) with each line compared against master:
- bdrs style prop (border-radius): remapped px-preservingly at 69 sites
in 58 files (xs 4px->xxs, sm 6px->xs, md 8px->sm). The codemod remapped
radius= but not its style-prop alias bdrs.
- Responsive object values (e.g. p={{ base: "md", sm: "xl" }}): remapped
the token VALUES at 9 sites in 6 files (base/sm/md breakpoint keys are
untouched; only their spacing values shifted: md 16->lg, xl 32->xxl,
xs 4->xxs).
Sweep result after fixes: zero remaining line-identical old-key token
references to spacing xs/md/lg/xl, radius xs/sm/md, or shadow xs/md in
any form. Intentional survivors unchanged: radius/shadow/bdrs "lg"
(disclosed broken-key-now-16px class), spacing/radius sm where the
px value did not change.
Verified: tsc, eslint (changed files), metabase/ui jest suite.
* Address review: fix remaining token escape forms (object props, braces props, style objects)
Final codemod escape classes from the exhaustive token sweep, all
remapped px-preservingly:
- Object-literal / default-parameter prop values (15 sites / 8 files):
ItemList picker-item default containerProps pb xs->xxs (4px, the
reported gap), FormFooter pt lg->xl, SlackSetup pt lg->xl,
EmbeddingSecretKeyWidget mb xs->xxs, CreateDashboardForm labelProps
mb xs->xxs x2, databases styles mb lg->xl, NotebookDataPicker
menuDropdownProps mt xl->xxl, Banner story py md->lg
- Braces-wrapped string props: CloudSMTPConnectionCard p lg->xl (24px)
and gap md->lg (16px), SettingsLdapForm mt xs->xxs,
DashboardLeaveConfirmationModal mb lg->xl
- style-object CSS properties: DatasetNotebook borderRadius xs->xxs
- Ternary token values inside responsive objects: SummarizeStep gap
md->lg (16px)
Sweep now returns zero real misses across every checked form: string
style props (incl. h/w/bdrs), braces props, responsive objects with
ternaries, object-literal/default-param props, style-object CSS props,
CSS var() references, theme.* accessors. Remaining grep hits are
untouched scales (lh, fz, size, breakpoints) or px-identical sm values.
* Address review: preserve Mantine spacing defaults exposed by Loki
* Update Loki Snapshots
* Address review: fix remaining scale regressions and visual guards
* Address review: make Loki update capture new references
* Update Loki Snapshots
* Address review: keep only targeted Loki references
* Address review: assign ticket component elevations
* Update Loki Snapshots
* Address review: tighten MonthPicker header spacing
* Address review: refine elevation visual stories
* Update Loki Snapshots
* Address review: keep renamed Menu Loki story selected
* Update Loki Snapshots
* Address review: preserve inherited Mantine defaults
* Address review: allow stock Mantine shadow tokens
* Address review: replace token audit test with ESLint rule
* Address review: drop unrelated whitespace-only dev/src changes
* Address review: use rem() in radius scale and rem docs
* Update Loki Snapshots
* GDGT-2486: Address PR comments
* GDGT-2486: Adjust negative tokens
* GDGT-2486: Address review findings
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Move the entities store into the metadata module and hide normalizr
* Record the moved schema file's import-time effect
* Describe what the metadata reducers do, not what they replaced
* Move the mirror's fixtures and slice-name helpers into the module (#81297)
* Move the mirror's fixtures and slice-name helpers into the module
* Build the QuestionSources fixture through the metadata store barrel
* Document why the metadata store is not a deletable mirror slice
* Trim the barrel and write-door comments
* only derive site-url from authenticated superuser requests
`maybe-set-site-url*` (global Ring middleware) derived `site-url` from request
headers whenever `(or (not (setup/has-user-setup)) superuser?)`. The left arm
needed no authenticated caller, so an unauthenticated request reaching the
instance before its first user was created could pin `site-url` from a forged
Host/Origin/X-Forwarded-Host header. Because the write is first-writer-wins and
setup never overwrites it, that attacker-chosen value survived setup and
poisoned every later auth/email link (notably password-reset URLs) for the life
of the instance.
Gate derivation on `superuser?` alone. `:is-superuser?` is already on the request
by the time this middleware runs (wrap-current-user-info is inner/earlier in the
onion), so an admin's own request still auto-derives `site-url` -- which happens
on the operator's next request after an interactive setup. An unauthenticated
pre-setup request now writes nothing; `site-url` simply stays unset (nil is
handled everywhere it's consumed, and a reset link built on nil is relative,
never attacker-controlled).
Deployments that provision headlessly, run multi-tenant, or never sign in as an
admin will otherwise sit at a nil site-url, so note in the setting's docs that
they should set MB_SITE_URL explicitly.
* Read Quartz job-data blobs through a class allow-list
Quartz persists job data, calendars, and BLOB triggers as serialized objects.
Metabase's job data is only ever EDN-shaped data (keywords, strings, numbers,
maps/sets/vectors) wrapped in Quartz's own JobDataMap/trigger classes, so when we
read it back we constrain deserialization to exactly those classes and decline
anything else.
Install a per-DB DriverDelegate (StdJDBCDelegate for H2/MySQL, PostgreSQLDelegate
for Postgres) that overrides getObjectFromBlob to read through a class allow-list
built from the plain-data classes job data consists of. This is scoped to
Quartz's own blob reads, so nothing else in the JVM is affected.
- metabase.util.deserialization-allowlist: a reusable, composable ObjectInputFilter
allow-list. clojure-data-prefixes covers the EDN value space; callers add their
own wrapper prefixes and read via read-object / read-object-bytes.
- metabase.task.secure-delegate: the allow-set (EDN + org.quartz.*), the blob-read
bodies, and install!, wired into task.bootstrap. The gen-class delegates are
AOT-compiled in the jar; from source they're compiled on demand so the class is
loadable by name through the classloader Quartz uses. That runtime compile is
fenced to source only (config/jar?): from a jar a missing class is a build error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
* Don't deserialize Java objects from H2 query results
Metabase reads scalar and collection values out of query results, never
serialized Java objects. Register a JavaObjectSerializer on the H2 driver so H2's
JAVA_OBJECT (de)serialization is declined rather than reconstructing arbitrary
classes from stored bytes. With that in place the ad hoc JAVA_OBJECT check in
read-column-thunk is redundant, so it's removed; read-column-thunk keeps its CLOB
handling. Ordinary values are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
* Strip internal keys from binning maps
Add :decode/api and :encode/serialize remove-internal-keys hooks to the
::binning schema so clients can't smuggle internal query-processor keys in
through a :binning map.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Strip internal keys from more client-submitted query maps
Extend the :decode/api / :encode/serialize remove-internal-keys hooks to the
::parameter, ::template-tag, ::constraints, ::middleware-options, and ::page
schemas so clients can't smuggle internal query-processor keys in through them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Reformat strip-internal-keys-test doseq bindings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test improvements and fix reflection warnings
* Validate HoneySQL idents at the app-DB compile step
Honey SQL renders an ident as bare, unquoted SQL in several positions --
a form head it doesn't recognize, an ORDER BY direction, a DDL modifier,
the function name of a `:%fn.arg` var -- so an ident reaching any of them
is raw SQL text. Hold every ident to a plain-identifier shape, with an
explicit allow-list for the keywords Honey SQL renders as fixed SQL of its
own (operators, DDL modifiers, column types, ORDER BY directions).
Also close two gaps found along the way: symbols walked past the `:raw` and
`:inline` gates, since Honey SQL resolves symbol heads and clause keys the
same as keywords; and `safe-value?` trusted any value shape it didn't
recognize, where it now enumerates the types bound as `?` parameters.
Query idents that were kebab-case are renamed in the SQL only -- the
Clojure keys they feed, including the `:metabase-user-id` request key, are
mapped back at the read site and unchanged for their consumers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Allow the Postgres operators our drivers register globally
`metabase.driver.postgres` registers the PG JSON, array and regex operators
with `honey.sql/register-op!`, which makes them global -- and the app DB can
be Postgres, so app-DB queries reach for them too: the semantic-search
migration compiles a `[:->> ...]`. Add them, and a test that holds the
allow-list to every non-namespaced operator Honey SQL knows, so an upgrade
or a new registration can't silently leave one out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Snake_case the remaining app-DB query idents
Four more sites the sweeps turned up, all renamed in the SQL only:
- `request/session.clj` selects the current user under the same kebab
aliases the session middleware did, so it gets the same rename-at-the-read
-site treatment.
- Search scorer names double as SQL aliases, and three of them are
kebab-case. They also key the weights in `search.config` and name the
scores the API returns, so the conversion happens at the one seam that
writes and reads the alias, leaving the scorer name alone. The alias gains
a `_score` suffix because `:view-count` and `:data-layer` are named after
the very columns they read.
- The model alias registry in `api/common.clj` feeds
`column-with-model-alias`, which assembles `alias.column` idents, so its
six kebab aliases are snake_cased along with the literal uses in the
in-place search engine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Constrain embedded card parameter values by the locked parameters
A dashboard's parameter value lookups receive the values of the other
parameters as chain-filter constraints, so the values offered for an
enabled parameter are limited to the rows the locked ones select. The card
lookups had no constraint argument to receive them: `card-param-values`
bound the JWT's params, used them only to check the searched parameter was
enabled and not pinned, then resolved the parameter to a Field and returned
that Field's whole value list.
Give the card lookups the same constraint argument and pass the JWT's
locked values from the embed layer. The argument is optional, so the
authenticated and public routes keep their current behaviour.
`param-type->op` and `param-type->default-options` move from
`parameters.dashboard` to `parameters.params`, so both the dashboard and
card constraint builders can reach them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Snake_case the app-DB query idents the guard rejects
CI caught `queries.models.query`, and a grep for the same shape found four
more sites the earlier sweeps had missed -- they were confined to the
app_db, collections, search and permissions modules. The aliases move to
snake_case in the SQL; where the keys they feed are confined to their own
namespace the readers move with them, and where they have to match a shape
from elsewhere they are renamed back at the read site:
- `queries.models.query` aliases so the app-DB branch matches the keys
`lib.metadata/card` returns, so it renames rather than propagates.
- `cache.task.refresh-cache-configs` feeds its aliases through a nested
subquery whose outer `:select` names them, so both levels move, along
with the readers and the test's expectations.
Also widen `safe-parameter?`: a `query_hash` read back from H2 arrives as a
`java.sql.Blob` rather than a byte array, and the enumeration rejected it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Remove a scratch probe test
Committed by accident in the previous commit -- a throwaway namespace with
printlns, used while checking chain-filter behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Fix the locked-parameter constraints for previews and stale slugs
Three problems with the constraints the card parameter lookups build:
The preview routes passed the sanitized Card, whose `:parameters` has the
locked ones removed, so the constraints were always empty and a preview
offered wider value lists than the embed it previews. They now pass the
stored Card and take only `:embedding_params` from the token, which is what
the published routes already do.
`embedding_params` is keyed by slug and `:parameters` by id, and nothing
keeps them in sync; a locked slug naming no parameter produced no
constraint and widened the values rather than narrowing them. It is now
refused. `param-values-merged-params` refuses the same on dashboards, where
the slug otherwise became a nil key that the constraint builder dropped.
A locked value of `[]` reached the filter builder as a value rather than as
"no value", producing a `:=` clause with one argument.
The constraints also carry the parameter's own `:options` now, as the
dashboard builder does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Allow a qualified argument in a :%fn.arg ident
`:%lower.metabase_field/name` renders `LOWER("metabase_field"."name")` --
Honey SQL splits the ident at the first `.` and formats what follows as
quoted entities, so a `/` there separates the parts of a qualified column
rather than landing in the SQL. Only the function name, which is rendered
unquoted, has to be a bare identifier.
The pattern rejected `/` anywhere after the `%`, which took the database
autocomplete endpoint with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Snake_case more app-DB query idents, and allow the FOR UPDATE modifiers
Found by widening the search: the earlier grep looked for the alias and its
`:select` on one line, so it missed every alias sitting on a continuation
line -- which is most of them.
- `activity_feed`: the recent-views queries alias `database-name`,
`initial-sync-status`, `entity-coll-id` and `moderated-status`. The keys
are read only within their own namespace, which builds the API response
from them under different names, so the readers move too.
- `xrays.automagic-dashboards`: `num-fields` and `list-like?` -- the latter
loses its `?`, which no snake_case spelling keeps.
- `:skip-locked`, `:nowait` and `:no-wait` are lock modifiers Honey SQL
matches by name in a `:for` clause, not idents it renders, so they join
the allow-list rather than being rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Move the query-analyzer's readers to the renamed table id
`query-analyzer/tables-for-native` aliases its table id, and that alias was
snake_cased without moving one of its two readers: `used-tables` still asked
for `:table-id` and so silently collected nothing. Unlike the write side,
which the guard rejects outright, a reader left behind reads nil.
The `tables-for-native` redefs in the tests carried the old shape too, so
they asserted against a result the function no longer produces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Revert the HoneySQL ident guard and the renames it required
The guard held every ident in an app-DB query to a plain-identifier shape,
which turned out to have a long tail: each release of it found more query
aliases to rename, and a rename that missed a reader failed silently rather
than loudly, since the reader simply got nil.
This reverts the guard along with every alias rename made to satisfy it,
leaving the parameter-value security fixes, which do not depend on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Harden site-url derivation fix: fail-closed redirect check, discerning test
Follow-ups to gating site-url auto-derivation on authenticated superuser
requests.
- check-sso-redirect: fail closed when site-url is nil. Previously a nil
site-url left our-host nil, so a redirect with a nil host but a non-nil
scheme was non-relative yet matched (= nil nil) and passed -- an open
redirect. Require a known host to match against: (and
our-host (= (.getHost redirect) our-host)).
- misc-test: bind has-user-setup false in the non-superuser negative case.
With has-user-setup true (the test-DB default) the old gate
(or (not has-user-setup) superuser?) was already false for that request,
so the test passed against the vulnerable code and distinguished nothing.
- misc: rewrite the SETTING SITE-URL block comment and the maybe-set-site-url
docstring, which still described the removed behavior (site-url set on the
initial setup request).
- system settings: drop the blank line in the site-url :doc (it made the new
paragraph render as an indented code block in the generated env-var docs)
and replace an em-dash with a comma.
* Read a card's parameters the way the rest of the code does
`card-param-constraints` read `:parameters` alone, but a native card can be
described entirely by its template tags and carry none -- which is how the
static embedding e2e fixture builds one. The locked slug then matched no
parameter and the lookup was refused. Use the same either/or source
`get-param-or-throw` uses a few lines above.
A parameter with no value constrains nothing whether or not it still
exists, so the existence check now runs after the empty-value guard rather
than before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQMWmi2o1WGSqK5DDZ99u
* Fix jar startup crash when the jar's directory contains a space
find-in-current-jar built a jar: URL by string-concatenating
get-jar-path, but get-jar-path returns the *decoded* filesystem path
(URL -> URI -> .getPath turns %20 back into a literal space). URL.toURI
then throws URISyntaxException on the raw space, killing startup while
loading bundled driver manifests.
Open the jar via the Path-based nio-fs helper instead — Paths don't
round-trip through URI encoding, so spaces are fine. This also stops
closing a filesystem out from under other consumers: the old code, on
FileSystemAlreadyExistsException, grabbed the provider's *cached*
filesystem and closed it in with-open; the Path-based overload creates
an independent uncached instance whose close affects only itself.
Verified against a jar in a spaced directory: the manifest URI is found
and slurpable, and a concurrently-open URI-cached filesystem for the
same jar stays open and registered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fpja4n6bzdCy4QufM49Hb
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexander Polyankin <alexander.polyankin@metabase.com>
* Don't crash the modules require hook on dynamic requires
(requiring-resolve (:failing-test-var opts)) has no statically-known
namespace; unwrap-require now returns nil for such forms instead of
falling through, and both callers skip non-symbols. dev.clj has carried
a live :hook crash from this since the hook was added.
* Add a hook regression test for dynamic requiring-resolve
Covers the crash fix: dynamic arguments lint to nothing, static
cross-module violations still report. hooks.* test namespaces join the
outside-the-module-system prefix list.
* Fail closed on dynamic module requires
* Polish module require hook tests
* Assert module hook findings directly
* Document intentional dynamic module requires
* Update module linter ratchet
* Add a manual kondo ratchet conflict merger
* Split kondo ratchet merge tests
* Document the ratchet conflict merger
* Simplify unlimited ratchet merge policy
* Simplify ratchet merger for final policy schema
* Resolve ratchet conflicts to the stricter policy
Concurrent policy changes now always resolve: the smaller budget wins, a
finite budget beats :unlimited, and a removed entry beats any budget.
Comment exemptions merge on their own. Every stage is read through
read-ratchets and shape-checked before a disabled stage decides the result.
bin/merge-kondo-ratchets reads the index stages by number, so a file
deleted on one side and changed on the other exits nonzero with nothing
staged, and both-sides-add merges against an empty base. It runs from any
directory in the repository and stages only .clj-kondo/ratchets.edn.
mage.merge-kondo-ratchets-test drives the script against real conflicts
in temporary repositories.
* Point the clojure-write skill at the ratchet merge resolver
* Reject malformed stages before merging ratchets
The shape checks move out of read-ratchets into validate-policies, and merge-ratchets runs them on every stage, so a stage with :config-counts [] or :comment-exempt [] is an error rather than an empty set of policies that the merge would read as removals.
* Cover unreadable stages in the merger tests
An empty file, two forms, or a non-map stage now fails the reader instead of merging as no policies; the script leaves such a conflict for a human and stages nothing.
* Reject a non-map ratchet stage before merging
A nil stage passed every field check because dissoc, keys, and the defaults all accept nil, so a malformed stage could merge as an empty set of policies.
* Reject non-keyword linter names
The merge keys on (str linter) and renders the same way, so a string, nil or
symbol name was rewritten as a keyword, collided with one, or produced a file
that no longer read back -- and the script still exited 0 saying it had staged
the merge. Also describe the two sides by git stage: "current" and "incoming"
are inverted during a rebase.
* Run each refused merge once in the tests
leaves-unresolved? and output each ran the script, so every refusal ran it twice
and the "changed nothing" assertion covered a different run from the one whose
message was checked. Read the merged policies with the EDN reader while here.
* Compare the conflicted file, not just its status
A conflicted path reports UU, DU or UD however its contents are rewritten, so
comparing git status alone did not show that a refused run left the file alone.
* Tighten merge-tool docstrings and the ratchet conflict docs
* Compare the index stages of a refused merge too
Porcelain status reports a conflicted path as DU/UU whatever its stages hold, so it alone does not show
that a refused run left the conflict alone. The stages carry each side's blob.
* Spell out the unmerged status codes
UU/DU/UD as bare shorthand assumes the reader knows git's porcelain codes for a conflicted path.
* Clarify the unmerged-state snapshot comment
* Add trusted master ratchet shrinker
* Retry ratchet shrink after concurrent master pushes
* Test the check-kondo-ratchets report
* Apply ratchet policy to the check report
* Add severity-based inline ratchet policies
* Update ratchet policy documentation
* Finalize ratchet policy enforcement
* Move seeded linters to bounded ratchets
* Simplify unlimited ratchet policies
* Finish ratchet CI integration
* Document unlimited ratchet policy values
* Keep ignore counts as the policy map
* Validate ratchet policy keys against known linters
Policy keys in .clj-kondo/ratchets.edn must name a clj-kondo built-in from the
pinned version, a linter configured under .clj-kondo/, or an allowlisted
external diagnostic. The check and the fixer fail with one error listing every
unknown name instead of counting it. Config budgets must be non-negative
integers.
Drop :unresolved-require, a linter that never existed, from the semantic-search
ignore and the ratchets file.
* Keep unlimited ratchet policies with no ignores left
An :unlimited entry in :ignore-counts records a decision about the linter, so
the fixer no longer drops it when its count reaches zero. The check and the
fixer print one sorted warning naming such entries; it never fails CI. Bounded
zero budgets are still dropped, and :comment-exempt is unchanged.
* Deliver ratchet shrinking through an automation PR
* Validate seeds and read only tracked kondo configs
The known-linter set now comes from git-tracked `.edn` files under
`.clj-kondo`, so the dependency configs `mage kondo` copies in locally
(gitignored, absent from a clean checkout) no longer make validation
depend on the machine. `--seed` is checked against the same set before
anything is written, and a hand-written bounded `0` with no ignores
left is reported when the fixer drops it.
* Reopen the ratchet automation PR after a close
The "already carries this result" skip now requires an open PR, so a
branch left behind by a PR closed without merging is overwritten and
gets a fresh PR instead of silencing every later run. The compare step
reads PR state with the default token under `pull-requests: read`.
* Let the ratchet automation PR approve and merge itself
Copies the backport recipe: github-actions[bot] approves the PR with the default token (a user cannot approve their own PR, and the automation user owns it), then the automation user enables squash auto-merge. The commit identity is the docs generator's, which merges with that one approval. A ratchets-only change no longer flips backend_all, so the PR waits on the Project tests job alone.
* Describe the self-merging ratchet automation PR
* Expect the ratchet check namespace in the project-test suites
* Read the ratchets file as exactly one map
An empty file, a non-map, or a trailing second form used to read as an empty policy set, which the merger would have staged as the removal of every budget. Tracked kondo configs missing from the worktree (unstaged deletions, sparse checkouts) are skipped instead of failing the scan.
* Skip the uberjar for a ratchets-only change
* Keep fork PRs away from the ratchet automation steps
The head-branch lookup matched any repository's branch of that name, so a fork PR could have reached the approval and auto-merge steps; only an open PR from this repository counts now. Approval and auto-merge key on the open PR rather than on whether the branch was pushed, so a run that failed after opening the PR is repaired by the next one.
* Look up the ratchet automation PR by owner-qualified head
gh pr list caps at 30 results and filtered forks client-side, so enough fork PRs on the same branch name could hide the real automation PR and make the create step fail. The REST head=owner:branch filter is applied server-side and matches only this repository's branch.
* Stop checkout credentials from overriding the automation push token
actions/checkout persists GITHUB_TOKEN as an Authorization header, which takes precedence over the token embedded in the push URL, so the force-push to the automation branch would be rejected.
* Tolerate config budget and exemption reductions in CI
CI already let inline counts fall below their budgets so the shrinker could record them after merge, but a lowered config budget or a stale comment exemption still needed an exact match, so two PRs that each removed a suppression could fail the second merge. Config counts now fail CI only when they grow, stale exemptions never fail CI, and check-kondo-ratchets reports config growth alongside inline growth. Local runs stay exact.
* Say what CI rejects without listing the cases
* Keep actionable ratchets bounded
* Exclude the ratchets file with one pattern
Path filters are OR-ed, so a separate "!.clj-kondo/ratchets.edn" entry matched
every path that was not that file: backend_all became true for every PR, and
no_source_code_changes could never be true. The exclusion has to live inside the
pattern, as modules/drivers does above.
* Arm auto-merge against a known commit
The approve and merge steps keyed on the PR number alone, so nothing had checked
what the fixed automation branch name currently holds. Both paths now record the
sha they pushed or compared, and auto-merge matches it. Also guard the job to
this repository, as the workflow it replaces did.
* Report ratchet validation failures without a stack dump
check read and validated the file, then read it again. Now it parses once and
catches inside, so read-ratchets, validate-linters! and scan all report the same
one-line message. Also: CI=false was truthy in the tests, seeding a recorded
linter with no ignores said "nothing to seed" while dropping its policy, and
lowered-counts rebuilt the seeded set per entry.
* Keep the drift message off stdout
The drifted path prints its findings and then exits; folding it into fail! added
a summary line that check-reports-empty-unlimited-test pins. Split the throw from
the print so only messages the run has not already shown are printed.
* Tighten ratchet docstrings and the generated header
* Guard against standalone negations in the path filters
The patterns in a filter are OR-ed, so a "!..." entry matches every path the other entries don't
instead of subtracting from them, and the filter is then always on. The ratchets exclusion is written
inside one pattern for that reason; this keeps the next one from being written the other way.
* Reuse the automation PR on its ratchet file alone
The extra check that master had not touched ratchets.edn since the branch was cut only bites when master
moved to a different value that shrinks back to the same result, since any change to the result is already
caught by comparing the file. Dropping it means only a shallow fetch of the branch tip is needed.
* Force-push the automation PR when it conflicts
A PR whose ratchet file already matches is otherwise left alone, and a conflict is the one thing that
state cannot clear on its own. Mergeability is computed lazily, so UNKNOWN is polled past and, if it
never resolves, left for the next run.
* Ask once whether the automation PR conflicts
Polling for a definitive answer bought nothing: the next push to master runs this again within the hour,
so a mergeability check still in flight can read as not conflicted and be settled then.
* Say what the reuse comparison is against
git diff FETCH_HEAD -- <path> reads as a commit-to-commit diff. It is against the working tree, which is
the only reason comparing the branch tip answers whether the PR still delivers the right file.
* Fix the circular Filter type alias
Record<string, Filter> is expanded eagerly, so naming it inside Filter's own definition trips TS2456. An
inline index signature is deferred and holds the same shape.
* Prove a ratchets-only change skips the backend filters
* Run the path-filter specs when the filters change
* Use gh CLI to approve ratchet PRs
* Reuse toucan2's current connection in Quartz to prevent deadlocks
When code inside a with-transaction or with-connection block triggers a
Quartz operation, Quartz would check out a new connection from the pool.
If the pool is saturated, this deadlocks the thread. Now the
ConnectionProvider checks for a bound toucan2 *current-connectable* and
reuses it via a non-closeable proxy that suppresses close/commit/rollback/
setAutoCommit, preventing interference with the outer transaction.
Closes UXW-307
* Address review comments
verify that asking for a connection while one is already bound via
t2.conn/*current-connectable* reuses the identical underlying connection
instead of checking out a new one. Plus fix an `if` that should be a
`when`.
* Revert Quartz connection-reuse proxy
Revert the non-closeable connection proxy that let Quartz reuse the
thread's bound toucan2 connection with close/commit/rollback/setAutoCommit
suppressed. Suppressing those methods breaks Quartz's data integrity:
JobStoreTX only releases its SELECT ... FOR UPDATE row locks on QRTZ_LOCKS
via commit/rollback, so the proxy either held the cluster lock for the
length of the outer transaction or, for autoCommit connections, defeated
the cluster mutex entirely, and made partial job-store writes
un-rollbackable.
The deadlock this was fixing (UXW-307) is addressed in the next commit by
giving Quartz its own connection pool instead.
* Give Quartz a dedicated app-db connection pool to prevent deadlocks
When code inside a with-transaction or with-connection block triggers a
Quartz operation, Quartz checks out a connection from the app-db pool. If
the pool is saturated, this deadlocks the thread (UXW-307). Fix it at the
root: Quartz now draws from its own small c3p0 pool, so job-store
operations never compete with application code for main-pool connections,
and Quartz keeps full control of its connections' transaction lifecycle.
- ApplicationDB now carries a quartz-data-source: a separate pool (max 5,
min 1, configurable via MB_QUARTZ_MAX_CONNECTION_POOL_SIZE) when
create-pool? is true, the raw data source otherwise. It inherits all the
main pool's properties except sizing.
- mdb.connection/quartz-data-source exposes it behind the ApplicationDB
read-write lock, re-exported via metabase.app-db.core.
- The Quartz ConnectionProvider draws from it; the tracing
connection-interceptor hook is unchanged.
- Tests cover provider behavior (no reuse of the bound connection, not even
wrapped), pool construction/sizing, lock-respecting acquisition, and a
deterministic pool-of-1 deadlock regression test.
* Reject already-pooled data-sources for the dedicated Quartz pool
connection-pool-data-source returns an already-pooled data-source
as-is, so building the Quartz pool from one would silently hand Quartz
the main pool itself -- reintroducing the starvation deadlock the
dedicated pool exists to prevent. No current caller does this (the only
production :create-pool? true site uses the always-unpooled env-derived
data-source), but nothing stopped a future caller from doing it.
Fail fast instead: quartz-connection-pool-data-source now throws on
pooled input, and application-db documents that :create-pool? true
requires an unpooled data-source.
---------
Co-authored-by: John Swanson <john.swanson@metabase.com>
Refresh the settings cache before reading LLM provider connections
Provider connections all live in the one llm-providers setting, and settings
are cached in process, so an instance only learns about another instance's
edit when its cache next polls — once a minute.
Until then it answers a page load with a list that predates the edit, and it
decides whether an edit is allowed against that same list: a connection
another instance has just deleted still looks present, and the type it
belonged to still reports itself as connected, so a legitimate create is
refused.
Force the staleness check on the five endpoints that read the list. It costs
the one query that reads settings-last-updated, unless something has actually
changed.
This does not make the read-modify-write of the list atomic: two instances
editing different connections at the same moment still lose one of the two
edits. That needs a lock, and is left for its own change.
With a driver bound, [:inline "!"] compiles through driver-specific
inline-value; MySQL emits the hex literal _utf8mb4 X'21', whose implicit
collation (utf8mb4's default, utf8mb4_0900_ai_ci) clashes with the
connection collation when MySQL aggregates collations for LIKE, failing
with 'Illegal mix of collations'. ::h2x/literal compiles through its own
registered formatter to a plain '!' on every dialect, which takes the
connection collation like the pattern parameter next to it.
* Don't lose the last change to a setting when a save fails
* Drop a duplicated test title
* Drop the no-op catch on the write chain
* Replace the write queue with a pessimistic disabled state
* Extend the failed-save revert to select and radio inputs
* Enforce single-statement impersonated queries on all sql-jdbc drivers (SEC-1189)
Connection impersonation confines a non-admin's native queries to a restricted
DB role, which Metabase activates with a `SET ROLE` prefix. To stop a user from
appending `; SET ROLE <broader>` and climbing out of that role, impersonated
native queries must be a single statement. That guard
(`driver/validate-impersonated-query`) was only overridden for `:postgres` and
`:sqlserver`; every other impersonation-capable JDBC driver (MySQL, Snowflake,
ClickHouse, Starburst) inherited the no-op `:default` pass-through and ran the
stacked query verbatim. On MySQL a stacked `; SET ROLE ALL` activates every role
granted to the connection user, escaping the restricted role entirely.
Define `validate-impersonated-query` once at the `:sql-jdbc` floor so every
impersonation-supporting driver enforces the single-statement validation, and
remove the now-redundant per-driver `:postgres`/`:sqlserver` overrides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XM9HwBRFsa3ptVAP7UWV14
* clean up
---------
Co-authored-by: Claude <noreply@anthropic.com>
* improve card type usage
* fix a few more Card type casts
* use same gate for visualize another way button as in getVisualizerInitialState so button doesn't silently do nothing
* Escape LIKE metacharacters in user-supplied search patterns (SEC-847)
Name the LIKE escape character explicitly so the escaping also holds on app DBs without a default one
Add h2x/like-substring and h2x/like-prefix and use them at every search sink
* Update in-place search filter tests for escaped LIKE patterns
Grant native create-queries explicitly in transform API tests
The transform API tests grant :perms/transforms explicitly but rely on the ambient db-level create-queries default (:query-builder-and-native) for the shared dataset DB.
That default is fragile: the native level only exists db-level, so any table-level create-queries row written for the group granularizes it down to :query-builder, and re-coalescing can never bring the native level back.
A target table synced by an async transform run during another test's snapshot-restore window leaves exactly such a row, and full-database-permission-for-user takes the most restrictive value across the DB.
When that happens, every test creating or running a native-query transform as a non-superuser 403s for the rest of the run while the MBQL-source tests keep passing (seen on the SQL Server 2022 driver job).
Replace the bare transforms grant with with-transform-db-perms!, which also grants db-level create-queries :query-builder-and-native.
A db-level set deletes every row of that perm type - leaked table rows included - so the grant repairs any polluted state for the test and the scoped restore puts the prior rows back afterwards.
Admins can turn on fast mode for Metabot when the selected Anthropic model offers it. A rejected fast request falls back to standard speed and holds it for five minutes, so the speed and the cache prefix stay put.
4.2.1 is published for eight more of the Hive pins in the hive-like driver,
so move them along with hive-common rather than leaving the family split
across two patch versions.
hive-jdbc, hive-service and hive-hplsql stay at 4.2.0 -- no 4.2.1 was
published for those three.
* Add a lint check requiring job-level timeout-minutes in workflows
A job without `timeout-minutes` inherits GitHub's 6-hour default, so a hung
job burns six hours of runner time - and holds up everything queued behind it -
before anyone notices.
actionlint cannot enforce this: its rule set is compiled into the binary, and
`.github/actionlint.yaml` only accepts `self-hosted-runner`, `config-variables`,
and `paths`, the last of which suppresses findings rather than adding them. So
the rule lives in a small yq-based script instead.
It runs in the lint job next to actionlint, gated on the same workflow-file
filter and passed the same changed-file list, and emits `::error file=,line=::`
annotations. Jobs that call a reusable workflow are skipped, since GitHub
rejects `timeout-minutes` there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Fold the actionlint path filter into the workflows filter
The `actionlint` filter matched exactly the same paths as `workflows`, and the
lint job was the only consumer of it. Drop the duplicate and point the actionlint
steps at `workflows` / `workflows_files`.
Also give lint.yml's own snowplow job a 10 minute timeout, so the workflow
passes the check it adds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Stop yq shifting reported line numbers on files with header comments
yq slurps a leading comment block before parsing unless
--header-preprocess=false, so every line number it reported was short by the
size of that block. Five workflows start with a comment; on cache-warm.yml the
`warm` job was annotated at line 18 instead of 42.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Update .github/file-paths.yaml
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
`parse-approved-origins` runs on essentially every API response (via
`approved-origin?` in the security-headers middleware, and again in the MCP
origin check) and re-parses the whole CORS-origins allowlist each time. Any
entry that fails to parse -- e.g. a malformed `http://localhost:3000:*` saved
into the SDK/MCP origins setting -- hits `log/errorf "Invalid URL"` on every
request, producing bursts of identical errors around each dashboard load.
The allowlist strings are slow-moving globals, so LRU-cache the parse. Callers
pass a couple of distinct strings (the merged SDK+MCP allowlist, and the
MCP-only one), so a small LRU bound keeps both cached without either evicting
the other, while remaining bounded.
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fail closed when an OAuth token's client is gone
resolve-access-token looked up the access token, checked expiry, and read the
user id off the token row — but never consulted oauth_client. So an access
token kept authenticating even if the client had been deleted.
Require the token's client_id to still exist in oauth_client, using the
client_id already present in the fetched token data — one indexed lookup on the
bearer path, no schema change. A missing client now fails closed (401).
Tests:
- core-test: token resolves while its client exists, returns nil once the
client row is deleted.
- session_oauth_test: register the referenced oauth_client so the bearer-bridge
tests exercise the new client-existence check (previously they minted tokens
against a client_id with no row).
* Revoke sessions and OAuth tokens on user deactivation
Add an `:event/user-credentials-revoked` event, emitted in the user
`before-update` hook when a user is deactivated, with two subscribers:
- session: delete the user's core_session rows
- oauth-server: mark the user's access and refresh tokens revoked_at
* Pin that an API key's lifecycle follows its synthetic user
An API key authenticates as its own synthetic user (api_key.user_id); the
creator_id column that records the admin who minted it is attribution only and
is never consulted at auth time. So deactivating that admin does not revoke the
key — which is the intended design: keys are org-level service credentials,
visible to and revocable by any admin from the API-key page, independent of who
created them. Revoke a key by deactivating its synthetic user
(DELETE /api/api-key/:id), not the human who minted it.
No behavior change. Add a regression test that pins this: a key survives its
creator's deactivation (200) but is revoked when its own synthetic user is
deactivated (401), so the two paths cannot be conflated by a future creator_id
join. The DELETE-endpoint revocation path is already covered by api-keys-work-e2e.
* Register the credentials-revoked topic in the events hierarchy
The events system validates topics against its own hierarchy in
metabase.events.hierarchy, not Clojure's global one, so the plain
derive calls left :event/user-credentials-revoked unregistered and
publish-event! threw a 500 whenever a user (or API key's synthetic
user) was deactivated. Use events/derive! like every other subscriber.
* filter lts versions when finding latest from tags
* always skip lts tag since it isn't valid for finding latest versions
* add guard for valid version to release-log
* add tests
* fix actionlint
* clean up/fix logic in release-log
* Keep returning the major version for rolling .x tags
getMajorVersionFromRef now recognizes v0.56.x / v0.51.2.x style tags in
addition to valid version strings, so only lts and non-version tags get
an empty string back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* remove dimension anchored blocks from FE
* Remove dimension-anchored blocks from the backend
Research-plan groups are now always a metric sliced by dimensions; the
dimension-anchored shape (one dimension slicing several metrics) is gone:
- add_research_groups: drop anchor/dimension_id/metric_ids, require metric_id
- remove_from_research_plan: members lose metric_ids; dimension_ids required
- research-groups validation: metric-only, anchor dispatch removed
- blocks-tree: no :type on nodes; "By <dimension>" headings and the
same-name disambiguation machinery deleted
- BlockSelection/ExplorationBlockNode schemas lose :type; the
exploration_block.type column is no longer written (left in place,
nullable)
- explorations.selmer prompt rewritten for the single group shape
Legacy dimension-anchored rows keep rendering (as metric-headed blocks);
pinned by the new blocks_test regression test.
* more FE cleanup, fix tests
* Drop exploration_block.type column
---------
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Replace @cypress/skip-test with a local e2e helper
* Document --spec requirement in the e2e-test skill
* Tidy skip-test helper comments and skill doc wording
* Wait for the query to reach the reduce step before cancelling in cancel-test
cancel-test gave the query a fixed 100ms head start before future-cancel and
assumed it would be sleeping inside *reduce* by then. Only an interrupt that
lands inside *run*'s try posts ::cancel to the canceled channel, so whenever
pre-pipeline setup took longer than 100ms the future died silently during
setup and the test timed out waiting for the cancel message.
That made the test flaky under CI load and deterministically red when run in
isolation: on a fresh JVM the first query's setup takes seconds, so the
granular-rerun step (which re-runs only the failed test) could never pass it,
pinning any PR that flaked onto it. Synchronize on a promise delivered from
*reduce* instead of sleeping.
* Block on a latch instead of sleeping in *reduce*
The 1000ms sleep was a second timing assumption: the cancel had to arrive
within it or the reduce would complete and save the execution. Await a latch
nobody opens instead — the interrupt wakes it just like the sleep, with no
window to miss. The 30s timeout only bounds the worker thread if cancellation
is genuinely broken.
* Use a never-delivered promise and drop the dead *result* call
A promise is the same CountDownLatch under the hood (interrupt propagates
identically from deref) and matches the started promise next to it. The
*result* call after the block was unreachable on the happy path — the
interrupt throws out of the deref — and pointless on the timeout path, where
the test failed 28 seconds earlier.
* Collapse the port/val channel assertions into one
A timeout or closed channel takes nil, which fails against ::cancel on its
own — no need to name the winning port. Also fixes the doubled word in the
testing string.
* fix cancellation test cleanup race
* Clarify cancellation test scope
* Strengthen cancellation metadata assertion
* Polish cancellation test formatting
Clean up stale, contradictory, and unreachable guidance in Metabot prompts and skills
- Remove references to tools/params that never existed (`search_tables`, `search_transforms`, `get_report_details`, doc-search tool, `collection_id`/`database_id` on search, `update_dashboard` mutations, `is_library_member`, dead `source_tables` param, duplicate Slackbot tool)
- Align per-profile guidance with actual tool wiring: SQL-only profile has no notebook/metric/chart tools, Slackbot has no `read_resource`, `construct_notebook_query` schema differs by profile, SQL query-link behavior differs by tool
- Fix `read_resource`: pagination bug (server now returns a ready-to-use `next-page-uri` instead of broken `?page=N` guidance), curation-signal comparison, container/document listing
- Fix a Selmer `{% safe %}` vs `{% verbatim %}` bug that silently blanked all `{{#model_id}}` examples in the SQL profile prompt
- Document previously-missing required arguments across several tools (`create/edit/replace_sql_query`, `edit_chart`, time-clause `unit`, etc.)
- Fix `document_schema_collect`'s unreachable stop instruction and missing `database_id`
- Fix `list_available_fields` fallback for saved questions
- Add regression tests locking in the template-rendering and pagination fixes
* key guest embed tokens per instance instead of one shared slot
Concurrent guest StaticQuestion/StaticDashboard embeds under one
MetabaseProvider wrote into a single Redux rawToken slot, so the
last-mounted instance's token won for all of them. Key by a stable
per-mount instance id instead.
* refactor: name the guest token key mountId
instanceId read as the Metabase instance, and collided with
EnsureSingleInstance's own instanceId. Also simplify the two reducer
cases the fix rewrites, which Immer lets us assign into directly, and
drop a !mountId guard that currentToken already covers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk
* test: drop the multi-dashboard guest token spec
It passed with the whole fix reverted to master: asserting each endpoint
was called at least once is already true there, because every mount's
first render uses its own token prop and only the re-render collides. The
name also implied multiple StaticDashboards work, which they never have —
they share one global state.dashboard slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk
* test: cover a guest refresh that switches resource
Every existing refresh case reissues a token for the same resource, so
none of them notice which token the component ends up using — PR #81240
flipped that precedence and CI stayed green. Switching to a question that
takes different parameters is what makes the choice observable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkZ5rUUDeeWN9GU2wuEUHk
* fix: drop a guest mount's token when it unmounts
The iframe embed route re-keys its children when the token changes, so a
parent-side token update remounts the component with a fresh mountId.
Nothing removed the old entry, so Object.keys(...)[0] in
getOrRefreshGuestSession kept picking the dead mount and every request
was rewritten with its stale token. Once that token expired, the refresh
wrote back to the same dead key, so the map never recovered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016stkr2R8EEPxp5gbyqDjdR
* test: cover a guest remount with a new token
Mounts a guest question, re-keys it with a second token the way the
iframe route does, and asserts the query goes out with the new token.
Fails without the unmount cleanup: no request reaches the new token's
url at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016stkr2R8EEPxp5gbyqDjdR
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-user sandbox fingerprint (the advanced-FieldValues cache key, and
the sandbox component of data-access tokens) narrowed the hashed login
attributes to remappings whose dimension target resolved to a field of the
sandboxed table itself. But the query processor applies *every* attribute
remapping when it runs the sandboxed query, including ones targeting
columns of tables joined inside the sandbox card, or columns referenced by
name. Those remappings were silently dropped from the fingerprint, so two
users whose sandboxes differed only by such an attribute hashed to the
same field_values row and were served each other's cached values (and
could read each other's stored exploration results via the colliding
data-access token).
Fix: fingerprint every key in attribute_remappings, regardless of what
its target resolves to. This exactly mirrors the set of attributes the QP
consumes, keeps unrelated login-attribute changes from busting the cache,
and leaves hashes for own-table-only sandboxes (the common case)
unchanged. Existing colliding rows become unreachable under the new hash
and are reaped by the delete-expired-advanced-field-values sync job.
Fixes SEC-874.
* Small correctness and hardening fixes
A handful of independent, low-risk fixes bundled together — each too small to
ship on its own, each a behavior improvement that also closes a minor
hardening gap.
Reduce DB load / avoid unconditional work:
- Settings cache: a settings-last-updated cookie now triggers a cheap
single-row staleness check and reloads only if the cache is genuinely
behind, instead of unconditionally reloading the whole settings table.
Exposes restore-cache-if-needed! with a :force-check? option as the shared
"reload if actually stale" primitive the cookie path now uses.
Reduce log noise:
- CORS origin validation runs on every request's raw Origin header, so it now
parses that client-supplied value with the silent try-parse-url rather than
the variant that logs "Invalid URL" at ERROR — a malformed Origin no longer
fills the operator's error log. Parsing behavior is otherwise identical, so
no CORS allow/deny decision changes.
Correctness (case-insensitivity):
- forgot_password throttles on the lower-cased email, matching the
case-insensitive account lookup, so User@x and user@x share one bucket
rather than each getting a fresh allowance.
Add a missing throttle:
- PUT /api/user/:id/password throttles the old_password re-auth check, which
previously had none, bringing it in line with the other credential
endpoints.
* Set create queries permission explicitly for transforms tests
---------
Co-authored-by: dan sutton <dan@dpsutton.com>
* Withhold fingerprint statistics from row-restricted users in get_field_values/read_resource
Fingerprints were read from the shared Field row and refingerprinted on demand with no
restriction gating -- the same leak #436 fixed in llm/context.clj, left open here. Gate
both behind a fail-closed, per-database-batched row-restricted-table-ids check, resolving
each Field's owning table canonically rather than trusting caller-supplied metadata.
Also guard list_available_fields, viewing-context entity lookups, and typed-schemas
table/card selection against destination-database resources, and apply a missing
permission-filter-columns pass to card-details' FK targets.
* Widen LLM schema-context row-restriction handling to sandboxing and routing
row-restricted-user? only checked impersonation and failed open on a feature/license
lapse. Replace it with metabot's fail-closed row-restricted-table-ids, applied per table so
one restricted table doesn't blank out fingerprints for the whole response.
Also filter a table's own sandbox-restricted columns (previously only FK targets were),
constrain mentioned/FK-target tables to the requested database, add a database read-check,
and cap+log restricted-table field-values fetches per table.
* Cover destination-DB exclusion in typed schemas; quiet expected 404s in viewing context
Adds test coverage proving select-schema-cards/select-tables actually exclude
tables and cards backed by a destination (routed) database from typed-schema
generation -- the only guard on that path, previously unverified.
Also stops fetch-and-format logging every expected 404 (missing entity, or a
destination-db routing guard) at ERROR level; it now logs at DEBUG and still
falls back to the simple entity rendering, same as before.
* Quiet expected destination-DB 404s in get_metadata
safe-fetch was logging every fetch failure at error level, including
the routing-destination-database 404s from check-table-resource-database
/check-card-resource-database, which are an expected outcome rather
than a bug. Matches the debug-level treatment already given to the
same case in agent/user_context.clj.
* Style pass: trim design-rationale docstrings, shorten oversized fns, wrap long lines
- row-restricted-by-db: move the retry-ladder rationale out of the docstring and
onto the try/catch lines it explains.
- permission-filter-columns: extract column-table-id/field-visible?/caller-joined?/
referenced-field-visible?/column-visible? as top-level private fns instead of
closures, bringing the function back under ~20 lines.
- build-schema-context: extract drop-or-enrich-fingerprints.
- Wrap a handful of lines pushed past 120 chars by the with-cache nesting and new
test fixtures.
No behavior change; same 83 tests (315 assertions) still pass.
* Fail closed on unresolvable table IDs in row-restricted-table-ids
A table ID that couldn't be resolved to a Table row silently fell out
of the per-database restriction map and defaulted to unrestricted,
contradicting the fail-closed contract this function documents.
* Log restriction-probe failures in row-restricted-by-db
All three exception paths in row-restricted-by-db were silent, so an
impersonation/routing attribute failure left no trace of why every
fingerprint for a database fell back to the per-table restriction path.
* Handle unrecognized get_field_values data_source gracefully
mu/defn schemas aren't enforced in production, so an LLM-supplied
data_source could reach a case with no default clause and throw
uncaught, failing the whole tool call. Widen the case (and its schema)
to cover "question"/"report" like field-values already does, and give
both the permission-check case and field-values a graceful fallback
for anything else.
* Return an agent-facing 404 for a field-id with no resolvable table
check-column-table-perms! used api/check-404, which throws without
:agent-error?, so handle-agent-error rethrows it and fails the whole
tool call. This is reachable from ordinary stale result_metadata (a
dropped column, or serdes drift), not just a malicious input.
* Apply restricted-field-values cap after the eligibility filter
The per-table cap on restricted-table FieldValues fetches was applied
to raw columns before filtering to those that should have FieldValues,
so ineligible columns ahead of an eligible one in the raw list could
consume its slot in the budget and leave it without values. Filter
first, then cap -- grouping by the persisted Field's own table_id
while at it, consistent with the permission checks elsewhere in this
namespace.
* Move database read-check out of the extract-sources/generate-sql prelude
fetch-accessible-tables-with-columns required database read access
unconditionally, so /extract-sources hard-403'd even when a caller only
wanted its independent :card_ids behavior. generate-sql is the path
that needs the 403, so move the check there via build-schema-context
and leave get-tables-with-columns (extract-sources) to return an
empty result instead.
* Exclude destination-DB cards and fieldless models from answer-sources
get-metrics-and-models had no destination-database guard, so a card
backed by a destination (routed) database could still reach
list_available_data_sources -- the same gap already closed for
get_metadata. Also omit models whose :fields end up empty after
permission filtering (no view-data access to their table) instead of
listing them as data sources with nothing queryable.
* Treat API-key users consistently across user endpoints and attributes
API keys are backed by rows in core_user with type = 'api-key'. Most
/api/user endpoints already restrict themselves to personal users, but
a few didn't, and user attributes were resolved for all user types.
Align everything on the rule that only personal users are managed via
/api/user and only personal users carry attributes:
- GET /api/user/:id and PUT /api/user/:id now 404 for non-personal
users (API-key users and the internal user), matching the sibling
endpoints (password, reactivate, password-reset-url, delete, modal)
and the list endpoint's existing type = 'personal' filter. API keys
are managed via /api/api-key instead.
- EE PUT /api/mt/user/:id/attributes now 404s for non-personal users.
- user/add-attributes only resolves attributes (tenant + jwt + login)
for personal users; other types resolve to {}. Sandboxed queries run
by a non-personal user therefore report a missing user attribute
rather than reading attributes stored on the user row.
No migration: any pre-existing login_attributes on API-key rows are
left in place but no longer take effect. GET /api/user/current still
works for API-key authentication, with an unchanged response shape.
Scoped programmatic access as a first-class feature is tracked
separately in #72959.
* Provide a schema for `/api/user/current`
* Update kondo ratchet budgets
- :metabase/validate-defendpoint-has-response-schema 441 -> 440, since
GET /api/user/current now has a response schema.
- :discouraged-var 100 -> 101 for the mt/with-temp ignore in the new
sandboxing test: it creates a :model/User, which metadata providers
do not support, matching the exception documented in the linter
message and the file's existing ignores.
* CR resp - throw on missing `:type`
* Send finish-reason length for a Gemini MAX_TOKENS truncation
Translate Gemini finishReason values to AI SDK v5 finish reasons and add them to the `:usage` chunk, as the other
adapters do. `MAX_TOKENS` now reports `:finish-reason "length"` instead of an in-stream error part, so the agent loop
stops cleanly and the client can offer to continue.
* Add missing image-related stop-reasons to gemini provider
The image related stop reasons should happen in practice since we only do text generation, but add them for
completeness.
https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/rest/v1/GenerateContentResponse#FinishReason
* Add stop-reasons from the Gemini Developer API as well
https://github.com/metabase/metabase/pull/80959#discussion_r3867216474https://ai.google.dev/api/generate-content#FinishReason
* Review feedback: only add synthetic usage part for finish-reasons-without-error
* Review feedback: name the early-stop-without-error subset
* Deterministically gate remote-sync branch-switch test on its async boundaries
* Sign in before activating the token in the admin settings describe
* Wait for collection items responses before asserting synced content
* Reword test comments per writing conventions
* Trim comments and move timeouts to request/test-level options
* Remove test comments and restore waitForTask retries cap
* Fix H2 deadlock between the post-import search reindex and readers
* Additional cleanup
* Cover the in-task H2 reindex with a unit test
* Consolidate remote-sync e2e setup into a shared helper
* Support Anthropic partner models via the Google provider
Anthropic models are served by `streamRawPredict` with an Anthropic Messages body, so route them through the new
`metabase.metabot.self.google.raw-predict` namespace, which wraps the existing claude adapter. Connecting probes the
model's own `streamRawPredict` route with an empty body: Google resolves the credential, project, location and model
from the URL before Anthropic sees the body, so an Anthropic-shaped 400 validates all four without spending a tokens.
* Title-case the Gemini model display names
To match the convention for all other providers and models.
* Translate mid-stream failures on the Google provider
`google-raw` handed back the SSE reducible unwrapped, so a failure while the agent loop consumed it escaped the
adapter untranslated and surfaced as a raw 500. Wrap it in `core/reducible-with-api-errors`, like every other adapter.
* Carry the probed model in :learned-config
We already reported `:probed-model` as sibling of `:learned-config`. Nest it inside so that it gets saved with the
learned config so we can use it for subsequent connection probes for the Google provider. This tends to be a much
better guess for what model we should use for the connection test rather than just using the first model in the fixed
models list, since there is no guarantee that model is actually present in the currently configured location.
* Probe a Google connection against the model it serves
The listing path re-probed whichever model the fixed catalog names first, so a connection at a location serving Claude
but not Gemini 3.x saved fine and then reported itself broken the moment its cache entry expired. Probe the model the
connection is known to serve instead — the one Metabot is pointed at, or the `:probed-model` its last successful probe
recorded — and fall back to the catalog's first model only when it has neither.
* Start the edit form on the model the connection was probed against
Reopening a connection while Metabot points elsewhere reset the Model picker to the type default and submitted that as
the probe target, so a Google project whose location serves Anthropic partner models but not Gemini had a no-op edit
rejected against credentials that work. Fall back to the connection's own probed-model, when the catalog still offers
it, before the type default.
* Ensure the obfuscated-value? regex matches values ending in newline
Enable "dotall" mode for the obfuscated-value? regex to support obfuscated values where one of the last two chars is a
newline. For example, the contents of JSON service account key used for Google cloud auth or similar.
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html#DOTALL
* Review feedback: validate-google-model!
* Review feedback: use proposed-model in vllm list-models
* Review feedback: ensure new-value is not an obfuscated-value? in set-single-provider-setting!
* Add comment about `model->family` dispatch
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
---------
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
Allow embedding readable cards in documents without query-authoring perms (UXW-5037)
Saving a document clones every embedded existing card into a document-owned
card row. The clone path ran check-run-permissions-for-query on the cloned
query, which for a native query demands create-queries=query-builder-and-native
on the whole database. A user who could view (and run) an existing native
question therefore could not save a document embedding it, losing their work
with a vague 403.
That check is an authoring gate, but the embedding user is not authoring a
query: the query text comes from an existing card row they passed a read
check on -- the client supplies only a card ID, never SQL. Running the clone
is still gated by the usual runtime permission checks (card read perms,
result-metadata view-data, block perms), which are the same checks that gate
running the source card, so cloning grants no new runtime capability. This
mirrors POST /api/card/:id/copy, which likewise clones a readable card
without an authoring check.
Split the documents-API card creation helper into create-card! (still
checked; used for client-supplied draft cards) and clone-card! (used when
cloning embedded cards and when copying a document's cards). Authoring a new
query -- via draft cards or by editing a cloned card's query through
PUT /api/card/:id -- still requires the appropriate query permissions.
* Bump timeout to 60 minutes
* Run redshift GC in parallel, with 1 worker per cluster node
* Run snowflake GC with N=8 parallelism
* Record dataset census before GC sweep
* Satisfy the linter
* More consistently hide tenant users/groups
There were a few locations where we weren't correctly hiding the
existence of Tenant Groups such as "All Tenant Users" when the setting
was turned off. This also hides tenant *users* from the API surface.
* allow specifically showing tenant users
I realized that there was a possible regression in the last commit. We
were hiding tenant users across the board. This meant that a workflow
that was previously possible was not possible anymore:
- an admin turns off the tenant feature or disables tenants
- then the admin uses the API to reactivate some tenant users and turn
them into internal users.
This is probably super niche anyway but it seems nicer to allow `GET
/api/user` to show these users, IF you pass the
`tenancy=external&status=all` flags.
Then you can reactivate the user and detach them from the tenant.
This is all API only so it's probably academic, but figured I'd keep the
door open.
* fix tests
* fix e2e tests
* Move tenant tests to enterprise tenant test namespaces
* Address review feedback on tenant-group hiding
- Rewrite hidden-tenant-group-ids to intersect the input IDs with all
tenant-group PKs in memory instead of building an unbounded IN clause
- Return 404 from PUT/DELETE /api/permissions/group/:group-id for hidden
tenant groups, matching GET /api/permissions/group/:id
- Assert GET /permissions/graph/group/:id succeeds when use-tenants is on
- Remove duplicate create-tenant-group-requires-use-tenants-test
`dashboard/update-dashcards!` calls `(params/dashcards->param-field-ids
(t2/hydrate new-dashcards :card))` to compute the field IDs referenced by
parameter mappings. `t2/hydrate` is a no-op when `:card` is already present
on a dashcard, so if the client `PUT`s `/api/dashboard/:id` with a dashcard
that includes a nested `:card` (the standard shape from `GET`), that object
is forwarded verbatim into `param-target->field-id`.
That function validates its `card` arg against `:metabase.queries.schema/card`
(added Oct 2025), which requires keywordized metadata (`:type/Category`,
`:source/table-defaults`, ...). After JSON round-trip through
`cy.request`, those values are strings, so the request 500s with
`Invalid input: {:result_metadata [{:semantic_type [...]}]}`.
`dissoc :card` before the hydrate call forces a fresh DB select with
properly-typed metadata, keeping the on-demand-DB field-value update path
unchanged.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A handful of independent, low-risk fixes bundled together — each too small to
ship on its own, each a behavior improvement that also closes a minor
hardening gap.
Reduce DB load / avoid unconditional work:
- Settings cache: a settings-last-updated cookie now triggers a cheap
single-row staleness check and reloads only if the cache is genuinely
behind, instead of unconditionally reloading the whole settings table.
Exposes restore-cache-if-needed! with a :force-check? option as the shared
"reload if actually stale" primitive the cookie path now uses.
Reduce log noise:
- CORS origin validation runs on every request's raw Origin header, so it now
parses that client-supplied value with the silent try-parse-url rather than
the variant that logs "Invalid URL" at ERROR — a malformed Origin no longer
fills the operator's error log. Parsing behavior is otherwise identical, so
no CORS allow/deny decision changes.
Correctness (case-insensitivity):
- forgot_password throttles on the lower-cased email, matching the
case-insensitive account lookup, so User@x and user@x share one bucket
rather than each getting a fresh allowance.
Add a missing throttle:
- PUT /api/user/:id/password throttles the old_password re-auth check, which
previously had none, bringing it in line with the other credential
endpoints.
* Flush other executions before test. Divert other executions during test.
* Flush after closing the batch ingress path.
* Remove extraneous changes
* Remove more extraneous changes
* cleanup data warehouses nightly
* rename
* don't reinvent wheels
* simplify
* more
* cleanup
* updates
* Absorb failures at the redshift cluster level and at the driver level
* Extract old dataset name pattern
* Update ratchets
* Merge MB_REDSHIFT_TEST_HOSTS and MB_REDSHIFT_TEST_HOST when both are set
* Allow ad-hoc dispatch and report on outcomes of gc sweeps
* Don't run on forks
* Update ratchets
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ben Grabow <ben.grabow@gmail.com>
Make isRenderedWithinViewport retry until element settles in viewport
The command read getBoundingClientRect() once, so a caller asserting right
after a scroll-into-view action (focusing a recently moved dashboard filter)
could observe the element mid-scroll (top ~1647px vs 800px viewport) and fail.
Wrapping the geometry checks in .should() lets Cypress re-read the rect until
it lands within the viewport.
* fix: recognize lib/*-stripped pMBQL in ->legacy-mbql, restore edit-chart's query-id
->legacy-mbql gated pMBQL->legacy conversion on :lib/type alone, so a query that
lost its :lib/* keys (e.g. round-tripped through the frontend's viewing context)
fell through unconverted. The raw pMBQL then got embedded in /question# links,
crashing the frontend with "Stage 0 does not exist". Detect pMBQL structurally
(:lib/type or :stages) and normalize against the query schema before converting.
Also add the query-id edit-chart's result was missing, so extract-charts can
capture edited charts into conversation state and chart->xml stops rendering
an empty query-id attribute.
* fix: preserve edit-chart's query in memory, don't fail turn on link conversion
edit-chart's result set :query-id without :query, so extract-charts (which
requires both to fire, then rebuilds :queries from :query alone) overwrote
the chart's real queries with [nil] on the next update-memory call.
->legacy-mbql could also throw instead of degrading: normalizing a
:lib/*-stripped query mints fresh :lib/uuids that don't match positional
aggregation/expression refs elsewhere in the query (e.g. an order-by on the
query's own aggregation), failing conversion inside the streaming transducer
and taking down the whole agent turn instead of just producing a bad link.
Falls back to the raw query on conversion failure now.
* fix: document ->legacy-mbql's conversion-failure fallback
Docstring claimed conversion always succeeds after normalize, but the
try/catch fallback added earlier can still return raw pMBQL when a
stripped query's positional refs don't resolve.
* test: mark edit-chart-result-survives-update-memory-test ^:parallel
Doesn't write shared state, same as the test above it.
* fix: thread edit-chart-tool's resolved query into edit-chart
edit-chart-tool falls back to current-queries-state when a chart's own
:queries is empty, but edit-chart only checked charts-state, so its
result carried :query-id with a nil :query in that case -- the same
[nil]-in-memory bug fixed earlier, via a different path. Pass the
caller's resolved query through as a fallback.
* fix: capture viewing-context charts' query-id, don't clobber on edit
chart-config->chart never set :query_id, so a chart seeded from viewing
context rendered query-id="" on edit and was never captured into :charts
state by extract-charts. Reuse the context-item id already shared with
:chart_id.
That capture also collided with edit-chart-tool's own memory write for
the same chart-id: extract-charts fully replaced the entry, dropping
:image_base_64/:timeline_events/:chart_config the tool had just written.
Merge onto the existing entry instead.
* fix: separate ->legacy-mbql's docstring from its bug history
Docstring for ->legacy-mbql read as the change's rationale (BOT-1604,
the :stages detection story) rather than the function's contract.
Trim the docstring to what a caller needs -- normalizes MBQL 5 to
legacy, passes through unchanged on failure -- and move the "why"
for the :stages guard down to an inline comment next to the check.
Remove stale /* eslint-env node */ comments from frontend/build files
Flat config ignores eslint-env comments (ESLintEnvWarning per file, hard
errors as of ESLint v10), and they are redundant: eslint.config.mjs already
defines globals.node for frontend/build/**/*.js.
* respect default time dimensions from list of dimensions
* Curate a default time dimension on the time-facet test fixture
Master's #79409 stopped auto-assigning a default dimension to new
metrics, so the time-facet tests' fixture card no longer gets a default
time dimension from dimension auto-sync. Since this branch makes the
curated default authoritative for the time-facet variant (no fallback to
the query's temporal breakout), curate the default explicitly on the
fixture.
The earlier fix (#80388) made *queue declarations* reload-safe by guarding
them with a load-time ownership claim and replace-on-reregister semantics,
but left the *listener* registry non-idempotent: register-listener! threw
"Listener already registered" whenever the channel was already present in
the root *listeners* atom.
Fix mirrors the queue side exactly: def-listener! now claims its channel's
declaration site at load time (claim-listener-declaration! — same-ns
re-claims are no-ops so reloads stay legal, cross-ns claims throw), and
activates through register-declared-listener!, which replaces any existing
registration instead of throwing. Ad-hoc batch-listen! keeps its strict
throw-on-duplicate contract (double-listen-throws-test et al.), since
runtime registrations have no load-time claim protecting them.
* Encrypt subscription/alert recipient details; enforce allow-list on send
Encrypt at rest the `details` column of `notification_recipient` and
`pulse_channel`, which stores external email recipients for alerts and
dashboard subscriptions, so plaintext values are rejected on the strict
read when an encryption key is configured. Register both columns in
`encrypted-string-columns` so `encrypt-db`/`decrypt-db` and key rotation
re-encrypt them, and add a v58 migration that encrypts existing rows and
decrypts them on rollback.
Also enforce `subscription-allowed-domains` at send time (previously only
on write): the notification send path calls `validate-email-handlers!`
before delivery and throws if any external recipient is not permitted by
the allow-list. The exception is re-thrown with a generic message so
recipient addresses are never written to the logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Move the v58 pulse_channel encryption migration test into the v58 group
order-your-migration-tests-test requires migration tests to be grouped by
major version in ascending order; the new test had landed among the v48 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Fail the test when a rollback-only scope cannot roll back
Every top-level `with-temp` runs in a rollback-only transaction. If something
inside one commits -- DDL does that implicitly on H2 and MySQL, which also
destroys the savepoint -- the rows written up to that point are durable and
leak into every later test. Warning about it hides the leak until some
unrelated test counts rows and gets the wrong answer, and the six offending
sites found so far were caught exactly that way.
Throw instead when running tests. That covers every cause -- raw SQL DDL,
HoneySQL DDL, a savepoint lost to a deadlock -- where a guard on the SQL we
build would miss the raw statements `drop-table!` sends. Production keeps
warning and discarding what is still pending: there the transaction is a real
one that has already committed, and failing the request would not undo it.
It sucks!
We will need to put our own error handling in place to do Actually
Good retries for 429s, but it's not clear yet where the best place for
that would be, because we're not getting stack traces from the 429s currently.
* Write the metadata mirror from one listener, not 33 endpoint hookups
* Describe the hydration listener by what it does, not what it replaced
* Point the original_fields note at the listener, not onQueryStarted
* Pin the hydration listener to Api actions, and report a failed write
SEC-669: re-check read permission when listing bookmarks
GET /api/bookmark (bookmarks-for-user) filtered only on the target's
archived column and never re-checked read permission, so a bookmark kept
leaking a card/dashboard/collection/document/exploration's name,
description, and type after the user lost read access without the item
being archived (moved to a private collection, or a revoked/narrowed
collection grant).
Add a visible_collection_ids CTE (permissions only; archived filtering is
left to the existing where-conditions) and filter each bookmark branch by
collection visibility via visible-collection-filter-clause. Visibility is
evaluated for the passed user-id via a UserScope, so the function is
correct for any user, not only the current request's user.
* Redesign Radio
* Add Radio stories to loki filter
* disallow radio labelPosition
* use StoryBoard in radio stories
* add new story's snapshots
* Radio.Card: border, leftSection, migrate call sites
- card border 0.5px border-neutral-strong, none when disabled
- children disallowed, leftSection slot for icons
- EditUserStrategyModal + DataSegregationStrategyPicker on label/description
* update loki snapshots
* Icon color prop instead of css module in DataSegregationStrategyPicker
* remove extra div
* make the indicator in the radio card optional
* fixed radio size, no size prop
* radio card body via mantine text/stack
* forwardRef radio card
* radio card border: highlight when selected
* update loki snapshots
* drop size="xs" from radio call sites
* fix card growing on select, interactive card stories, optional checkbox card indicator
* skip interactive card stories in loki, update card snapshots
* Key cached session tokens on the app db
with-empty-h2-app-db! swaps in a different application database, and this cache was keyed on the username
alone. A token minted against the previous database names a session that does not exist in the new one, so
the request 401s, and the token minted inside leaks back out when the original is restored. The retry in
client-fn cleared the whole cache and tried again, which papered over it often enough to look like flake:
"Failed to authenticate X after three tries".
Key it on the app db as well, like every neighbouring cache does via memoize-for-application-db.
databases-list-can-upload-test and dismiss-spinner-test go from failing to passing; both were in the CI
failure list, the former across 12 jobs.
* Fix rollback-only app DB transaction semantics
* Reject rollback-only combinations that cannot roll back
- :nested-transaction-rule :ignore skips the savepoint :rollback-only needs, so the pair is rejected at any
depth rather than silently committing
- a throw out of the transaction body now rolls the connection back explicitly, since restoring autocommit
commits whatever is still open -- a requested rollback that failed was committing the work it had to discard
- the unsupported-options error names the options, and drops the "application database" framing that
misattributed failures on other java.sql.Connections
* Restore transaction state even when the savepoint rollback throws
The callback accumulators are discarded unconditionally at the top of rollback!, so leaving the state
restore behind a throwing .rollback left the tree half-unwound: an outer scope that caught the error and
committed would run its before-commit callbacks against the failed nested scope's state.
* Create test-data Databases outside the caller's transaction
Their ids are memoized for the whole app db, so the Database has to outlive whatever scope first asked for
one. Created inside a test's rollback-only with-temp it is rolled back while the memo keeps its id, and every
later lookup hands out an id for a row that never existed.
do-outside-transaction is the same detachment run-after-commit-callbacks! already did privately.
* Don't cache a personal collection id created inside a transaction
The cache's premise is that a Personal Collection cannot be deleted, so its id never changes. A rollback
breaks that: the collection goes away and the cached id points at a row that never existed, which reads as
a 403 on every later request that resolves the user's personal collection.
* Skip the test-database cache in a transaction rather than detaching
Detaching made the Database durable but took a second connection while the caller's transaction was still
open. When that transaction holds cluster lock rows -- data_permissions and search index setup both take
them -- the second connection waits on them until the lock acquisition times out.
Bypassing the cache keeps the work on the caller's connection, and matches how the personal collection id
cache handles the same problem. do-outside-transaction goes with it, having no other callers.
* Stop the outer commit when a requested rollback failed
The full rollback only runs when this call turned autocommit off, so for a nested scope or a caller-managed
connection a failed rollback! just propagated. Code between there and the outermost scope can catch it, and
the commit branch would then commit the writes the caller asked to discard.
Mark the tree instead, and refuse to commit it. Throwing rather than quietly rolling back: the caller asked
to commit and cannot, and dropping the outer work silently trades one bug for a worse one.
* Tolerate a savepoint an outermost rollback-only can no longer roll back to
A statement in the body can commit the transaction under us -- DDL does that implicitly on H2 and MySQL --
which invalidates the savepoint. Those writes are durable by then, so failing the scope reports a problem
nobody can act on while leaving the data exactly where it was.
At the outermost scope, discard whatever is still pending and log where it happened. Nested scopes still
poison the tree and throw, since there the enclosing work is pending and must not be committed.
* Note the option of rejecting DDL inside a savepoint-bearing transaction
* Never commit a transaction whose rollback failed
Four review passes flagged the same hole from different angles. Restoring autocommit commits whatever is
still pending, and after a rollback we were relying on to discard those writes, that is exactly the work
we were told to throw away. Track it and leave autocommit off instead, letting the pool roll the
connection back on check-in.
Two narrower routes to the same place:
- rollback! now marks the tree itself, so an exception-driven rollback that fails poisons it too. Before,
only a requested rollback did, and the error path could still reach a commit.
- the mark is rechecked after before-commit callbacks drain. A callback can catch a nested scope whose
rollback failed, which lands after the earlier check has already passed.
Two tests changed meaning as a result: a nested scope whose rollback throws now stops the outer scope
committing, so it no longer completes and its own after-commit callbacks no longer run. That is the point
-- its writes are still there.
Also adds the regression test for personal collection id caching, models autocommit honestly in the mock
rather than pinning it true, and records what the in-transaction test-Database lookup gives up.
* Build the test-data Database before with-temp opens its transaction
Created inside a rollback-only scope it never survives, so it is rebuilt for every test, and the ids
memoized in metabase.test.data.impl outlive it. Cards then get inserted against table ids for rows that no
longer exist: FK_REPORT_CARD_REF_TABLE_ID on a fresh app db.
Materialise it before the transaction opens, where it can commit. The table and field lookup maps get the
same in-transaction bypass the Database id already had, for the case where a dataset is first asked for
inside a scope anyway -- neither is enough on its own.
Reproduce with a throwaway app db, which is what CI has and a local one does not:
MB_DB_TYPE=h2 MB_DB_FILE=/tmp/fresh-$$ ./bin/test-agent :only '[metabase.queries.models.card-test]'
* Only pre-warm the dataset for the default driver
db-id resolves through the driver under test, so in a driver-test namespace the pre-warm reached for that
warehouse's dataset -- work a with-temp there may never need, against a warehouse that may not be up.
Driver failures went from 2 to 12 in the push that added it.
The FK problem it fixes is on the app-db side, for the default dataset, so confine it there. Best effort
even then: a dataset that cannot be built must not take an unrelated with-temp down with it.
* Evict temporary users from personal collection cache
* Evict the personal collection cache on the rollback, not on the user
Clearing the temp User's own entry misses the case that actually bites: a committed user's Personal
Collection is created lazily on its first permission check, which often lands inside somebody else's
with-temp. Nothing evicts it when that scope rolls back, and every later request for that user 403s.
Key off the with-temp boundary instead, and drop whatever it may have cached. Costs a re-lookup per user
afterwards; correct whichever user and whichever scope created the entry.
agent-api + activity-feed went from 21 failures to none.
* Skip the dataset pre-warm where the app db is deliberately empty
with-empty-h2-app-db! rebinds the app db, and the memo key includes its identifier, so the pre-warm missed
the cache and created a test-data Database inside a scope whose whole contract is emptiness.
databases-list-can-upload-test snapshots an empty Database set and saw two.
Bind *skip-dataset-prewarm?* there instead. Also drops the earlier :h2 restriction, which came from a bad
reading of two CI runs sampled at different completion stages: it could never have helped here, since those
helpers are H2, and it took the pre-warm away from driver tests that needed it.
Reproduce with a throwaway app db, which is what CI has and a local one does not:
MB_DB_TYPE=h2 ./bin/test-agent :only '[metabase.queries.models.card-test]'
* Register the cache eviction with a unique key
metabase.test.redefs already holds `:around :default` on do-with-temp*, and methodical overwrites methods
sharing a key rather than composing them. Whichever namespace loaded last would win, silently dropping
either this eviction or the rollback-only transaction wrapper. It happened to work; it should not depend on
load order.
Also notes what a failed connection-level discard leaves behind. Only a caller-supplied connection is
exposed: on one we own, the same flag stops autocommit being restored, so the writes are never committed.
It logs rather than throws because throwing from that catch would mask the exception being unwound, and
master never attempted the discard at all, so a caller-supplied connection is no worse off than before.
Signalling it properly would mean telling the caller some other way.
* Note that session tokens are cached without regard for transactions
The user ids directly above skip the memo inside a transaction, since users created in a `with-temp` are
discarded with it. The token cache has no equivalent guard, so a session rolled back with its scope leaves a
token behind and the request fails with "Failed to authenticate X after three tries". The retry in
`client-fn` hides it more often than not.
* Note that a rollback can leave a stale personal collection id cached
"A Personal Collection cannot be deleted" does not hold for a transaction that rolls back. Tests evict on
the with-temp boundary; production has no equivalent and keeps the id for the rest of the TTL. Accepting
that for now -- it predates honoring :rollback-only, and closing it properly wants an after-rollback hook
alongside do-before-commit/do-after-commit.
* Follow the created-globally rename in the token cache TODO
* Build the query for export inside the guard, not beside it
`:query_json` already returns nil for a card whose query cannot be exported, but `lib/query` was called in the
argument position, outside `try-export-query`. A stored `dataset_query` with no stages builds a query that fails
its own output schema, so one such Card takes down every response that walks a set of Cards -- the typed-schemas
endpoint among them.
metabase.search.appdb.scoring-test alongside metabase.typed-schemas-rest.api-test goes from 13 failures to 0.
* Don't run a test request inside a rollback-only transaction
`user-request` takes a temporary Session when it is handed a User or an id rather than a `:rasta`-style keyword,
and the handler runs in-process on this thread. Now that the with-temp rolls back for real, every app db write
the request made is discarded when it returns -- the warehouse table writes survive on their own connection and
the API still answers 200, so the test sees a successful call that changed nothing.
Insert the Session and delete it in a `finally` instead. A caller already inside a transaction still gets the
same behaviour it always had; what goes away is the extra rollback scope around the request itself.
metabase-enterprise.action-v2.models.undo-test goes from 25 failures to 0, passing all 134 assertions, which
matches master.
* Mint the test users' sessions before anything opens a transaction
`authenticate!` ran about 150 times in a single `pulse.api.pulse-test` run, nearly always inside a transaction.
A session minted inside a `with-temp` is rolled back with the scope, the next request 401s, and the retry in
`client-fn` cleared the whole cache -- every user's token, not just the dead one -- so everyone re-authenticated
in-transaction and the cycle repeated. Each of those inserts bumps the user's `last_login` through the
`:model/Session` after-insert hook, which is what breaks a test that reads the creator before making its request.
Mint the four test users' sessions at initialization, where nothing has opened a transaction yet, so they are
durable. On a 401 forget only that user's token, so one temporary user's rolled-back session cannot evict them.
pulse.api.pulse-test goes from one failure to none, matching master. publish-refresh-error-event-sends-admin-email-test
passes too, where it fails standalone on master: that email only goes to admins with a `last_login`, and
crowberto's was rolled back with every login.
* Ask for the FieldValues the chain filter test needs
The full FieldValues row for `categories.name` is created lazily, and `update-field-values-for-table!` only
refreshes rows that already exist -- so a run that creates it inside a `with-temp` discards it and nothing
makes another. `select-one-pk` then returns nil and the test dies on "Not something with an ID".
Same shape as the rest of this branch: a durable row created on demand, now rolled back with the scope that
created it. Ask for the row instead of assuming an earlier test left one behind.
* Run the metric leaf queries one at a time inside a transaction
A transaction owns a single connection and `future` hands that binding to every thread it starts, so the
leaves' app db writes -- used-card stats behind a cluster lock, query rows -- interleave on one session.
Savepoints are per session: a thread rolling back to its own destroys the ones its siblings are holding, the
next release fails with "savepoint does not exist", and the transaction aborts, taking the rest of the test
with it. That is why api-arithmetic-test loses a different test on each CI run.
Only tests get here. A request runs in no transaction, where each future takes its own pooled connection and
the parallelism is worth having.
On a throwaway Postgres the namespace errors on two tests per run before this and passes three runs out of
three after.
* Commit a lock row that would otherwise roll back with the caller
A lock row is written once and read forever after, and acquisition leans on that: rival first-time inserters
block on the winner's uncommitted unique-index entry and retry once it commits. Inside a `with-temp` that
commit never comes, so the row is gone again and the next acquisition replays the race -- on MariaDB until the
lock-wait timeout expires.
Insert it on a connection of our own when the caller was already in a transaction, which is the only case
where the row would not survive. Nothing in production takes a cluster lock from inside another transaction,
so this leaves that path alone -- worth keeping that way, since lock names are dynamic in places and rows that
used to roll back with a failed transaction would otherwise persist.
Reasoning rather than measurement; the TODO on the function says so and says what to do if it does not help.
* Bound the out-of-band insert on its own connection
`set-query-timeout!` returns false on MySQL running MariaDB Connector/J, and the fallback sets
`innodb_lock_wait_timeout` on the session -- but `acquire-lock-row!` sets it on the caller's connection, not
the one the insert opens. Blocked by a rival's uncommitted row, the insert would then wait out the server
default instead of the second it was given, on exactly the databases this is meant to help.
Give it the same treatment on its own connection. Also note in the docstring that the second connection is
only taken until the row exists, which is what bounds the cost of holding two at once.
* Resolve the venues id before counting queries
The cache is fine; the query the test counts is its own `(mt/id :venues)`. Inside a transaction the test-data
id lookup deliberately skips its memo, so the call lands in the window that is supposed to see none.
* Keep the pre-warm out of app dbs that are meant to be empty
`with-empty-h2-app-db!` already opts out of the dataset pre-warm; the serialization test dbs and the
schema-migration ones never did. A `with-temp` inside them materialised the test-data Database, and an extract
then saw a Database no assertion expects -- serialization.v2.load-test/database-test wanted one and found two.
* Do not insert a lock row out of band outside tests
The claim in "Commit a lock row that would otherwise roll back with the caller" was wrong: production does take
cluster locks inside another transaction. `load-from-h2!` inserts Databases, and so takes the batch-permissions
locks, inside one big load transaction. Our fresh connection then blocked on a next-key gap lock the caller's
own SELECT ... FOR UPDATE was holding, and a thread cannot outwait itself -- it timed out, retried, and failed.
That is what broke dump-to-h2, load-from-h2 and rotate-encryption-key on MariaDB.
Gate the out-of-band insert on `config/is-test?`, which is the only place a transaction may never commit, so
production goes back to exactly what it did before. Keep a fallback for the same shape inside tests, where
`load-from-h2!` can block on itself just as readily: on a lock-wait timeout, insert on the caller's connection.
* Stop three card tests leaving rows behind
`updating-model-query-does-not-shift-metadata-overrides-test` had no cleanup at all, and
`create-card-with-type-and-dataset-test` opened a plain `t2/with-transaction` where its siblings pass
`{:rollback-only true}` -- so both committed cards with a `{}` dataset_query that every later test walking the
card table then had to survive. That leak is older than this branch; honoring the rollback is what makes the
second fix work.
`get-series-for-card-permission-test` read crowberto's personal collection with `select-one-pk`, and a nil
collection_id puts the card in the root collection, which rasta can read -- so the test quietly asserted 403
against a card that was never private. Ask for the collection instead.
* Truncate the heartbeat fixture times to milliseconds
Postgres stores microseconds, so a timestamp ending .7369995 comes back a millisecond above its own
millis-truncation and the assertion misses by one.
* Tolerate a card whose query will not parse when finding related entities
Nothing at the schema level stops a stored `dataset_query` being empty, and such a card simply has no
context-bearing forms. Without the guard one of them takes down the related-entities computation, and with it
every x-ray, for everyone else. Same treatment `card-details` already gives an unexportable query.
* Give the end-to-end schema test a Database of its own
The test asserts exactly which questions, metrics and models fall inside the test-data Database's scope, which
holds only as long as no other namespace commits a Card against that Database. Two such leaks have been fixed
on this branch already and there is at least one more; the assertion cannot be made safe by chasing them,
because any test added later can break it again.
Run it against a copy of the Database instead, so nothing else can write there.
A throwaway namespace that commits one Card on the test-data Database ahead of it reproduces the CI failure
exactly (core_test.clj:147) and passes with this change.
* Recognise the MySQL lock-wait timeout in the in-band fallback
`lock-wait-timed-out?` knew about `SQLTimeoutException` and our own `::acquisition-timeout` marker, but not
ER_LOCK_WAIT_TIMEOUT itself. The server raises that on its own schedule, so on MySQL and MariaDB -- the
databases the fallback exists for -- it could arrive as a plain `SQLException` and the fallback would not fire.
Check the code through the cause chain.
* Take the revisions with the cards
`with-model-cleanup` deletes rows directly, which skips the Card hook that would have removed the revisions
this test's POST and two PUTs create. Name Revision in the cleanup as well.
* Truncate once, and align the chain filter's temp values
The second `truncate-to` could not do anything the first had not.
* Create the test users before the related tests race for them
`user->id` skips its memo inside a transaction and creates the users there instead, so on this branch they roll
back with the scope and the next test makes them again. These tests are parallel and every one of them takes a
`with-temp` Card whose creator defaults to rasta, so several threads insert the same user at once and all but
one time out on the row lock. The namespace goes from three errors to none.
Also covers the guard in `related/definition`: a Card whose stored query will not parse contributes no
context-bearing forms, and a genuinely similar Card still outranks it.
* Cover the card whose query will not build
The guard in `card-details` had no test: a Card with a stage-less stored query answers without `:query_json`,
and the Card beside it in the same response is unaffected.
* Cover the batch path and the lock-wait code
The first version of the card test called `get-table-details` once per card, which cannot see the failure it
claims to cover -- the bug was that one bad card killed the whole `cards-details` seq the others were in. Go
through that seq instead. Removing the guard turns the test red at the same malli output error CI was showing,
which the per-card version did not.
Also cover `lock-wait-timed-out?`: MySQL's ER_LOCK_WAIT_TIMEOUT on its own and inside a cause chain, and any
other error still propagating rather than being taken for a timeout.
* Create the temp search index before the transaction, not inside it
`with-temp-index-table` runs CREATE TABLE and DROP TABLE on the ambient connection, and on H2 and MySQL that
commits the transaction around it. Six tests opened it inside a `with-temp`, so the rollback had nothing left
to take back and every row they created leaked -- a Transform, some Cards and Collections, a published Table, a
Document -- to any later test that walks the app db.
That one leaked Transform is the whole of the serialization failures: a collection-scoped export includes every
Transform in the instance, so all four tests came back with one entity more than they expected.
Take the index table before the `with-temp` instead. The nested scope reuses it, so nothing else moves.
`metabase-enterprise.search.scoring-test` also had no fixtures at all, and fails on its own: the first lazy
`initialize :db` fires mid-test and recreates the H2 database underneath a test already writing index
bookkeeping.
The 133 namespaces that run before serialization.api-test in the Enterprise job reproduce the CI failures
exactly -- 5011 assertions, 5 failures -- and pass with this, at 4714 assertions.
* Say the cluster lock comments plainly
Lead with the point rather than building up to it, and drop the clause chains. No behaviour change.
The TODO above `clear-cached-session-tokens!` was stale: it asked for the `created-globally` treatment, which
landed earlier on this branch. Point at what is actually left instead -- initialization can still first happen
inside a `with-temp`, and then the sessions roll back with it.
* Keep the non-index assertions where the app db cannot index
`with-temp-index-table` is a `when`, so wrapping a whole test in it skips the whole test on MySQL and MariaDB.
Two of the tests moved in the last commit also assert things that need no index, and those assertions went
quiet there. Add a sibling that runs the body either way, and use it for those two.
`search.appdb.scoring-test` had no initialization fixture, the same gap its enterprise counterpart had: the
first lazy `initialize` can recreate the H2 db underneath a test already writing index bookkeeping.
* Sort the fixtures require
* Download through the client that retries a 401
`downloaded-card-filenames-test` passed a raw cached token straight to `client-full-response`, so it skipped
the retry every other test gets from `client-fn`. A session rolled back with the scope that made it then came
back here as a bare 401, which is what the BigQuery job has been failing on.
`user-http-request-full-response` still returns the headers the test reads.
* Improve rollback test isolation and comments
* Do not poison the transaction tree when the savepoint is already gone
MySQL and MariaDB roll back the whole transaction to break a deadlock, discarding every savepoint with it. The
ROLLBACK TO SAVEPOINT that follows fails with "SAVEPOINT does not exist", and marking the tree rollback-required
there is backwards: the server has already discarded the writes, so nothing is pending for an enclosing scope to
commit by mistake. A rollback-only scope whose DDL committed implicitly already gets this treatment.
The refusal message named a rollback-only transaction, but the ordinary error path sets the flag too.
Also validate :nested-transaction-rule with a throw. It is a public option, and `assert` compiles away under
`*assert*` false. Sorting the unsupported keys keeps the message stable across runs.
* Keep the test request's session out of the login hooks
:model/Session's after-insert hook publishes :event/user-login, which stamps User.last_login, and
:event/user-joined for a User that has never logged in. Deleting the Session afterwards undoes neither, so every
user-http-request left both behind for whatever ran next.
Write the row instead. created_at has to come along, since the model supplied it.
* Ask whether the app db can hold the index, not whether any engine can
supports-index? is true for any active engine. With semantic search on MySQL or MariaDB, the macro that exists to
run the body without an app-db index took the app-db branch and tried to create the table anyway.
* Say something when a stored query is dropped
Both guards swallowed every exception silently, so a Card whose query failed to build for an unrelated reason
looked exactly like one that has no query. try-export-query, right next to the Metabot guard, already warns.
Related entities logs at debug: it runs for every candidate Card while ranking.
* Commit the out-of-band lock row rather than assume autocommit
The docstring said the row was committed, but nothing committed it -- the insert relied on the pooled connection
arriving in autocommit, which the pool does not promise. The caller's SELECT cannot find the row otherwise.
* Let a failed dataset pre-warm be heard
Now that the pre-warm runs for every driver, a warehouse that is down fails it on every top-level with-temp. At
debug that is invisible on a normal run and resurfaces much later as a Database that does not exist. Warn once
per message.
Also note that mt/id costs a query inside a transaction, which is what a with-call-count window has to plan for.
* Polish prewarm comments
* Mark the tree rollback-required even when the savepoint vanished
A vanished savepoint does not mean nothing is pending. DDL committing implicitly, or a server breaking a deadlock,
ends the transaction and discards the writes so far -- but anything written afterwards sits in a fresh transaction
and is still pending. Skipping the flag there let an enclosing scope swallow the rollback error and commit exactly
those writes.
Say so at the point of the catch, since the reasoning does not survive a quick read.
* Gate every app-db index helper on app-db support
with-temp-index-table and with-appdb-search-if-available-otherwise-legacy still asked supports-index?, which is
true for any active engine. With semantic search on MySQL or MariaDB they ran app-db DDL the app DB cannot take,
including from scopes nested inside the fallback body that exists to avoid exactly that.
Cover the borrowed session too: it must leave last_login empty, and delete itself afterwards.
* Cover the out-of-band lock row against a connection in a transaction
Hands the dedicated connection over with autocommit off and checks the row survives the caller's rollback. Without
the explicit commit the insert dies with the connection, and acquisition then fails on the row it just wrote.
Runs on MySQL and PostgreSQL only; H2 takes an in-process lock and writes no row.
* Pin the vanished-savepoint case against the outer commit
A savepoint the server already discarded says nothing about what was written after it. The existing rollback tests
throw generic exceptions, so nothing stopped the SQLState 3B001 / MySQL 1305 exemption from coming back.
* Pin the engine in the otherwise-legacy fallback
Where the app DB cannot hold an index, the fallback took whatever default engine was active. Semantic can be
active there, and the bodies are written for an index the app DB does not have. Run them under legacy search, as
the macro's name says. with-legacy-search moves above its first use.
* Polish rollback and search comments
* Stop two remapping scopes deadlocking on MySQL and MariaDB
A remapping writes a Dimension for a Field the whole suite shares, and holds it until its scope ends. Two threads
doing that at once deadlock on the unique index over dimension.field_id: each holds the record lock the other's
uniqueness check wants, while asking for the insert-intention gap in front of it. MySQL and MariaDB pick a victim
and roll it back, which drops its savepoints, so the rollback that follows fails on a savepoint that is gone.
Serialise remapping scopes against each other. Tests that never remap keep running in parallel alongside them.
The helper already carried a TODO saying it was not thread-safe.
Reproduced on MariaDB 12.3 and confirmed on master, so this predates the branch. custom-values-test failed nearly
every run before, and three runs in a row are clean after. Verified on H2 as well.
* Serialise every temporary Dimension, not just remapping scopes
Remapping is not the only way a test writes a Dimension for a shared Field: card-test, card/metadata-test,
field-values-test and data-studio's table-test all create them directly, in parallel, on the Orders and Products
fields. Those still raced with the remapping scopes.
Move the lock to `do-with-temp*` for :model/Dimension so every site is covered, including ones added later.
`locking` is reentrant, so a `with-temp` holding several Dimensions still works.
* Take the search index out of six more with-temp transactions
Same fix as the earlier pass over the search tests, for the ones that pass a warehouse driver: their index scope
sits inside `with-temp`, so `drop-table!` runs DDL on the ambient connection. H2 and MySQL commit the transaction
to do it, which makes the temporary Transforms durable and destroys the savepoint. The rollback that follows can
no longer take them back, and it discards toucan's compensating deletes along with everything else still pending,
so the Transforms outlive the test.
Three tests later in the same namespace count transforms and saw the strays: root-collection-items-include-
transforms-test expected one and found four.
These were missed the first time because `mt/normal-drivers-with-feature :transforms/table` skips them on H2, so
only a driver run reaches them.
Reproduced with a MariaDB warehouse: transform-test went 3 failures to 0, and the four namespaces that failed
together now match master at 2532 assertions. H2 is unchanged at 109.
* Let the failed-rollback warning reach the console
The shared Console appender only accepts FATAL, so the warning naming a rollback-only scope that could not roll
back went to logs/test-log.json and nowhere a CI run gets read. It took hours to find six tests leaking rows
because of it; the warning had been firing the whole time.
Give that logger an appender that accepts WARN. The message carries the mb-test key, so it names the test that
broke its own isolation rather than the later one that trips over the rows.
* Hoist the search index scope above with-temp in two document search tests
Both tests opened `with-appdb-search-and-legacy-search` inside `mt/with-temp`.
That scope creates and drops the index table on the ambient connection, and on H2
and MySQL the DDL commits the transaction `with-temp` meant to roll back, so the
temp Documents leaked into later tests.
Found by the throw this branch adds: both showed up as errors at connection.clj:293
in the app DB test jobs.
* Serialize the reduced-fields card test
It holds a rollback-only savepoint across two `with-temp` Cards and an API call. Run in parallel, something else
commits the transaction that savepoint lives in, so both Cards are discarded and the downstream query reports its
source Card as missing.
Only MySQL surfaces it, and only under a full suite: the test passes on its own, and six local configurations could
not reproduce it. Confirmed by running the MySQL and MariaDB driver jobs with this single change -- all four green,
against four runs where they failed.
* Materialize the test users' Personal Collections before with-temp opens
`user->personal-collection` is a get-or-create and sits on the permissions path every API request takes. A
namespace that does not initialize `:test-users-personal-collections` creates Lucky's the first time a request
calculates permissions -- which happens inside a `with-temp` -- so the transaction rolls it back. Every later
request tries to create it again, and the parallel ones time out on each other's COLLECTION table lock:
GET /api/database expected a status code of 200, got 500.
Timeout trying to lock table; INSERT INTO COLLECTION (..., "Lucky Pigeon's Personal Collection", ...)
That is what failed `databases-list-include-saved-questions-test` on the Mongo, Redshift and Athena jobs -- the
empty database list was the 500, not a visibility problem.
Prewarm them next to the dataset, under the same skip flag, so helpers that need an empty app DB are unaffected.
This also closes the related hole where `:test-users` itself first initializes inside `with-temp`, since that
component initializes it.
Confirmed on the Mongo driver jobs: MongoDB Latest went from failing to green.
* Update the test-user session note now that initialization is prewarmed
* Ratchet config-level kondo suppressions too
:config-counts in ratchets.edn tracks, per linter, the :off switches and
:exclude entries in .clj-kondo/config.edn (top-level and per ns-group).
Only weakening counts: adding discouragements or turning a linter on is
free. Lowering works like the inline budgets; raising is a hand edit.
* Count every excluded item and :config-in-comment in the config ratchet
Map-shaped :exclude values with vector entries counted as one suppression
({compojure.core [GET DELETE POST PUT]} is 4, not 1), and the
:config-in-comment :off switch wasn't aggregated at all, so both could
grow without tripping CI. Reseeded.
* Count :config-in-call suppressions, and self-heal on config.edn
`:config-in-call` is a scoped `:linters` map like `:config-in-ns`, so a
linter switched off inside one was invisible to the ratchet. Both scopes
now fold into the same count.
The self-heal workflow only watched Clojure sources, which was right while
budgets came from inline ignores alone. Now that `.clj-kondo/config.edn`
feeds `:config-counts`, removing a suppression there can leave a budget
stale with no way to trigger the fix.
* Count scoped :off suppressions at any nesting depth
Linter scopes nest more than one level: :discouraged-java-method keys by
class and then by method. Counting only the first level meant an :off one
map further down waived a warning the ratchet never saw. No budget moves --
the repo's only two-deep entry is a :level :error discouragement.
* Regenerate kondo ignore budgets after restacking onto master
* Fix flaky document-comment split test by using realClick on hover-revealed button
The test clicked the hover-revealed "new thread" comment button of the
second paragraph via Cypress's synthetic .click(). After realHover() moved
the cursor to reveal the button, the synthetic click could act on the button
while its hover transition was unwinding, so the click did not reliably
trigger the route navigation that opens the comments sidebar — leaving
getSidebar() to time out.
Switch to .realClick(), which moves the real mouse onto the button (keeping
it visible/stable) and dispatches a real click that reliably fires the link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wait for document save to persist before opening comments sidebar
The split-paragraph test opened the comments sidebar immediately after the
Save button disappeared, but a successful save schedules a deferred
navigation back to /document/:id. Under network throttling that navigation
resolved after the sidebar route had opened, clobbering it, so the sidebar
never mounted and getSidebar timed out. Wait on the PUT request and the
"Document saved" toast so the post-save navigation settles first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Google SSO already requires the id-token email to be verified; the
generic OIDC provider (and slack-connect, which derives from it) did
not look at the claim at all.
Tokens with email_verified present and not true are now rejected. An
absent claim is still accepted: it is optional per OIDC Core and some
IdPs never send it.
* Fix: Support Google Cloud Universe Domain in BigQuery driver
* feat(bigquery): Add universe domain to connection properties UI
* feat(bigquery): improve universe domain helper text
* Update bigquery_cloud_sdk.clj
Indeed we are out of sync as the impersonating was removed from master branch.
* removed empty comment
* Fixing formatting
Wait for the question move PUT to settle before navigating to the
affected dashboard, so its empty state is asserted only after the
dashcard has actually been removed.
* ensure highlighted bars are included in brush drill filter
* fix e2e
* fix e2e again, December 2026 label was not appearing maybe due to a screen size flake
The 'adding non-series columns to the tooltip' scatter test hovered a
bubble immediately after render. The ExplicitSize throttle re-renders the
chart once its container is measured, moving bubbles out from under the
realHover() before the tooltip fires, so [data-testid=echarts-tooltip]
was intermittently never found (4000ms timeout).
Apply the same view-toggle stabilization the sibling tooltip test already
uses (extracted as stabilizeScatterChart) before each hover, forcing a
re-render against an already-measured container.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Require a justification comment above inline ignores, ratcheted per linter
An ignore of a linter outside :comment-exempt needs a substantive ;;
comment on the line above or trailing on the same line (bare ;; dividers
don't count, and no attempt is made to check the comment is about the
ignore). ~18% of ignores have one today, so 35 linters start exempt.
The set only shrinks: once a linter's last uncommented ignore gains a
comment, the fixer drops its exemption and CI holds the line.
* Locate trailing justification comments in masked source
A semicolon inside a string or character literal on the ignore's line
counted as a trailing comment, so an unjustified ignore could pass.
The masker now keeps the ; that starts a comment (and blanks the char
after a backslash), so justified? finds only real comment starts.
* Share one tree scan across the ratchet tests
Each ratchet test walked and slurped the whole source tree; a delay
runs the scan once for all three.
* Invalidate the shared tree scan per test run
The namespace-level delay cached the scan for the JVM's lifetime, so a
long-lived REPL compared fresh ratchet-file contents with stale counts.
The :once fixture now resets the cache; a directly-run test var scans
fresh.
* Scope the shared tree scan to the fixture's lifetime
The atom cache survived a run, so a later directly-invoked test var
(which skips :once fixtures) reused a stale scan. The cache now holds a
delay only while the fixture is live; without it every call scans fresh.
* Require canonical syntax for kondo ignores
Keep ignore detection deliberately lexical and fail closed: every real marker must use the key-first map form, otherwise the ratchet rejects it with an actionable error. This avoids maintaining a partial Clojure reader for the two attr maps that previously placed the ignore behind another key.
Justification comments must be substantive and either directly above the ignore or trailing on its line. Authenticate comment openers in masked source so strings and code-line comments cannot pose as justifications.
* Harden kondo ignore scanning edge cases
* Use reader delimiters for kondo marker boundaries
* Match Clojure reader whitespace in kondo markers
* Reserve clj-kondo namespaced map syntax
* Harden kondo scanner diagnostics
* Rebuild the budgets and exemptions on the named-linter base
Rebased onto the ignore-naming branch, so every budget is recomputed from
the tree and :comment-exempt is reseeded with exactly the linters that
still have an uncommented ignore: 38, up from 35. The three extra are
linters that were hidden inside bare `#_:clj-kondo/ignore` forms until
they were spelled out; :all itself drops out, having no ignores left.
* Point at :comment-exempt after inserting ignores for a new linter
kondo-insert-ignores writes comment-less ignores, which the justification
ratchet rejects for non-exempt linters.
* Leave ignores preceded by a # for a human, and gate the exemption advice
A `#` immediately before the matched form is ambiguous: in `#^{...}` it is
legacy metadata that removal must take with it, in `x#^{...}` it closes a
syntax-quote gensym that removal must leave alone. Telling them apart means
reading Clojure, which this scanner deliberately does not do -- so report
the site under :skipped, as it already does for prefixless maps and
unbalanced braces, and let a human excise it.
Separately, kondo-insert-ignores no longer advises a :comment-exempt entry
unconditionally. An inserted ignore lands under whatever comment already sat
above the flagged form, so a linter whose sites are all justified would get
an exemption that no-stale-exemptions-test then rejects.
* Align the scan-test expectations
The occurrence vector's :justified? column and the counts map both drifted
out of alignment as linters were added to the fixture.
* Regenerate kondo ignore budgets after restacking onto master
* Make every kondo ignore name the linters it suppresses
The vector-less `#_:clj-kondo/ignore` suppresses every linter on the next
form, so a site added to silence one warning quietly absorbs any other that
appears there later. Replace all 50 with explicit linter vectors.
The budgets absorb what the bare forms were hiding: :discouraged-var
106 -> 129, :unresolved-namespace 3 -> 9, :type-mismatch 2 -> 6,
:unresolved-symbol 4 -> 9, plus first entries for :consistent-alias,
:duplicate-require and :redundant-do. :all drops out entirely, so a new
bare ignore now fails the ratchet with no budget to land in.
* Use the mdb.env alias in app-db-as-data-warehouse
Var-quotes resolve namespace aliases, private vars included, so the
fully-qualified name was never needed. Dropping it retires the
:aliased-namespace-symbol ignore and the unused-alias warning the
workaround caused.
* Regenerate kondo ignore budgets after restacking onto master
* Add primary/secondary NavLink variants and sibling components (GDGT-2469)
Restyle the design-system NavLink from Figma:
- Add `primary` (brand-fill selected) and `secondary` (subtle gray selected)
variants, with Default/Hover/Pressed/Selected states.
- Add the `icon-{primary,secondary,disabled,brand}-inverse` color tokens.
- Add `NavLinkButton` and `NavLinkBadge` sibling components for the right
section; both invert automatically on a selected `primary` item (no prop),
the button also has its own hover/press and stops click propagation.
- Give NavLink a `leftIcon` prop so it renders and colors the leading icon
itself instead of styling the section slot.
- Add a two-variant state-matrix Storybook showcase (Loki-covered).
* Migrate NavLink call sites to primary/secondary variants (GDGT-2469)
- Admin nav: `admin-nav` -> `secondary`
- Entity picker: current level -> `primary`, other levels -> `secondary`;
search results -> `primary`
- Remove the now-unused `default`/`mb-light`/`admin-nav` variant CSS
* Revert NavLink `leftIcon` prop back to `leftSection` (GDGT-2469)
Drop the polymorphic NavLink wrapper and colour the leading icon via the
`.NavLinkSection[data-position="left"]` CSS overrides again.
* Update Loki Snapshots
* Cleanup
* Update Loki Snapshots
* Add Loki reference for the NavLink Overview story (GDGT-2469)
* Update Loki Snapshots
* Address NavLink review comments (GDGT-2469)
- Migrate the remaining `default` NavLink (embedding SimpleDataPicker) to
`primary`, restoring its selected styling
- Render NavLinkButton as a `<div role="button">` (valid inside a NavLink's
`<a>`) with keyboard activation, instead of a `<button>`
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Stop duplicating databricks test jobs.
In https://github.com/metabase/metabase/pull/57148/changes we added
support for databricks multi-catalog feature. In this PR the existing
databricks job was duplicated, so every databricks test ran twice. The
rationale for this (and more broadly the rationale and background for
the entire feature) was never explained.
The code which looks at the multi-catalog flag looks to be tested in
the metabase.driver.databricks-test test. So running the entire driver
suite all over again just with that one flag set differently looks to
be very wasteful.
We have been putting a lot of load on databricks recently with the
extreme levels of backporting we're doing, which has led to periods
during which 99% of databricks jobs time out. The fact that we're
running twice as many tests as we need to is surely a contributing
factor to this.
It would be even better if the multi-catalog functionality were tested
inside the main test suite instead of spun off into a separate job,
but I want to focus on getting it stable first.
Also we split databricks into 3 jobs, but since the bottleneck is in
the DB itself, this was counter-productive. I've brought it back to 2
jobs like the other cloud databases.
* Reject plaintext values in encrypted app-db columns via a strict encrypted read
Make the canonical encrypted read path strict: when MB_ENCRYPTION_SECRET_KEY is set,
a value that is not encrypted is rejected instead of trusted, so a value written
directly via SQL cannot stand in for a properly encrypted one. This relies on the
existing aes256-cbc-hmac-sha512 authentication of encrypted values.
- encryption/maybe-decrypt is now strict; the previous lenient behaviour is
maybe-decrypt-accepting-plaintext. Every existing lenient caller (settings,
secrets, key rotation, the encryption-check sentinel, migrations) uses the
accepting-plaintext variant explicitly.
- models.interface: encrypted-json-out / transform-encrypted-json are the only
(strict) variants. The explorations encrypted-text/EDN transforms are strict too.
- app-db.encryption: the rotation list (encrypted-columns) now also includes the
exploration_query_result columns, so key rotation and encrypt-db cover them.
- A v63 clojure migration (EncryptEncryptedJsonColumns) encrypts any plaintext
values left in the encrypted-json columns (e.g. the v58 credentials SQL backfill)
so strict reads succeed after upgrade.
Secrets and settings keep the lenient path for now and will be handled in follow-up PRs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Use explicit arities for maybe-decrypt / maybe-decrypt-accepting-plaintext
Replace the [& args] + argument-sniffing with plain [v] / [secret-key v] arities, which
is unambiguous (the value itself can be a byte array).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Make secrets strict; move credentials migration to v58; split decrypt by type
- Narrow the re-encryption migration to auth_identity.credentials only (the sole
column the v58 SQL backfill wrote as plaintext) and move it to v58 so it backports
to every affected release. Other encrypted columns are always written encrypted and
covered by encrypt-db, so they need no migration.
- transform-secret-value is now strict (secrets reject plaintext at rest); encrypt-db's
secret loop reads raw + lenient so the enable-encryption-later flow still works.
- Split the decrypt helpers by value type like maybe-encrypt/maybe-encrypt-bytes:
maybe-decrypt / maybe-decrypt-bytes (strict) and their -accepting-plaintext lenient
counterparts, with String/bytes type hints and explicit arities.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Drop the -accepting-plaintext decrypt variants; use strict + explicit try/catch
The lenient variants silently returned plaintext, which conflates "legacy plaintext
(fine)" with "corrupt/invalid" — and only the caller knows which. Notably the
encryption-check sentinel treated a plaintext UUID as :valid when it should be :invalid.
Each caller now uses the strict maybe-decrypt / maybe-decrypt-bytes and handles the
throw explicitly: the sentinel and the startup check treat a decrypt failure as
invalid; key rotation and the secret loop tolerate plaintext (guarding with
possibly-encrypted-*?); settings and migration reads fall back to the raw value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Remove now-unused metabase.test require in encryption-test
Left over after dropping the lenient-decrypt tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Reintroduce -accepting-plaintext decrypt variants for plaintext-tolerant callers
Bring back maybe-decrypt-accepting-plaintext / maybe-decrypt-bytes-accepting-plaintext so the
plaintext-tolerant callers (settings reads, migration reads, key rotation and the secret loop)
call them directly instead of wrapping the strict maybe-decrypt in try/catch.
The encryption-check sentinel and the startup key-check stay strict, but now express it as an
explicit possibly-encrypted-string? guard + lenient decrypt so a plaintext UUID still reads as
invalid (rather than being trusted) without a try/catch. The core app-db column transforms
(models.interface, exploration) remain strict.
Also apply review nit: maybe-blob->bytes uses cond->.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* encrypt-db: also re-encrypt secret-value BYTES columns (stored_result.result_data)
stored_result.result_data (like secret.value) is a mi/transform-secret-value byte column, read
strictly via maybe-decrypt-bytes. do-encryption only re-encrypted secret.value, so a result_data
written as plaintext before a key was set stayed plaintext, and once a key is set the strict read
throws (500 on GET /api/exploration/query/:id).
List both byte columns and loop over them. Rename encrypted-columns -> encrypted-string-columns and
add encrypted-bytes-columns, splitting the two lists by wire type (string vs bytes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address review: stream re-encryption; fail instead of double-encrypting undecryptable values
- `maybe-decrypt-accepting-plaintext` / `maybe-decrypt-bytes-accepting-plaintext` now throw when a value looks
encrypted but cannot be decrypted with the current key, instead of logging and returning it unchanged. Returning it
let the key-rotation path re-encrypt it into `encrypt_new(encrypt_old(x))`, permanently unrecoverable. They still
return genuinely-plaintext values as-is.
- Re-encryption streams rows via `reducible-select` (the string path and a new bytes helper) so a large column such as
`stored_result.result_data` is not held in memory all at once, and catches the strict decrypt to clear (clearable
columns) or abort rather than double-encrypting.
- Callers that must tolerate the throw handle it: the `encryption-check` sentinel and `check-encryption` treat a
decrypt failure as a wrong key; the Setting after-select degrades gracefully on read; `encrypted-json-out` (migration
reads) returns the raw value on failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix rotate-encryption-key test for the lenient Setting read
The Setting after-select degrades gracefully on an undecryptable value (returns the raw ciphertext) rather than
throwing, so reading a wrong-key setting yields ciphertext, not a thrown exception. Assert not= plaintext instead of
thrown? for that read; the strict Database/User columns still throw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Encrypt api_key hashes at rest (#80902)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Validation transpiled with `identify=True` which quotes every identifier and
freezes its case, so a bare `subtotal` on Snowflake stopped resolving to
`SUBTOTAL`. Keep the LLM's own quoting instead, and only quote reserved words,
folded first so quoting can't change what they match.
* Quote ClickHouse impersonation role as a single identifier
Stop splitting the role attribute on commas so the whole value is one
role identifier. This removes multi-role activation via a comma-separated
attribute.
* Escape backslashes when quoting the ClickHouse role identifier
A trailing backslash would otherwise escape the closing quote of the
role identifier. Escape backslashes before doubling interior quotes.
* Delete e2e tests tagged @skip
Removes 53 skipped blocks (86 test cases), the oldest of them skipped since
2020, plus the fixtures and helpers they left unreferenced. Three spec files
held nothing but skipped tests and are deleted outright.
The @skip tag itself still exists in the workflows and the matrix builder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop the @skip grep tag from the e2e workflows
No spec carries the tag anymore, so the exclusion is dead weight. With
`-@skip` gone the tagged-run expression no longer needs format() to append
it, and collapses to the input or the default tag set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* BOT-2010: Post Metabot's Slack answer as a markdown block
Channel answers went in an `mrkdwn` `section`: no table syntax, and a
3000-char cap that cut them short. They now go in a `markdown` block at 12000.
- `final-text-blocks` emits `{:type "markdown"}`, and the answer is elided to
`markdown-text-limit` (12000) rather than the section limit
- the prompt switches bold and strikethrough to `**text**` and `~~text~~`;
`*one*` renders italic and `~one~` does not render at all, so the old
dialect would have silently lost both. Link syntax is untouched --
`<url|label>` does render inside a `markdown` block (measured)
- the test harness models Slack's real rejection for each block type
(`invalid_blocks` for `section`, bare `msg_too_long` for `markdown`), and
charges the markdown budget across the payload as Slack documents it.
Without this the answer would no longer be checked by anything and the
limit assertions would pass vacuously
Verified against the live API: a `markdown` block takes exactly 12000 chars,
Slack expands one into several as it renders -- a heading becomes a `header`,
a rule a `divider`, a table a `table` -- and that expansion counts against
the 50-block ceiling, which is why the prompt still bans headings and rules.
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Restrict markdown link URI schemes in rendered notifications
Only http/https/mailto links and images survive markdown rendering for
email/Slack notifications; other schemes and unparseable URIs are
dropped instead of being passed through (or, for malformed URIs when a
site URL is set, throwing mid-render).
* Diff pulse recipients by id or email in the subscription-permission check
Key the added-recipients diff the same way pulse-channel keys
recipients (user recipients by :id, external ones by :email) so
external-address changes are covered by the check too.
* Allow inline base64 images in notification markdown
The app's markdown renderer accepts data:image/...;base64 URIs
(DATA_IMAGE_URI_PATTERN); let notification rendering accept the same
pattern so text-card images look the same in email.
* Cross-link DATA_IMAGE_URI_PATTERN to its backend counterpart
* Share the data-image URI pattern between backend and frontend
Lift the pattern into metabase.util.markdown.image (cljc), compile it
into the frontend bundle via shadow-cljs, and re-export it as
DATA_IMAGE_URI_PATTERN so both renderers accept the same inline images
by construction.
* Apply resolve-uri to Slack images too
* Render a dropped link-ref as its text alone instead of restoring brackets
* Compute the action model's write permission from the API, not the metadata mirror
* Read the embedding picker's source card from the API, not the metadata mirror
Clicking the database row in the TablePicker before its expand handler is
wired drops the click, so the schema fetch never fires and cy.wait("@getSchema")
times out with "No request ever occurred." Gate on the expand toggle's
aria-expanded state (collapsed -> click -> expanded) so the schema request
reliably fires before selecting the database — same pattern already proven in
the sibling publish/unpublish test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A transform's :query source is auth-checked against its source database but leaves out some entities named by the query. This checks those too by leveraging on QP's check-query-permissions*.
The shared verifyTablePreview helper opened the column-description
hovercard with a single one-shot realHover(). That hovercard opens on a
delayed openDelay timer, so a lone realHover can miss it: after
cy.wait("@dataset") the preview grid re-renders on a microtask and
Chrome v133+ headless hit-tests CDP mouse events differently, either of
which drops the hover and cancels the open timer. hovercard() then only
retries the query, never re-hovers, so the card stays permanently
absent for that attempt.
Replace realHover() with the re-queried synthetic mouseenter+mouseover
dispatch already proven in data-model-shared-1's local verifyTablePreview,
so the events land on the post-render DOM node.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* BOT-522: Seed the Slack agent loop with persisted conversation state
The Slack loop wrote `metabot_message.state` on every turn but always started
`run-agent-loop` with `:state {}`, so it never read back what it had persisted —
unlike the in-app path, which seeds from `conversation-state`.
This makes sure we feed the state back into the agent loop, filtering out errored messages.
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This feature was not following the bug-reporting-enabled gate on the BE previously, and we now enforce using the session user when making a non-anonymous report.
As this is a endpoint that has been in the wild for some time, we'll first report any non-compliance first while overriding with the desired behaviour of only using the session user instead of breaking the API by returning an error
* Read the permissions tree's databases from the API, not the metadata mirror
* Record each edit's database in permissions state instead of holding subscriptions
* Take each database field from the endpoint that returns it
Two e2e failures traced to the same cause: no single endpoint returns
everything the permissions tree reads.
GET /api/database is the only endpoint that hydrates router_user_attribute
(warehouses_rest/api.clj:361 and :501). Reading the database from
getDatabaseMetadata therefore dropped it, and the "(Database routing enabled)"
breadcrumb stopped rendering.
GET /api/database/:id/metadata is the only endpoint that returns tables. The
group page's database list passes listDatabases entries, which have none, so
hasPermissionValueInSubgraph found no children and the blocked-child
confirmation never opened.
The mirror had merged both, and held them for the session. getPermissionsDatabase
now merges them explicitly, taking router_user_attribute from the list and tables
from the metadata response. The tables are kept in permissions state rather than
read back from the request's cache entry, which is dropped as soon as the admin
navigates away from that database.
databases.unit.spec.ts pins the merge with fixtures shaped like the two real
responses, which is what the previous specs could not catch: they seeded one
complete database, making the two sources indistinguishable.
like cards, the actions table has a top level column for database
id. But we can use the canonical value on the action's query and let the
db value be for bulk lookups and as an index. But we make sure to keep
it in lockstep as we go.
* Replace legacy button CSS with Mantine buttons
* Do not click the disabled map update button in the e2e test
* Polish admin header and add-row wrapping
* Use static view-section paddings instead of clamp
* Refuse analytics-dev DB connection/creation unless analytics-dev-mode is on
An :is-audit-dev database is a handle onto the app-db; it carries no real
connection details of its own. Reusing the app-db datasource for it is only
ever valid while analytics dev mode is enabled. Previously the connection
router only *skipped* the datasource reuse when the mode was off, silently
falling through to build a credential-less pool against the wrong host
(surfacing in CI as `FATAL: role "runner" does not exist`).
Make the invariant explicit and fail loudly at every site:
- db->pooled-connection-spec throws for an :is-audit-dev DB when the mode is off
- can-connect? throws likewise instead of reporting the DB as connectable
- create-analytics-dev-database! refuses to create the DB when the mode is off
Tests create/connect these DBs, so they now run under analytics-dev-mode=true.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
* Update is-audit-dev routing test for the throw-on-invariant-violation behavior
The connection layer now throws for an :is-audit-dev database reached with
analytics-dev-mode off, rather than silently declining to route it to the app
DB. Update the test's dev-mode-off case to assert the throw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Deflake dependency broken list spec
visitBrokenDependencies did a bare cy.visit with no wait, so tests
immediately queried dynamically-loaded list items with only Cypress's
default 4s timeout - flaky when the breaking-dependencies API is slow.
Mirror the sibling visitUnreferencedEntities helper: intercept the
/api/ee/dependencies/graph/breaking request, wait on it, and assert the
list is visible before the test interacts with it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wait for the test's own broken entities before asserting
The prior deflake waited for the page's breaking-dependencies request, but
the underlying data still raced: setup gated on a bare count
(nodes.length >= 3), which the instance's pre-existing Usage Analytics
breaking dependencies satisfy before the test's transform-table break is
analyzed. The page then rendered only Usage Analytics rows and the test's
"Test Transform Table" / question / model were absent.
Gate setup on the specific expected entities by name instead of a count,
and align the breaking helper to include personal collections (matching its
sibling waitForUnreferencedEntities) so the personal-collection question is
visible to the check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* checkbox redesign
* small fix
* fix disabled checkbox visual size
* disallow checkbox labelPosition
* add new loki tests
* update loki snapshots
* fixup! update loki snapshots
* fix e2e specs relying on checkbox Icon-check/Icon-dash classes
* checkbox card border
add border-highlight token, use it for selected card
* disallow checkbox size
single 16px size; xs/sm were both 16px and md was unused
* disallow checkbox card children
* drop redundant role from checkbox icon
* checkbox card body via mantine text/stack
* forwardRef checkbox card
* drop border on disabled checkbox card
* drop disabled checkbox border ring
alpha border over alpha bg stacked into a visible ring
* hide unchecked card indicator icon
mantine hides indicator icon via color:transparent; .icon color override defeated it
* update colors loki snapshot
The java.time classes grouped under ::->absolute-datetime and ::->time were derived in
Clojure's global hierarchy, sharing one mutable namespace with model keywords, hook traits and
every other subsystem -- and registering concrete JDK classes there is especially easy to
collide with.
Move them into a hierarchy var private to this namespace, mutated through derive! -- the same
shape as metabase.events.hierarchy, kept in-file because add-type-info is private and is the
only reader.
The prefer-method calls against Object still apply: class-to-class isa? goes through Java
assignability rather than any keyword hierarchy, so Object continues to match every temporal
class regardless of which hierarchy is in use.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* better development against audit db
* Bump :discouraged-namespace kondo ratchet to 81
connection.clj now requires metabase.audit-app.core with a
:discouraged-namespace ignore (audit-db dev support), so the budget
needs +1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjQcjQx7NPGr3Rpf6QaG4b
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add hierarchy visualization utilities
* Normalize hierarchy visualization label newlines
* Preserve ordered hierarchy traversal in visualizations
* Format hierarchy utility docstrings
* Move hierarchy visualization utilities to dev
* Move hierarchy visualization tests alongside the dev namespace
Also sort nodes unreachable from a root, so malformed hierarchies still
render in a stable order, and mark the hierarchy tests parallel.
* Keep hierarchy visualization tests discoverable by the test runner
The runner skips `dev` directories and non-`metabase.*` namespaces, so a
`dev.*` test never runs. Follow `metabase.core.modules-test` and
`metabase.core.kondo-ratchet-test`, which test dev namespaces from a
`metabase.*` test namespace, and say so in the namespace docstring.
* List hierarchy nodes without drawing a tree
`nodes` returns every tag as a flat vector, leaves-to-roots by default so a node
precedes its parents, or alphabetically on request. Ordered hierarchies break ties
by derivation order and the rest fall back to `pr-str`, both of which `hierarchy->graph`
already worked out, so there is no separate code path for the two kinds.
A test pins the topological order to `ordered-hierarchy/sorted-tags` so the dev output
cannot drift from the order the runtime uses.
* Fix ordered hierarchy ordering and validation
* Format hierarchy utility docstrings
* Mark ordered hierarchy tests parallel
* Return nil from first-common-ancestor when tags share no ancestor
Passing a tag with no ancestors as the second argument made the lookup call nil as a
predicate, so disconnected roots and tags outside the hierarchy threw an NPE instead of
returning nil.
* Reject children declared after a tag's first occurrence in any basis
The check only ran for nested vectors, so a tag introduced in one basis could pick up
children in a later one. Hoisting it into derive-basis applies the same rule to every
top-level basis.
* Ratchet down kondo ignore budgets
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
The notification create/update endpoints let a handler attach an existing
ChannelTemplate by id, or carry an inline template that creates, overwrites,
or deletes one, but didn't apply the permission check used when writing a
ChannelTemplate directly. Gate any such template write on
mi/can-write? :model/ChannelTemplate, mirroring the existing inline-channel
check. Seeding and the pulse->notification migration go through the model
layer directly, so they're unaffected.
POST /api/action and PUT /api/action/:id now require the current user to
have ad-hoc native query permission on the target database when the
submitted dataset_query is native.
* Load the enterprise admin pages through plugin route slots
* Load the tenant collection pages through plugin route slots
* Resolve every plugin route slot in a test
* Name the chunks the plugin route slots load
Check revert perms against the table of the restored definition
For Segment and Measure reverts, table_id is re-derived from the
definition on update, so check write perms against the source table of
the restored revision's definition when it has one, falling back to the
revision's stored table_id otherwise.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Default undeclared endpoint params to an empty map schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Ignore unparsed request bodies instead of declaring them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Pass multipart params as the request body of multipart endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Declare query and route params read by dashboard and database endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Ignore the kebab-case linter for newly declared snake_case query params
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Anchor remote-sync conflict-modal test on the async conflict lifecycle
* Reword test comments per writing conventions
* Trim comments and move timeouts to request/test-level options
* Use navigation helper when navigating to the transforms page
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fail fast on terminal sync task statuses
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Fix detached-DOM flake in upload table deletion e2e test
Re-query the upload-tables-table container on each assertion instead of
caching it via within(). The table re-renders when getUploadTables
resolves, detaching the node within() captured, so a retrying query
against the stale subtree got stuck on an intermediate render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Appease the linter
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
* Key cached session tokens on the app db
with-empty-h2-app-db! swaps in a different application database, and this cache was keyed on the username
alone. A token minted against the previous database names a session that does not exist in the new one, so
the request 401s, and the token minted inside leaks back out when the original is restored. The retry in
client-fn cleared the whole cache and tried again, which papered over it often enough to look like flake:
"Failed to authenticate X after three tries".
Key it on the app db as well, like every neighbouring cache does via memoize-for-application-db.
databases-list-can-upload-test and dismiss-spinner-test go from failing to passing; both were in the CI
failure list, the former across 12 jobs.
* Track globally-created test users per application database
with-empty-h2-app-db! swaps in an empty app db, where the users another database created do not exist.
`created-globally?` was a boolean, so it stayed true across the swap and user->id kept handing back ids from
the previous database. Authenticating then inserted a Session for a user that was not there:
Failed to authenticate :crowberto after three tries: Referential integrity constraint violation:
FK_SESSION_REF_USER_ID: PUBLIC.CORE_SESSION FOREIGN KEY(USER_ID) REFERENCES PUBLIC.CORE_USER(ID) (1)
Track the set of app dbs that have them instead. The memo beside it was already keyed this way via
memoize-for-application-db; only the flag was not.
databases-list-can-upload-test failed 8/8 before this and 0/8 after; the whole namespace now passes.
* Say what the token cache holds
Update E2E test timings with averaged data
Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).
🤖 Generated automatically by update-e2e-timings workflow
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iethree <30528226+iethree@users.noreply.github.com>
Reject impersonation role attribute equal to the driver default-role sentinel
connection-impersonation-role now rejects a role attribute that (case-insensitively)
equals the driver's default-database-role sentinel (e.g. NONE / DEFAULT), which would
otherwise emit SET ROLE NONE and drop the connection to the base warehouse credential.
* Resolve card UI parameter fields from param_fields for saved cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Resolve parameters against the query for questions opened from a dashboard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Remove unused data from embed and public endpoints
Public and static-embed card/dashboard/document endpoints now return a
minimal placeholder dataset_query (a blank native query against the same
database) instead of selecting individual query keys. The frontend only
needs the database id here. Parameter combining and :param_fields
hydration in public-card now run before the card is trimmed since both
read template tags from the full query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add tests for embed card and dashboard dataset_query
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Assert strictly on dataset_query in public and embed tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Handle native queries in Table viz
* Fix column shadowing in HeaderCellWithColumnInfo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for public question parameters
Public and static-embed card payloads carry param_fields instead of the
real query, so PublicOrEmbeddedQuestion now builds UI parameters from
them via getCardUiParametersFromParamFields, mirroring the saved-mode
dashboard path. The query-based helpers are renamed to the FromQuery /
FromParamFields scheme on both the card and dashboard sides, and the
ParameterWithTarget casts are dropped now that getParameterTargetField
accepts an optional target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for embed setup card parameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use param_fields for guest embed SDK question parameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Route card UI parameters by param_fields presence
Restores getCardUiParameters as the entry point: it delegates to
getCardUiParametersFromParamFields when the card carries param_fields
(public and static-embed payloads) and to the query-based variant
otherwise. The SDK SQL parameter surfaces use it since they serve both
guest and authenticated contexts. Also reverts the dashboard helper
renames back to getSaved/getUnsavedDashboardUiParameters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix preview embed card param values
The preview-embed card values and remapping endpoints get their card via
card-for-unsigned-token -> public-card, which now replaces the query with
a blank native placeholder, so field-filter parameters could no longer be
resolved and the response schema rejected the nil result. Bind
qp.perms/*param-values-query* around the card fetch, the escape hatch
remove-card-non-public-columns already honors, matching the public
sharing values endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add preview embed test for remapped parameter fields
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix
* vscode fix
* Backfill template tag parameters in param_fields UI parameters
GET /api/card/:id returns parameters as stored, unlike the public and
embed endpoints which combine them with template tags, so
getCardUiParametersFromParamFields now derives parameters via
getParametersFromCard like the query-based variant always did. Guest
embed payloads are unaffected: their parameters arrive already combined
and the blank query derives nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use getCardUiParameters dispatcher in SDK load-question
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Guard applyTemplateTagParameters by param_fields presence
Cards whose payloads carry param_fields have parameters already combined
with template tags by the backend, so re-applying from the query would
wipe them. Keying the guard on the card payload keeps it in sync with
how the card was loaded, replacing the isGuestEmbed flag checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use getCardUiParameters everywhere and build test queries via Lib
Question.parameters and the query builder call sites go back to the
getCardUiParameters entry point, which now routes by param_fields
presence, leaving those files unchanged relative to master. The
template tag fallback test builds its query with
Lib.createTestJsNativeQuery instead of an inline legacy map.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Make getCardUiParameters the only entry point
The FromQuery and FromParamFields variants are private to cards.ts now;
all consumers call getCardUiParameters, which routes by param_fields
presence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Build preview embed remapped-fields test query via lib
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Restore null fallback in useAvailableParameters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix
* Preserve query type in stripped public and embed card queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add placeholder aggregations to stripped MBQL queries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep parameter template tags in stripped native queries and revert FE workarounds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Export parameter-template-tag? via import-vars
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add test for :param_fields hydration on GET /api/card/:id
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Clear All tenant users metabot permissions when switching to group-level mode
Switching metabot permissions to group-level only wiped the All Users magic group's rows.
Under tenants the simple mode also writes to All tenant users, and since resolution takes the
most permissive value across a user's groups, a leftover :yes there overrode every group-level :no.
POST /advanced now clears both magic groups; DELETE /advanced keeps both instead of dropping the
tenant toggle. The confirmation dialog names both groups when tenants are on.
Fixes#80394
* Backfill the tenant permission cleanup and make both mode-switch dialogs tenant-aware
Instances that switched to group-level mode before the fix still carry the seeded all-yes rows
for All tenant users, so a migration clears them wherever metabot-advanced-permissions is true.
The disable dialog now says both magic groups keep their access under tenants, and both dialogs
use the "All internal users" name the permissions UI shows when tenants are on.
* Explain why the tenant-permission migration only reads the setting row
* Move the tenant-permission migration to 063 for the single backport
* Move the tenant-permission migration to 061 so it backports to every affected release
* Scope metabot permission resolution to the groups the current mode shows
Resolution took the most permissive value across every group a user belongs to regardless of
mode, so any row on a group the admin UI hides -- the All Users and All tenant users rows in
group-level mode, the seeded data-analyst rows and any API-written group in simple mode --
silently overrode every visible :no. The write-site fixes (deleting rows on POST /advanced, the
backfill migration gated on the setting row) only covered the paths that went through the
endpoint; PUT /api/setting, serdes import and PUT / on a hidden group all bypassed them.
Filter in the resolver instead: simple mode only counts Administrators, All Users and All tenant
users; group-level mode counts everything but those two default groups. Hidden rows stay in the
table and come back into force on switching modes, so POST /advanced no longer deletes them and
DELETE /advanced genuinely keeps them, which also restores the simple-mode values after a round
trip instead of leaving the default groups at all-no. With leftover rows inert the migration is
no longer needed.
* Leave the mode-switch dialog copy as it was
* Refuse the metabot permission mode switch while an env var forces the setting
The setter writes the row regardless, but the env var keeps winning on read, so DELETE /advanced would
delete every group's rows and leave the instance in group-level mode with nobody but admins allowed in.
* Flip the metabot permission mode before dropping the group-level rows
Deleting first meant a failed setting write left group-level mode in place with its permissions already gone. In the new order a failed delete only leaves rows that simple mode ignores.
* Say in the docstrings that resolution only sees the active mode's groups
* Clear the default-group rows again when switching to group-level mode
Preserving them so they returned on a switch back was a silent widening: the 061 migration seeds every default group all-yes, so a round trip through group-level mode handed everyone AI access again, including the tenant rows that caused this bug.
Both mode-switch dialogs promise removal, and nothing in the docs describes a mode remembering its old configuration, so the modes now delete the rows the other one owns, as before. Resolution stays scoped to the active mode's groups: that is what makes rows created by other write paths, and the rows already sitting on instances in the field, harmless without a migration.
* Switch the metabot permission mode in one transaction
Ordering the two writes only chose which half-applied switch an admin got: the mode changed while the rows did not, or the rows went while the mode stayed. The setting write goes last inside the transaction, since it updates the settings cache in place and a rollback would leave the new value there.
* Reload the settings cache when a mode switch rolls back
The setting write updates the cache in memory, which the rollback does not undo, so a commit failure would leave this node serving a mode the database does not have.
* Cover the settings cache in the mode-switch rollback test
The redefined setter now writes the setting for real before throwing, so the test fails if the rollback leaves the new mode in the cache.
* Extract the mode's group list to one definition
Both the resolution query and the mode-switch endpoints had their own copy of the
Administrators / All Users / All tenant users list, and read as opposites: one
selected with :in, the other deleted with :not-in. They now share
visible-groups-clause, with hidden-groups-clause defined as its complement.
* Improve Metabot permissions documentation
* Address Metabot permissions review feedback
* initial deepseek support
* [BOT-1899] Port DeepSeek onto the LLM provider connection registry (#80100)
Wire DeepSeek into the LLM provider connection registry
DeepSeek arrived as an adapter reading its own credential settings, which is how
providers worked before the connection list. Make it a connection type like the
rest:
- `deepseek` joins `metabase.llm.provider`'s registry with the api-key and
base-url fields the admin connection form renders itself. No key prefix:
DeepSeek keys carry the same `sk-` an OpenAI key does, so matching on it would
claim OpenAI keys and reject nothing.
- `llm-deepseek-api-key` / `-api-base-url` become the type's single-provider
environment variables, reading and writing through the connection they
configure like every other provider's.
- The adapter takes `:credentials` off the request instead of reading settings,
so two DeepSeek connections can hold different keys.
- The frontend mirrors the new type name and gets a DeepSeek mark.
The app-db migration that folds the old per-provider settings into the
connection list is a frozen snapshot of the registry as it stood then, and stays
untouched: no released version wrote a DeepSeek setting, so there is nothing to
adopt, and a rollback drops the connection because the versions it rolls back to
cannot serve DeepSeek either. A test pins that.
Regenerated the settings docs, and listed DeepSeek among the providers you can
bring a key for.
* revert docs changes
* review feedback
---------
Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* Fix flaky native database source selectDatabase detach
The database picker popover opens automatically and re-mounts its rows as the database list finishes loading, which could detach the target row while Cypress waited for it to become actionable ("elements ... disappeared from the page"). Anchor the click in selectDatabase behind a visibility assertion so Cypress re-queries the popover chain on detach and clicks a freshly attached, settled row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Force-click the native database picker row to avoid detach
The database picker popover opens automatically and, as GET /api/database
resolves, its onQueryStarted hook re-writes shared metadata. That hands the
selector a fresh databases array reference and re-mounts the AccordionList rows.
The previous visibility-anchored click still entered Cypress's actionability
wait, during which the target row detached ("elements ... disappeared from the
page"). Click the row with { force: true } so Cypress fires the moment the row
is found and visible, skipping the actionability wait window in which the
re-mount can detach it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>
* BOT-1606: Truncate over-long Metabot answers instead of going silent
`channel/final-text-blocks` put the whole answer into a single Slack `section`
block. Slack caps a section's `text.text` at 3000 characters, so any longer
answer came back as `invalid_blocks` ("must be less than 3001 characters") and
the thread simply went quiet -- no answer, no error, nothing to retry.
Elide the answer at the limit, and when that actually cuts it, follow it with a
`context` block saying so and linking to the full answer in Metabase. The notice
is deliberately kept out of the message's `:text`, which `thread->history`
replays back to the model as the assistant's own words.
The answer stays a single mrkdwn `section` block, as before. Answers that already
fit are byte-for-byte unchanged: `u.str/elide` returns its input untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* BOT-1606: Address review comments on the truncation notice
Works through appleby's review nits on #80105:
- Docstring summary for `truncation-notice` is one sentence (continuation
aligned to 3 spaces, matching the rest of the file).
- The notice now names the limit, so a reader can judge how much to trim:
"This answer was longer than 3000 characters."
- Adopted the suggested "...and try again." ending.
- Added a debug log on the truncation path, recording the answer length
against the limit.
- Moved the shared `oversized-answer` fixture into `slackbot.test-util`;
`channel_test` and `api_test` had identical copies.
- `slack-section-text-limit` now derives from the production constant
rather than restating it, which makes the drift test redundant.
`log/debugf` evaluates its args only once the level is enabled, so the
debug log was verified through the REPL with log capture on rather than
through the suite, which cannot reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Cap the visualization title so Slack accepts the message
The truncation shipped so far only guarded the answer block. The error in
BOT-1606 names `/blocks/1`, which is the visualization title block, not the
answer: `format-viz-title` embeds a `/question#<base64-query>` link, and for
a large query that base64 alone runs past Slack's 3000-character section
limit, so `chat.postMessage` rejects the whole message.
Reproduced with a native query of the size Metabot routinely writes: a
7,119-character SQL string yields a 9,770-character link and a 9,820-character
title block, and Slack returns the reported error verbatim.
A cut URL is a broken URL, so drop the link rather than truncate it and keep
the title; with no title left, `viz-output->blocks` already falls back to
"Query results". `section-text-limit` becomes public for this -- streaming
already required the channel namespace, so no new dependency.
Both this and the answer truncation log at `info` rather than `debug`:
`metabase.slackbot` inherits INFO from log4j2.xml, so a debug line there is
invisible on a default instance. Link drops get their own counter rather than
reusing `responses-truncated`, which counts responses; a long answer and a
long query link are different problems and worth telling apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Fall back to plain text when Slack rejects the blocks
`send-channel-response` logged a failed `chat.postMessage` and stopped
there, so a user whose message Slack refused saw the "is doing science..."
status and then nothing. `send-dm-response` already retries with a
plain-text reply; do the same here, with the same wording.
A plain-text reply carries no blocks for Slack to reject, so it survives
whatever made the block message fail -- including causes we have not
anticipated and so cannot cap the way the answer and viz title are capped.
Add `responses-undeliverable`, incremented from both paths when the
fallback fails too. It counts only total delivery failure: a response the
fallback rescues is deliberately not counted, so the metric answers "how
often did a user get nothing at all", which is the alertable question.
Left the error-cleanup fallback in `send-dm-response` uncounted -- that
path is already reporting a thrown exception, and its fallback carries an
error notice rather than an answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
* BOT-1606: Cap alt_text, and stop miscounting title-only elisions
Follow-ups from a Clojure review of the branch.
Slack caps an image block's `alt_text` at 2000 characters, not the 3000 it
allows a section block, and `viz-output->blocks` passed the raw title
straight through. A title of 2001-3000 characters therefore cleared
`format-viz-title` untouched -- comfortably inside the section limit -- and
then failed `chat.postMessage` with `invalid_blocks`: the same silence this
branch exists to fix, reachable through the function it hardened. Cut the
title with `limit-chars` rather than `elide`, since alt text is plain and an
ellipsis would only eat into the budget.
`format-viz-title` also reported a dropped link whenever the rendered title
was over the limit, including when there was no link at all -- logging
`link_length=0` while claiming a drop, and incrementing `viz-links-dropped`
for something that never happened. Both cases still elide the title; only the
reporting now branches.
Docstrings: several first lines ran on into the next line, which
https://guide.clojure.style/#docstring-summary asks not to happen. Also
`format-viz-title` claimed to return nil only when given neither a title nor
a link, though it also returns nil for a link too long to keep -- as the
branch's own test asserts -- and `slack-section-text-limit` restated the
sentence from the var it aliases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ew4fCZUQC2fu4MZ7JamMLm
---------
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Fix flaky alerts toggle e2e: re-query preview iframe for Alerts click
The 'toggles alerts for question when email is set up' spec cached the
embed preview iframe body via within() and chained the Alerts button
visibility assertion straight into the click. Toggling the sidebar
options re-renders the embedded question, so the cached body and the
found ActionIcon detach mid-click ('the page updated while this command
was executing'). Re-query the iframe body fresh and split the visibility
assertion from the click so Cypress retries against a stable element.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Give re-render-dependent alerts lookups a longer timeout
Iteration 1 removed the mid-click detach by re-querying the preview
iframe body, and 49/50 burn-in runs passed. The residual failure is a
different flake: toggling a sidebar option re-renders (and re-runs the
query of) the embedded question inside the already-loaded iframe, and
that round-trip occasionally exceeds Cypress's default 4s command
timeout, so findByRole for the "Alerts" button times out with only a
"group" role present in the iframe body. Give the Alerts button and
alert-modal lookups that depend on a re-render a 15s budget so Cypress
waits the slow re-fetch+render out instead of asserting mid-render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
The "should update existing sandboxes when changing column selection"
test raced in two ways:
- It waited on @updatePermissionsGraph with the default 5s request
timeout, but the "Next" click first runs several sequential metadata
fetches (list tables, list databases, permissions-graph revision)
before issuing that PUT, so under load the request can appear later
than 5s and the wait times out. Give it a 15s timeout.
- It asserted H.undoToast().should("not.exist") with no positive anchor,
which can pass against an unrendered page. Anchor it on the positive
success signal ("Select data" step marked complete) first.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sébastien <sebastien@metabase.com>
* Add connection and read timeouts to python-runner requests
* Tighten timeout docstrings
* Cancel the runner on a client timeout, retry transient log-poll timeouts
Review follow-ups on the python-runner timeouts:
- a client-side timeout on /execute now fires /cancel, so the runner
stops working on a run nobody is waiting for
- a read timeout while polling /logs is retried instead of ending the
poll loop for the rest of the run
- use u/seconds->ms instead of inline math
PUT /api/notification/:id accepted a different id from what was in the URL which diverges from normal PUT behaviour. This fix aligns it with how we've handled PUT updates in this codebase by only handling allowlisted params for the update.
* Deflake SDK question data-picker target-collection test
The "change target collection to a different entity id" test blindly
pressed Escape then clicked "Pick your starting data" to open the data
picker. In the embedding SDK the DataStep always renders the data picker
and auto-opens it when no source table is selected, and that open state
settles asynchronously after the collection re-render. When the popover
auto-opened after the blind Escape, the follow-up click toggled it back
closed, so H.popover() never appeared and the Orders lookup timed out.
Wait until the popover is actually gone after Escape before reopening it
from a known-closed state, and use visibility anchors instead of exist,
removing the toggle race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Deflake SDK question data-picker target-collection test
The embedding data picker uses isInitiallyOpen={!table}; with questionId="new"
no source table is ever selected, so the picker auto-opens on every re-render and
stays open. The prior approach toggled it (esc to close, click to reopen), which
raced the async auto-open: sometimes the click toggled the just-opened popover
shut, sometimes esc failed to close it at all. Interact with the already-open
popover directly to remove the toggle race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Reaction types registered by defreaction derived from :metabot/registered-action in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move them into a hierarchy var private to this namespace, mutated through
derive! -- the same shape as metabase.events.hierarchy, kept in-file because defreaction is
the only writer and known-reaction-types the only reader.
This also narrows the schema-rebuild watch. It was attached to #'clojure.core/global-hierarchy,
so it woke on every derive anywhere in the application and compared descendant sets to decide
whether anything relevant had changed; now it only fires for reaction registrations.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy-cookie alias :search.engine/fulltext -> :search.engine/appdb lived in Clojure's
global hierarchy, sharing one mutable namespace with model keywords, hook traits and every
other subsystem. Move it into metabase.search.hierarchy, mirroring metabase.events.hierarchy:
a defonce var holding a hierarchy map, mutated through derive!.
All thirteen engine multimethods now dispatch on that hierarchy, and known-engine? walks it
rather than the global one.
The hierarchy has a single writer inside the search module, so it exposes only derive! and
ancestors -- the operations that are actually used.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The model persistence test clicked the global and per-database persistence
toggles without waiting for their requests to complete, then immediately
POSTed to persist a card. The card-persist endpoint returned 400
("Persisting models not enabled for database") when it raced ahead of the
per-database setting, and the per-database toggle could fail to render when
the database page booted from a stale global setting.
Intercept and wait on both persist-enable requests before proceeding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "unsubscribe from an alert when the user has not created it" test
clicked the Unsubscribe button immediately after a bare findByText for
the confirm modal's title. That query resolves the instant the title
mounts, while the Mantine modal is still animating in and its title
transiently covers the button's click point, so cy.click() intermittently
failed with "covered by another element".
Anchor on the title being visible first so the click is deferred until
the modal's entrance transition has settled.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-running mq init in a REPL (dev server startup plus test init, or a
retried :mq test-init step) threw for the exploration queues:
```
Queue :queue/exploration-plan is already registered with different config.
```
This is because of an attempt to catch cases where we were declaring
the same queue multiple times with different config. However, this was
subtly broken in two ways:
- first, there are two phases to queue registration: declaration, and
registration. Declaration just means "write down what queue X's config
is." Registration means actually executing that. Our check ran on
*registration*, not on declaration. So if there were two different
calls to `def-queue!` for the same queue, the first one would declare
its `def-queue*`, then the second would overwrite it, and there would
be no meaningful check left.
- second, doing registration twice would always throw, because some
queue configs contained functions, and declaring the function twice
resulted in non-equal results.
Instead, we want to just check that we're not *declaring* the same queue
multiple times in different namespaces. We allow declaring the same
queue twice in the same namespace for REPL convenience - technically a
loophole, but this is similar to a check we have for `defsetting`s and
it doesn't seem to have been a problem.
* Pin exploration auto-binning to an explicit bin width (UXW-4391)
Exploration charts fan one metric/dim query out into several
segment-filtered variants that share an x-axis. With {:strategy :default}
binning, the QP re-derives the bin width per variant from that variant's
own filters, so a segment that range-filters the binned column (e.g.
"Highly rated products", rating >= 4) got 0.125-wide bins while its
siblings got 0.75-wide bins.
Fix: resolve the width the QP would use for the unsegmented query
(card-level filters included, per-segment filter excluded) and pin it on
the breakout as {:strategy :bin-width}. The binning middleware never
renegotiates an explicit bin width, and bin edges snap to multiples of
the width, so every variant lands on the same bin grid. Card-level range
filters on the dim still narrow the bins, since they're shared by all
variants.
- move filters->field-map + extract-bounds from the binning middleware
into metabase.lib.binning.util (middleware delegates; extract-bounds
now returns nil and the middleware keeps the qp.error-type throw)
- add metabase.lib.binning/default-bin-width (exported via lib.core):
the width :default binning resolves for a column given the query's
own filters, nicer-breakout fixed point included
- pin it in metabase.explorations.query-plan.mbql/apply-default-bucket
* Address CR
- default-bin-width now keys its filter lookup by the column ref's own
id-or-name (like the QP binning middleware) instead of (or (:id column)
(:name column)) with a regression test for the bug this fixed.
- Build the segment :definition test fixtures through lib instead of the
mt/mbql-query macro
* Hide the empty dashcard menu on visualizer cards when downloads are not permitted
* Address review comments
* Type the dashcard menu's underlying-question items as an array and simplify code
* add copy value click action
* move details above filter
* move copy into it's own section
* fix import
* e2e adjustments
* Adding unit test for column settings
* Don't show copy action on PK columns
h2x/cast already guards cast targets: a plain type name is emitted raw,
anything else is quoted as an identifier. The MySQL and Postgres
json-query implementations bypassed that route and spliced the stored
field's database-type into SQL unvalidated (CONVERT(.., <raw>) and
::<raw> respectively).
Expose the plain-type-name predicate as h2x/raw-type-name? and use it:
Postgres quotes a non-plain type as an identifier (same rule as
h2x/cast); MySQL rejects it with an invalid-query error, since
CONVERT's target type cannot be a quoted identifier.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Tile endpoints resolve latField/lonField legacy refs via resolve-field;
a ref with :source-field (implicit join) now fails fast with a 400
instead of producing a broken query. The check lives in resolve-field,
which every tile endpoint (ad-hoc, card, dashcard, public, embed,
preview-embed) reaches through tiles-query.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Internal query-processor and permission keys (`:qp/...`, `:query-permissions/...`,
`:impersonation/...`, `:persisted-info/...`, `:source-query/...`) are added to a
query during preprocessing and drive permission enforcement, so a client must
not be able to supply them. Previously five separate stripper middlewares
removed them one key at a time.
Replace those with a single declarative rule: a qualified map key outside the
`:lib` namespace is an internal key and gets stripped when a query is
deserialized from JSON. The rule is a Malli `:decode/api` (and
`:encode/serialize`) transformer on the query/stage/join/clause-options schemas,
run via `lib.serialize/prepare-after-deserialization` at the JSON boundaries
(REST API request decode and app-DB read) and once as a preprocessing
middleware, `qp.perms/remove-internal-keys`, gated by `qp.pipeline/*pivot?*` so
pivot sub-query keys survive their re-run.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Improve API schemas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keywordize nested parameter option map keys
`::parameter-with-value` only keywordizes its top-level keys (via
`normalize-map-no-kebab-case`), so on the query-string parameter path -- where
the whole value arrives as a JSON string with string keys at every level -- the
nested `parameter.options` and `dimension.options` maps kept string keys. The
api decode's `strip-extra-keys` pass then dropped those undeclared string keys,
silently emptying `:options` (a case-insensitive filter would run case
sensitively, a relative date filter would lose `:include-current`, a
stage-targeted dimension would lose `:stage-number`).
Give both option schemas a `:decode/normalize` of `lib.schema.common/normalize-map`
so their keys are keywordized before the strip pass, matching how the rest of
the lib schemas decode map keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address agent-api / xrays review feedback
- /v2/query continuation-token branch now normalizes-and-validates the embedded
query like the query_handle branch does, instead of trusting the unsigned
token's shape; the total-row budget is read off that validated query.
- reject-native-query! normalizes the payload to MBQL 5 (best-effort) and checks
for a native stage with lib/any-native-stage?, so native detection no longer
depends on how the JSON was decoded (keyword vs string keys); /v1/execute and
the query_handle path decode with plain json/decode and let normalization do
the keywordizing. Malformed payloads still fall through to the existing shape
checks for their clean 400s.
- Renamed validate-query -> normalize-and-validate-query (it normalizes and
strips, not just validates) and led its docstring with what it returns.
- xrays cell-query route params decode declaratively via a ::cell-query schema
(:decode/api base64-decodes + validates ::root.cell-query) instead of
base64-decoding once in the schema and again in the handler.
- Tiles reject test asserts a clean 400 (status-code) rather than any Exception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update sandbox dimension-target test for keywordized option keys
The dimension-target `:options` map now keywordizes its keys (e.g. `"stage-number"`
-> `:stage-number`), so the attribute-remappings round-trip test expects the
normalized keyword form on the way back out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* add permalink to quarantine entry, update compare function to return QuarantineEntry type
* print search url to make tests easy to find
* update tests
* remove a few comments
* simplify search url
Disable "Send now" when the alert has no delivery target (#79881)
Extract alertHasValidTarget from alertIsValid: the handler and channel
checks without the subscriptions one. Send now is a one-off send that
ignores the schedule, so it gates on the target only, while Done keeps
the full check.
Fix disabled Done button in question alert modal (#79914)
bg is a style prop, lands inline on the root and beats the :disabled
rule in Button.module.css, so the button kept its brand background while
the label switched to the dark text-disabled token. color feeds
--button-bg instead, which the disabled rule overrides.
* Run full query-permission checks when compiling a native form (SEC-822)
POST /api/dataset/native compiled and returned the query after checking only
database-level native perms, so a referenced source card or card template tag
was resolved without a read check. Preprocess the query once (with inline
parameter compilation enabled), run the same check-query-permissions* checks
the run path uses (surfacing a permission failure as a 403), then compile the
preprocessed query. Also trim the response to :query and :params.
* Validate link-card entity ids on write for dashboard cards (SEC-836, SEC-832)
DashboardCard validated the link-card entity id only when building the read
query; nothing checked it on write, so a non-integer id could be stored and
would then break every hydration of the dashboard. Add a write-side check in
define-before-insert and a new define-before-update, and wrap the notification
link-card renderer in try/catch so one bad card degrades to a missing part
instead of failing the whole subscription. Adds regression tests.
* Require positive-integer entity ids in dependency backfill (SEC-830)
replace-dependencies! placed to_entity_id directly in the insert, and the
dashboard click_behavior extraction forwarded targetId with no type check. Add
an explicit pos-int? check at the insert where to-add is built (a runtime
check, not an mu/defn schema, which is stripped from prod builds), and filter
targetId to positive integers during extraction so bad ids drop rather than
making the backfill job throw. Adds regression tests.
* Validate click-behavior target ids on write for dashboard cards (SEC-830)
Review follow-up: the entity ids the dependency backfill consumes come from
dashcard click_behavior targetIds, so validate them at the write boundary as
well, keeping the deep guard in replace-dependencies! as defense in depth.
Covers both the top-level click_behavior and per-column column_settings ones,
on insert and update. Adds regression tests.
The 'converting question to a native query' e2e test asserted on
[data-testid=cell-data] immediately after clicking 'Convert this
question to a native query' (and after 'Explore results'), without
waiting for the POST /api/dataset run to settle. Under load the
assertion raced a stale/mid-flight results table and timed out.
Mirror the working convertToSql() helper: intercept POST /api/dataset
and cy.wait for it before each cell-data assertion.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move plan util from common into lib utils
* Move waitUntilNextFramePainted from common into lib utils
* Move export format types from common into metabase-types
* Move plan helpers into the settings module
Add a copy button to the query builder's view footer, next to download.
It copies whatever is on screen: tabular results (tables, pivot grids,
or a chart toggled to the raw data view) as tab-separated text plus a
text/html table flavor so spreadsheets paste a grid, and any other
chart as a png rendered through the same pipeline as the png download.
Copies are always formatted, matching the rendered table: remapped
columns, column visibility and order, renamed titles, click-behavior
link text, and pivot grids with their headers and totals. Raw data
stays a download concern. Copying is capped at the rows loaded in the
browser, with a toast saying when only the first slice was copied.
The clipboard write starts inside the click's user activation so Safari
accepts it, cell click metadata is built only for columns whose text
needs it so wide tables serialize in linear time, and the selected-cell
copy now shares the quoting TSV serializer.
Cover Database connection details on serialization import
Pins the two checks in load-one! for Database: an H2 engine is refused, and
low-level JDBC keys are dropped from details, write_data_details and
admin_details.
* Harden document card-creation permission checks
Route document create/update/copy card creation through the same checks
POST /api/card applies: create-check the target collection, read-check
each card copied by document_id FK, and query-permission-check the fields
that parameter targets name.
* Guard document AST entity ids before app-DB lookups
smartLink/cardEmbed reference ids come from free-form document JSON and
may be any value. Add a shared node-entity-id accessor that yields the id
only when it is a positive integer, and use it in the raw-entity readers
(serdes export transform, export-time dependencies, and descendants) so a
malformed reference is skipped rather than passed to a Toucan query.
* Add access and type checks to comment notifications
Mentioned users are now resolved to active users whose permissions cover
reading the commented entity before being added to the notification
recipients, matching how other delivery surfaces scope their audiences.
The TipTap text-node renderer now coerces non-string :text values to
strings so they always compile as escaped text rather than as hiccup
elements.
* Scope the comment mentions listing to visible users
GET /api/comment/mentions now applies the same audience rules as
GET /api/user/recipients: non-superusers only see users in their own
tenant, further narrowed by the user-visibility setting (:none -> self,
:group -> shared-group members, :all -> everyone in the tenant). This
also resolves the endpoint's long-standing TODO about tenant handling.
same-groups-user-ids moves from the user REST namespace into the user
model so both endpoints share one implementation.
* Move JoinConditionOperator from metabase-lib into metabase-types
* Move font family and DeepPartial types into metabase-types
* Generate mock document ids without the lib utils uuid helper
* Import JoinConditionOperator from the api barrel in metabase-lib
* Drop lib-utils re-export shims and inline docs at the moved declarations
* init vllm support
* tmp improvements
* cleanups; bump max tokens floor for vllm; env shadowed cred field guards
* fix openrouter temperature
* vllm reasoning
* fixes
* cleanups
* make vllm transport errors non-retryable
* fix vllm connect diagnostics; add recorded stream fixtures
* [BOT-1930] Rebuild vLLM support on the provider connection registry (#80103)
* Rebuild vLLM support on the provider connection registry
vLLM becomes a provider type in `metabase.llm.provider` rather than a set of
`llm-vllm-*` settings read by the adapter: a required base URL and an optional
API key, no default model, and an `MB_LLM_VLLM_API_BASE_URL` group so the
environment can configure one connection. The base URL is the credential — a
server started without `--api-key` takes no key — so it alone brings a usable
connection into existence, and the admin form renders from the descriptor
instead of a bespoke component.
Two things the adapter used to reach out for now travel with the connection:
- The connect-time preflight, which exercises tool calling and structured
output against the model that will be used, runs from `verify-credentials!`
under a new `probe?` flag. Only writes probe; a model listing must not spend
a generation on the operator's server.
- Whether the served model streams reasoning, which only the probe can answer,
is reported back as `:learned-config` and stored on the connection instead of
in a global setting, so two vLLM connections no longer overwrite each other's
verdict. The request path reads it from the credentials it is handed.
A vLLM connect has no default model to select, so it adopts the model its probe
exercised, and an edit re-probes against the model the connection is actually
serving Metabot.
* Document connecting a vLLM server
The provider list in the AI settings docs is hand-maintained. vLLM is the one
provider an admin can misconfigure outside Metabase, so it gets the server flags
the connect-time check looks for alongside its entry.
* fix kondo
* revert docs
---------
Co-authored-by: Tyler Plude <tsplude@gmail.com>
* review comments
---------
Co-authored-by: Maksym Yakubych <maksym@metabase.com>
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* upload all pass/failures for backend tests on rerun
* also for frontend tests
* add some tests
* cleanup whitespace
* copilot review
* a few more fixes from local review
* determine rerun from github context instead of input
* Use content-based cache keys for m2, bun, cypress, eslint.
Eagerly populate cache on push to master/release for m2, bun, cypress.
Read-write eslint cache from every branch.
* Handle multiline outputs
* Remove unneeded M2 restore on prepare-frontend
* Handle potentially multiple cyprss version matches. Update commentary.
* Validate list of alias combinations in use across the repo
* Check preresolve aliases in parallel
* Enable Qwen 3.8 Max via OpenRouter
* Downgrade tool_choice "required" to "auto" for qwen3.8-max
Alibaba returns a 400 request error if you pass tool_choice "required" and unlike moonshot / Kimi K3, does not allow
you to disable thinking mode for Qwen 3.8.
* Add coerce-stringified-scalars to transform tool call args
Qwen 3.8 sometimes sends integer args as strings, like `{:limit "15"}`. Use the tool's declared schema to
automatically transform any simple numeric or boolean scalars.
* Test: transform revision diffs should not include past source bodies
A transform's revision history diffs two full snapshots, so every past
:source (native SQL / Python body) and :source_database_id shows up in
the diff - even for versions that read from a database the current
viewer has no query access to. Add a test asserting the diff only
describes that the source changed, without reproducing the old source.
* Guard transform revision history per snapshot, not per current row
* extra guards in metabot tool
* GDGT-2932: Refactor MCP/CLI analytics to shared-layout + nested routes
Replaces the tab-query-param pages with a shared layout (header, tab
navigation, filter bar, and a single "has any data" gate) rendering
usage/events sub-routes through an Outlet, mirroring the
dependency-diagnostics pattern. New URLs:
- /monitor/ai-auditing/mcp/{usage,events}
- /monitor/ai-auditing/cli/{usage,calls}
with an index redirect from the bare section path to /usage.
Shared filter/pagination state is passed to the active leaf route via a
typed React context (following MonitorContent's existing
useMonitorSidebarContext precedent) rather than react-router's untyped
useOutletContext.
Also fixes two bugs found in cross-review: tab links were dropping the
current query string (clearing filters on every tab switch, since
LinkTab doesn't inherit location.search), and the index redirect was
gated behind the "has data" query so a bare /mcp or /cli with zero rows
never canonicalized to /usage. Bundles a parity fix for useCliHasData
(now surfaces query errors, matching useMcpHasData) so the CLI usage
view doesn't spin forever on a failed count query.
* Fix MonitorHeaderTabs to use aria-current instead of the ARIA tabs pattern
MonitorHeaderTabs rendered its options with role="tab" (via Mantine
Tabs.Tab + a Link component), but each "tab" is a real, bookmarkable
route rather than in-place content switching — the ARIA tabs pattern
implies activating a tab doesn't navigate, which isn't true here. It
also never rendered a matching tabpanel/aria-controls pairing, since
the actual content lives in a react-router <Outlet/> outside the Tabs
tree entirely.
Rewritten as plain nav links with aria-current="page" on the active
one, matching the existing AreaTab component's approach for the same
kind of route-driven navigation. Visual pill styling is preserved via
a small CSS module. Updates the two dependency-diagnostics/tasks/mcp/
cli consumers' tests and e2e specs that asserted the old role="tab".
* GDGT-2932: Review
* GDGT-2932: Keep error count queries mounted across analytics routes
* GDGT-2932: Cleanup
* Fix task tabs E2E selector
* GDGT-2932: Address PR comments
* GDGT-2932: Cleanup
* GDGT-2932: Remove LinkTab. Introduce shared AiAnalyticsSectionLayout. Fix comments
* Move the xyflow stylesheet into the graph components
* Give the schema viewer one loader for its route and its prefetch
* Load the dependency graph from the plugin slot lazily
* Deflake metabot error boundary test by loading the chat module at suite setup
* Preload the chat module in the shared metabot test setup
* Use the shared metabot test setup in the error boundary spec
* Clean up leftover JS-era config, deps, and docs after the TypeScript migration
* Address review: keep .jsx in resolve.extensions, drop .js from loader tests, includes-based e2e validator filter
* Keep @babel/node for a separate deps cleanup round
* Keep js/jsx in rspack loader tests
* Fix flaky dashboard parameter-removal e2e test
The "should be able to remove parameter (metabase#17933)" test asserted on
the full dashboard URL query string with cy.location("search").should("eq",
...). The order in which parameter slugs are serialized into the URL is not
deterministic, so a correct-values-but-different-order string
(?text_starts_with=G&text_ends_with= vs ?text_ends_with=&text_starts_with=G)
could never match and the retry just spun until timeout. Empty/stale-URL
timing races produced the other observed failures.
Replace the four exact-string assertions with an order-independent helper that
parses the query string via URLSearchParams and deep-equals the expected
param map. It still retries through cy .should(callback), so async URL updates
settle before the assertion resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wait for parameter-remove button to enable before clicking
The parameter-removal step in the dashboard parameters spec clicked the
sidebar's Remove control via cy.findByText("Remove"). That query resolves to
the button's inner label <span>, not the <button> element. A span is never
"disabled", so Cypress's actionability check did not wait: it clicked the span
while the underlying button was still disabled.
The Remove button stays disabled until the dashboard's subscriptions have
loaded (the sidebar warns before archiving any subscription bound to the
filter). When that GET /api/pulse request was slow, the click landed on the
disabled button as a silent no-op, the parameter was never removed, and the
subsequent URL assertion timed out waiting for the slug to disappear.
Target the button element by role and assert it is enabled before clicking so
the click waits for the subscriptions request to settle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix linting error
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
The test opened the info sidebar right after an ad-hoc edit-mode save that
only awaited the PUT /api/dashboard request. The trailing query_metadata
reload re-rendered the header, dropping the 'More info' click so the
sidesheet never opened. Use the H.saveDashboard() helper, which waits for the
full save-and-settle sequence before proceeding.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix getTable intercept in segment_created tracking e2e test
The segment editor's DataStep loads a table via fetchTableMetadataAndForeignKeys, which calls GET /api/table/:id/query_metadata. The test intercepted the bare GET /api/table/:id endpoint instead, so cy.wait("@getTable") always timed out because that request never fires in this flow. Point the intercept at the query_metadata endpoint the editor actually calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Gate segment filter click on enabled Add-filters button
The segment editor renders a disabled "Add filters to narrow your answer"
button until onChange builds the query from fully-loaded table + FK metadata.
Waiting on the getTable response alone isn't enough — the trailing foreign-key
metadata loads race the click, so the click lands on the still-disabled button
and the filter popover never opens. Assert the button is enabled first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* add sample db
* delete cross version 57 since we don't support it anymore
* update cross version tests to use postgres
* fix modal selector to work on v1.60
* fix modal selector for v60 and v63 compatibility
* add better logging
* use qa image
* use herestring to handle large output properly
* use data from WA image
* fix flake in nested model assertion
* cleanup the README
* code review fixes
* Validate imported queries against this instance's MBQL schema (GHY-4241)
An export decides the shape of the queries it carries, so an import can hand us
a query shape this version has no representation for. `lib/normalize` does not
always reject one: it can quietly produce something the schema rejects, which
is then stored and breaks the card on read.
After `import-mbql` normalizes an ingested query, validate the result against
`::lib.schema/query` and refuse the import if it fails. Only full queries are
checked - bare refs and the MBQL fragments inside visualization settings have no
standalone schema to validate against.
Normalization failures stay non-fatal. They fire today on content this instance
produced itself (pivot column refs in visualization settings raise
`:malli.core/invalid-schema`), so promoting them would reject legitimate
imports. Validating the successfully-normalized result catches the real problem
without that fallout.
`MB_SERIALIZATION_SKIP_SCHEMA_VALIDATION` opts out. `metabase.models.serialization`
cannot read an EE setting, so the flag reaches it through a dynamic var bound by
`load-metabase!` - the one choke point every import path funnels through.
Also validate the bundled instance analytics content against the same schema in
CI. That content re-imports on every EE boot and a failure there propagates out
of `init!` to `System/exit`, so a bundle regeneration that broke it would
crash-loop rather than degrade.
* Fix import validation documentation
* Refactor documentation for import validation settings
* update docs
* Report the schema refusal as a 400 and stop duplicating its errors (GHY-4241)
The serdes import API reads `:status` off ex-data itself rather than going
through the API layer's exception handling, so `:status-code` left it nil and
the refusal surfaced as a 500 for what is a client input error.
`u/strip-error` already renders ex-data into the message, so the truncated
`Schema errors:` tail was a second, shorter copy of `:schema-errors`.
* Drop the unreachable status code from the schema refusal (GHY-4241)
`load-one!` rewraps every exception from the load block in a fresh ex-info
carrying `path-error-data`, and the import API reads `:status` off the
outermost exception only. Neither `:status` nor `:status-code` attached here
was ever visible, so claiming one in a comment was worse than saying nothing.
* Name both causes in the refusal, and pin bundle ingest errors (GHY-4241)
`mu/defn` schemas are not instrumented in prod, so an app DB can hold MBQL the
QP tolerates but `::lib.schema/query` rejects - a stale `:join-alias`, a field
filter with no `:widget-type`. Such content exports intact and is then refused
on import, so "most likely exported by a newer Metabase" was the wrong
diagnosis for a whole class of refusals. Name both causes, and point at
continue_on_error as the per-entity escape rather than only the global one.
The bundle test proved every query imports but not that every file parses.
`ingest-list` collects unreadable files in an errors atom rather than failing,
so a malformed bundled YAML would drop out of the queries under test while
still crashing EE boot. Assert the atom is empty too.
* Move refusal rationale out of docstrings, harden a test helper (GHY-4241)
Docstrings state the contract; the reasoning behind the check, the message
wording and the sentinel moves to comments beside the code it explains.
`*skip-schema-validation?*` no longer names who binds it, which would go stale
silently. `validate-imported-query!` now says legacy MBQL 4 queries are left
alone as well, which was true but unstated.
`cause-chain-messages` used `map ex-message`, so an exception with no message
would put a nil in the vector and NPE the callers' `re-find` - failing the
test for a reason unrelated to what it asserts.
* kick ci
* Simplify the import schema check's control flow (GHY-4241)
`validate-imported-query!` returns its argument and no longer consults the
opt-out, so it is a plain step in `import-mbql`'s pipeline and the caller
decides whether to run it. That removes the sentinel `normalize-imported`
needed to keep the refusal outside its `catch`, along with the comment
explaining the sentinel.
`some->` replaces `->`, which makes `normalize-imported`'s nil guard
redundant.
* update comment
* Route semantic embeddings through the provider SPI
Summary
- Register the existing ai-service, OpenAI, and Ollama implementations through the shared provider registry.
- Route semantic embedding, readiness, resolution, and preparation through the SPI while preserving the existing public adapter.
- Preserve the compatibility client model-dimensions contract by normalizing it to vector-dimensions at the provider boundary.
- Keep semantic-specific batching, metrics, and settings policy in semantic search.
Scope
This migrates consumers to the SPI. Persisted embedding-space compatibility and in-process provider selection are intentionally handled by later stack layers.
Testing
- Built-in provider behavior and existing semantic embedding adapter tests.
- Compatibility-client dimension normalization and conflict handling.
* Describe the embeddings client contract in SPI terms
The batch entry point delegates to the provider registry rather than a multimethod.
* Port complexity-test fake embedder to the provider SPI
get-embeddings-batch is a plain fn now, so the old defmethod no longer compiles
and broke every job that loads this namespace. Register a capturing provider
instead and assert on the descriptor it receives.
* Induce the dimension mismatch from the model, not the mock vector
The SPI rejects a vector whose length doesn't match the resolved model, so a dim-1 mock embedding
no longer reaches pgvector. Shrink the configured model instead, which is what an upgrade does.
* Persist immutable embedding-space identity
Summary
- Persist embedding-space IDs in semantic and library-entity index metadata.
- Backfill semantic metadata through a preserving schema migration and rebuild legacy entity vectors once when identity is unknown.
- Include immutable space identity in compatibility checks and physical table names.
- Enforce the PostgreSQL 63-byte identifier limit using UTF-8 byte length.
Scope
This establishes durable compatibility. Synchronization between entity compatibility checks and rebuilds follows in the next stack layer.
Testing
- Semantic migration preservation and backfill.
- Entity-index rebuild and compatibility behavior.
- Embedding-space table naming and non-ASCII identifier limits.
* Resolve the model in the index-cleanup test helper
record-new-index-table! now persists embedding_space_id into a NOT NULL column,
so create-test-index must build a resolved model that carries one.
* Track the index-status rename after rebase onto master
* Resolve the model-v2 variant in the reconcile tests
A bare assoc keeps the v1 embedding-space-id on a renamed model, so the rebuild wrote a meta row
whose space no longer matched what the provider resolves and nothing repopulated.
* Serialize entity index queries with rebuilds
Summary
- Coordinate library searches and reconciles with shared and exclusive PostgreSQL advisory locks.
- Keep compatibility rechecks and vector queries on the same locked connection so they cannot straddle a rebuild.
- Use non-blocking search lock acquisition so waiting searches cannot exhaust the pgvector pool.
- Preflight compatibility before embedding and return no results while a reconcile owns the index.
Scope
This PR only addresses library entity-index concurrency and degradation behavior.
Testing
- Search call ordering and locked connection reuse.
- Incompatible-index preflight and reconcile-contention degradation.
* cljfmt entity-retrieval core-test
* Follow the search-unfiltered rename in the entity-retrieval tests
Rebase fallout: the parent branch renamed `search` to `search-unfiltered`, and these call sites came across from
before the rename.
* Enable the in-process embedding provider
Summary
- Allow plugin-defined providers in semantic settings and safely encode provider names in physical index identifiers.
- Enable the bundled in-process Arctic provider with dimension readiness, query prefixing, and token metrics.
- Document the optional plugin and its glibc 2.34+ Linux or Apple Silicon macOS runtime requirement.
- Build the embedder jar in CI and run one artifact-only smoke path through the provider SPI on the OSS classpath.
Scope
This intentionally supports one pinned Arctic model. Custom model sources, MiniLM, and library overrides remain out of scope.
Testing
- Settings, prefix, token-accounting, readiness, and derived table-name regressions.
- Jar-manifest discovery, platform failure modes, batching, no-network behavior, and real ONNX inference.
* Re-hash qualified index table names over the 63-byte limit
qualify-index wraps the model table name with the metadata qualifier but did not
re-check Postgres's 63-byte identifier limit. A longer provider-encoded name plus
the qualifier could exceed it, so Postgres truncated the name on CREATE while
table-exists? still looked it up by the full name -- reporting the index table as
missing. Re-apply hash-identifier-if-exceeds-pg-limit to the qualified name.
* Hash only the table component of a qualified index name
Postgres's 63-byte limit is per identifier. Hashing the whole schema-qualified
name dropped the schema, so in app-db mode a long semantic_search.<table> became
a bare index_<sha1> created outside the isolated schema. Split off the schema and
hash only the table component.
* Cover in-process MiniLM complexity scoring
* Activate the in-process embedding provider on demand
* Stub lazy provider activation in token usage test
* Update Kondo ratchets for embedder changes
* Rely on bootstrap for in-process provider registration
* Cover the bundled complexity-score model in the artifact smoke test
The artifact job only exercised the retrieval model, so a broken or missing MiniLM
bundle -- which uses a different pooling and the three-input INT8 graph -- would have
shipped unnoticed.
* Document both bundled embedder models
* Register the in-process provider in standalone CLI mode
Provider registration happens in metabase.core.core/init!, which `--mode complexity-score`
skips entirely. A synonym axis configured for the in-process provider therefore failed with
:provider-not-registered on the CLI while working on the server.
The activation moves into metabase.embeddings.startup so both entry points run the same
routine instead of drifting apart.
* Assert the descriptor the in-process synonym embedder sends
The test claimed the embedder passes the MiniLM descriptor to the provider SPI but only
checked that it was a function, so an Arctic descriptor would have passed too.
* Pin the CLI's provider registration with a test
Nothing caught the standalone CLI skipping provider registration the first time, so assert
appdb mode registers before it scores.
* Honour :snowplow? false in the ai-service adapter
Routing single embeddings through the batch SPI dropped the option, so the health and
circuit-recovery probes started reporting their synthetic embeddings as organic token usage.
* Make the dimension-mismatch test reach pgvector
Shrinking the model with assoc kept the old embedding-space id, so resolution failed and the
query never ran -- the same path search-degrades-when-configured-model-cannot-be-resolved-test
already covers. Use a self-consistent model, stub the compatibility preflight aside, and assert
the warning that only the 22000 branch emits.
* Use dynamic redefs for the mock index's table suffix
Pre-existing on master: a plain defn redefined on one thread, so nothing here needs the root
swap. Same mock-index value either way.
* Document the tiny bundled models as a test tier
* Fix index-health-check false degraded report
row->index's :embedding-model now carries :embedding-space-id and
:embedding-spi-version, which get-configured-embedding-model doesn't
return, so the equality check in index-health-check always failed and
reported every healthy index as degraded. Compare only the keys
settings actually configure.
* Give v1 Arctic-embed models their own query prefix
model-family-query-prefixes matched every snowflake-arctic-embed
model name and prefixed queries with "query: ", which is only correct
from v1.5 onward. v1 models (including the bundled xs test-tier
model) expect the longer CQE-style instruction instead.
* Carry the plugin-name pin into embeddings.startup
The provider registration this branch moved into
embeddings.startup/ensure-in-process-provider! re-duplicated the
manifest name as its own private literal. Make it the one public
constant both the startup logic and the tests reference, instead of
reintroducing a third copy.
* Correct the Arctic Embed query-prefix cutover to v2.0
The generation split is at v2.0, not v1.5. m-v1.5 was getting "query: "
when it wants the longer instruction, and m-long matched neither pattern
so it got no prefix at all.
Replace the two mutually-exclusive patterns with an ordered first-match-wins
table. That drops the exclusivity constraint the previous shape needed and
covers every model in the family.
* Stop the query-prefix setting claiming `query: ` family-wide
The description predates the generation split and tells admins the whole
snowflake-arctic-embed family expects `query: `, which is wrong for the
bundled model.
* Anchor the Arctic Embed patterns to the model-name segment
Unanchored, they matched any name merely containing the family name, and
a false positive there is unsuppressable: `ee-embedding-query-prefix` can
replace a matched prefix but never remove one.
* Compare embedding spaces in the semantic health check
Settings can stay identical while a provider upgrade resolves the same
model into a new space, stranding the index. The space id is what
actually decides whether the stored vectors are still usable, so compare
that rather than provider/model/dimensions.
Resolution reaches the provider, so it can fail outright when the plugin
is absent or the platform is unsupported. That is its own incident, not
a model mismatch.
* Document the in-process semantic search model
* Add in-process semantic search integration test
* Drop incompatible legacy entity indexes
* Address embedder review findings
* Quote legacy index identifiers separately
* Internal load-namespaces tool to check for compilation errors
* Ignore load-namespaces in module checks
* lint load-namespaces
* Reject positional args
* Test the new utility
* Guard against nonexistent root.
Make the code more testable.
* Load CodeMirror editors lazily so they leave the initial bundle
* Inline the single-use dynamic imports into the lazy calls
* Share the codemirror split chunk with the embedding SDK build
* Split the snippet, Python and public document pages by route instead of wrappers
* Dedupe modules shared by async chunks instead of naming CodeMirror
* Drop the duplicate metrics viewer route that master already added
* Keep barrel imports and mark the expression language side-effect free
* Move the action editor boundary to the new action-creator module
* Drop the codemirror cache group the SDK config no longer needs
* Drop props types exported for wrappers that route splitting replaced
* Delay the code fallback so a fast chunk load never flashes uncoloured code
* Render nothing while the code editor chunk loads
* Delay the whole expression widget instead of showing a spinner inside it
* Open the editors from routes and modals instead of loading states
* Fold the Metabot preview chunk into the message's own loading state
* Fold the query editor chunk into the transform pages' own loading state
* Prefetch the expression widget from the notebook so its popovers open ready
* Await the run error message now that the code editor loads lazily
* Hold the custom column popover shut until the expression widget arrives
* Wait for the expression editor to hold focus before typing into it
* Type into the page directly when asserting the read-only native editor
* Read the editor value only once its document stops changing
* Fetch the routes-only plugin pages when their route is matched
* Fetch the remaining plugin route factories when their route is matched
* Load the AI settings pages as one chunk
* Say why these route files register no prefetch
* Derive the AI settings prefetch paths from the route paths
* Prefetch the model migration page like its siblings
* Count the python transform pages master already made lazy
* Give each AI settings page its own chunk
* Move useEntityData out of the tiptap SmartLink node module
* Load the Metabot chat panel lazily
* Load the public document page lazily
* Recover from a failed fetch of the Metabot chat panel
* Fetch the public document chunk at startup
* Fetch the data studio, model and snippet pages when their route is matched
* Fetch the monitor and timeline pages when their route is matched
* Name the chunks the route loaders build
* Fetch the AI auditing and library pages when their route is matched
* GDGT-2977 Style full markdown output in shared Markdown component
- Markdown.module.css covers every CommonMark+GFM element as :where() base layer
- compact prop for line-clamped previews
- disallowHeading renders headings as paragraphs instead of unwrapping to text
- drop duplicated rules from AIMarkdown, delete AdvisoryCard.module.css
- kitchen sink fixture + stories + spec failing on unstyled elements
* GDGT-2977 Trim markdown specs to our own behavior
- drop tests that only exercise react-markdown/remark-gfm
- cover urlTransform (metabase://, data:image, unsafe protocols)
- assert headings render as p when disallowed
- merge AdvisoryCard markdown cases
* GDGT-2977 Fix markdown spec asserting against mantine style tag
- container.firstElementChild was the injected <style>, so every
querySelector check passed vacuously
- render into a host element and select the markdown root from it
- javascript: urls collapse href to empty, which drops the link role
* addressing the PR's comments
* GDGT-2977 Match skeleton caption tooltip to legend caption
compact + lineClamp, per PR review
* fix small regression
* Let conversation titles run on their own model
Every LLM call read the one `llm-metabot-provider` setting, so naming a
conversation — a short, high-volume call — burned the same model the chat runs
on. Add an `llm-title-model` setting whose getter resolves rather than defaults:
the stored value, else the fastest model of the connection Metabot uses, else
that model itself. Provider types now carry a `:mini-model` alongside
`:default-model`, taken from each adapter's own supported-models list so the
fallback names a model the picker actually offers; Azure and the managed
provider have none and fall through.
Move model selection out of the AI providers card into its own Models section,
so per-use-case choices have somewhere to live. Deleting a connection clears a
title model pinned to it, reading the raw stored value so a merely-derived one
is left alone.
* Name a mini model for every provider type that has one
Mistral and Z.AI were the only types with no `:mini-model`, so conversation
titles on those connections fell through to the model Metabot chats on. Both
adapters whitelist exactly one model today, so their mini model is that same
model — the fall-through by another name, but spelled out where a real small
model goes when one is added.
Two tests hold the line. The registry map fails when a type gains or loses a
`:mini-model`, since a missing one reads as nil and quietly sends titles to
the full-size model. The listability test fails when a type names a model its
adapter will not offer: `list-models` returns the whitelist intersected with
the provider's catalog, so an id the whitelist omits never reaches the admin
picker, which renders it as a blank selection.
That second test caught Anthropic naming `claude-haiku-4-5` while the Claude
whitelist carried only `claude-haiku-4-5-20251001`, leaving the new title
picker blank on the commonest setup there is. The whitelist was the odd one
out — the adapter already defaults to the undated alias — so it loses the date.
* Sell the title model as the mini model it already was
Review asked whether each feature should get its own picker or whether one
cheap model should serve every quick task; the answer was one model. The
setting already resolved through the registry's `:mini-model` and nothing but
titles read it, so this is a rename — `llm-title-model` becomes
`llm-mini-model` — plus copy that presents naming conversations as the first
of the quick tasks rather than the point.
The derivation tests now pin the stored value to nil instead of assuming a
clean database; a crashed run had left a stored value behind, which the old
tests read as the derived one.
* Return Haiku's dated id, which is what the Anthropic catalog serves
Dropping the date assumed /v1/models would list the bare alias. Review
testing with a live key showed it does not: the catalog carries only
claude-haiku-4-5-20251001, so the undated whitelist key filtered Haiku out
of the picker, and the derived mini-model reference matched no option —
titles kept running on Haiku while the picker sat blank.
The alias stays where it always worked: inference-call defaults. The
whitelist and the registry go back to the catalog's id, which is also the
whitelist's convention — the 2025 models are dated, the 2026 models are not,
tracking what the catalog returns for each.
* Move a pinned mini model with the deployment its connection now serves
Editing an Azure connection's deployment already moves the Metabot
selection, because the reference bakes the deployment name in. A mini model
pinned to that connection went stale the same way, so it moves the same
way. A derived mini model is left unstored — it follows the Metabot
selection on its own.
* use json_query as the cache key to prevent cardinality cache collisions
* fix e2e, persist default settings that depend on cardinality
* review comments
* Display percentages instead of values on stacked bar charts
Adds a Stack value format setting that lets stacked bar chart segments display their percentage of the stack total.
Addresses #67702
* Handle percetanges for negative values
* Show decimals for fractional stack percentages
* Show percentage totals for stacked bars
* Improve condition readability
---------
Co-authored-by: Abhijnya K G <53806883+Abhijnya002@users.noreply.github.com>
* Invalidate the cached models list when credentials change
A credential write can change which models the provider serves, e.g. a different Bedrock region, Google location, or
Azure resource.
* Support multiple LLM provider connections at once
Metabot could only ever be connected to one AI provider, because each
provider's credentials lived in its own hardcoded settings
(llm-anthropic-api-key, llm-azure-api-key/-base-url, llm-bedrock-*, ...)
and llm-metabot-provider named the single provider in use. Adding a
provider meant editing a `case` in six namespaces plus three constant
maps on the frontend, and two keys for the same provider were impossible.
Replace that with a list of provider *connections* stored in one
`llm-providers` JSON setting, fronted by a CRUD API rather than edited as
a raw blob -- the same shape the `oidc-providers` setting already uses.
Provider types are now a pure-data registry describing each type's
credential fields, so the admin UI renders a connection form from the
server descriptor instead of a bespoke component per provider, and the
model picker lists models from every connection, grouped by connection.
Connection keys are slugs defaulting to the provider type, so existing
`llm-metabot-provider` values keep resolving unchanged. `metabase`
becomes an ordinary connection, which collapses the three-segment
`metabase/provider/model` special case into the generic rule. The
per-provider env vars keep working and each synthesizes one read-only
connection; MB_LLM_PROVIDERS puts the whole list under env control.
* Fix managed provider handling in the connection list
Five issues from reviewing the new admin UI, all around the Metabase-managed
provider:
- The managed provider's models were fetched from the proxy, so instances
that cannot reach it showed "Couldn't load models". The proxy serves one
benchmarked model rather than a listable catalog, so the registry now
carries that catalog directly and nothing is fetched. It doubles as the
allow-list `llm-metabot-provider` is validated against, replacing the
separate copy in metabot.settings.
- The managed provider had an Edit action that opened an empty modal (or one
offering to re-Connect something already connected). There is nothing to
edit, so managed connections now only offer Remove.
- It could be added twice. Provider types can declare themselves singletons;
the API rejects a second connection and the FE drops already-connected
singletons from the Add dropdown.
- The empty state repeated the section description verbatim.
- Cards showed the raw type slug under the name ("Metabase" / "metabase").
The provider type's label is now shown only when it differs from the
connection's name, i.e. when the admin renamed it.
Also drops the startup step that materialized a `metabase` connection on any
proxy-configured instance, which made the managed provider look connected when
nobody had connected it. It is now materialized only where a `metabase/...`
model reference is actually written: the legacy metabot-v3 sync, and the
migration of an instance already pointed at it.
* Address review comments on the provider e2e helpers and managed setup spec
- Drop the doc comments added to e2e-metabot-helpers.ts.
- Move the mock LLM server's base URL into the connection's own config rather
than the legacy `llm-anthropic-api-base-url` setting. Both worked, because
the adapters fall back to that setting when the connection carries no base
URL, but relying on the fallback left the connection describing one endpoint
while requests went to another.
- Assert that the add-on purchase really does precede the connection POST,
which the test claimed in its name but never checked, and add the missing
case: a failed purchase must not create the connection.
* Stop repeating the model family in the model picker
Providers already carry the family in the model's display name, so prefixing
it with the group produced labels like "Opus · Claude Opus 4.8" and
"Anthropic · Anthropic: Claude Haiku 4.5". Show the display name on its own.
The group still comes back from the API: the backend orders each provider's
models by it, so dropping it here keeps the grouping-driven order without
showing the label twice.
* Select a default model on connect, and simplify the ad-hoc connect view
- Connecting a provider when Metabot had nothing usable to run on now points
`llm-metabot-provider` at that connection's default model, so the first
provider you add leaves the instance working instead of connected with no
model selected. An existing selection that still resolves is left alone, so
adding a second provider does not silently switch Metabot over to it. The
"did it have a usable model" check has to run before the new connection is
saved: the setting's default names the `anthropic` connection, which a
freshly saved Anthropic connection would otherwise satisfy retroactively.
- The ad-hoc "connect to a model" modal no longer renders the admin list with
an "Add a provider" button that opens a second modal. It is one connect form,
and once a provider exists it is the model picker. Extracted
ProviderConnectionForm so the admin modal and this view share it.
- Show a skeleton while the connection and provider-type lists load.
- Replace the add-on ordering assertion, which compared indexes into the call
history, with a purchase that only resolves once the test releases it: the
connection must not be created while it is in flight.
* Fix the managed-provider-limit spec for the single-form connect view
The ad-hoc connect modal no longer offers an "Add a provider" button that
opens a nested modal, so assert on the provider picker the form now renders
directly.
* Show the model picker after connecting from the ad-hoc modal
Connecting a provider in the ad-hoc view called onClose straight away, so the
modal shut before the picker it was supposed to hand over to could render.
Stay open and show the picker instead, except for a provider that serves a
single fixed model (the managed one), where there is nothing to pick.
* Split the AI provider list and setup views apart
The one component branched on an `isModal` prop to be either the admin list
(cards, add/edit/remove, model picker) or the ad-hoc connect flow, which meant
neither was composable and the modal branch read as an afterthought. Split it
into AIProviderList and AIProviderSetup, each owning its own data and state,
sharing ProviderConnectionForm, LlmModelPicker and ProviderListSkeleton.
Call sites pick the one they mean instead of passing a flag.
Also use `useDisclosure` for the add-provider modal. `editing` and `deleting`
stay on `useState` because they carry the row being acted on rather than a flag.
* Cancel the managed AI add-on from the backend, not a plugin hook
`PLUGIN_METABOT.useOnProviderRemoved` only ran when the managed connection was
removed through that one component, so removing it any other way — the API
directly, or the fallback path in the delete handler — left the customer paying
for an add-on they no longer used.
Move the cancellation into `DELETE /api/llm/providers/:key` behind a
`defenterprise`, which is where the invariant actually holds: removing the
managed connection cancels its add-on for every caller. The EE side decides
which product type to cancel from the same premium features the frontend was
reading, and keeps the rule that an instance on legacy tiered pricing with no
metered offer to move to is left alone. Extracted the Store call the
cloud-add-ons endpoints already make into `cloud-add-ons.core` so both share it.
The plugin hook and its EE implementation are gone.
Also clear fetch-mock state between AI provider spec setups. The file grew a
second `describe`, and its routes and call history leaked into whichever suite
jest packed next into the same worker.
* Address review comments on the model picker and provider API shape
- Snake_case the two kebab-case keys the provider-type endpoint put on the
wire (docs-url, default-model), matching display_name alongside them. The
internal registry keeps its idiomatic kebab-case keys.
- Drop the model picker's own success toast: useAdminSetting's updateSetting
already toasts both outcomes, so picking a model fired two toasts and the
error path only looked unhandled.
- Show SetByEnvVar under the picker when llm-metabot-provider comes from an
env var, so the disabled Select says why.
* Remove the unused GET /api/llm/list-models endpoint
Nothing called it: no frontend, SDK, or e2e reference, only its own tests. It
listed models for whichever connection Metabot was pointed at, which
GET /api/llm/models now covers for every connection.
It is in the generated OpenAPI spec, so this is a breaking change for anyone
calling it directly. The spec is regenerated by the openapi-self-healing CI job
rather than by hand — a local run rewrites far more of the file than this change
touches.
* Pick a provider type from an icon grid instead of a dropdown
Replace ProviderTypeSelect with a ProviderTypePicker card grid, giving each
provider type an icon the backend names in the provider-type registry.
Connections now also report the env vars that configured them, so the UI can
name them rather than just saying the connection is env-managed.
* Use real provider logos, drop the stepper, lead with Metabase
- Drop the two-step "Provider / Configure" indicator. The modal swaps content
in place rather than paging, so it rendered as a lone empty circle next to a
floating label and conveyed no progress.
- Ship brand logos for the five bring-your-own-key providers, mirroring how
driver logos already work: a file per provider under
app/assets/img/llm-providers, looked up by provider type, falling back to the
registry's icon when there is no logo (the managed provider keeps `metabot`).
Sourced from svgl. The three monochrome marks are rewritten to currentColor so
they survive dark mode; Azure and AWS keep their brand colour. Bedrock uses the
AWS mark for want of a Bedrock-specific one.
- Offer the Metabase-managed provider first when the instance can actually use
it, since it is the option to reach for before bringing a key.
- Fix the blank managed connect screen. MetabaseAIProviderSetup decided between
"connect" and "already connected" from `llm-metabot-configured?`, which under
multi-provider is true as soon as *any* connection works — so adding Metabase
alongside an existing Anthropic connection rendered the connected-state card
for a subscription that was never bought, which draws nothing. The caller now
passes whether the managed provider itself is connected.
The provider-types order assertions became environment-dependent once ordering
keys off proxy availability (this worktree's .env sets MB_LLM_PROXY_BASE_URL), so
they now assert the set of types plus the lead position explicitly.
* Drop the provider picker heading that repeated the modal title
* Right-align the managed provider footer to match the other setup screens
* Focus the confirm button so Enter confirms instead of dismissing the modal
* Tuck the optional connection fields behind Advanced settings
The connect form led with Display name and API base URL, so the one field
that actually matters — the API key — was buried among inputs that already
have working defaults.
Show only the fields a provider type marks required, and collapse the rest
plus Display name into an Advanced settings disclosure at the bottom. The
split comes from the field descriptors rather than a hardcoded list, so
Azure (whose base URL is required) keeps both inputs up front.
Editing a connection that already customized an optional field opens the
section, so a non-default value is never hidden.
* Let the registry say which connection fields are advanced
Inferring it from `required` was wrong for Bedrock. `required` means
validation fails without a value, which is a different question from whether
an admin has to think about the field. Region has a default, so it is not
required — but it decides which models are reachable and where inference
runs, so burying it hands EU customers a silent us-east-1 default.
Add an explicit `advanced?` to the field descriptors and split on that. Only
the base URLs and Bedrock's session token carry it; region now stays up
front where it belongs.
* Cover submitting the provider connection form with the enter key
* Treat the single-provider env vars as a supported way to configure a provider
They were named and documented as a legacy compatibility shim — `legacy-env-connections`,
`migrate-legacy-provider-connections!`, and a doc line promising they "keep working".
That undersells them: configuring one provider by environment variable is the simpler
option when you do not want to hand-write the `llm-providers` JSON, and it is resolved
from the environment on every read, so editing a variable is picked up rather than being
frozen into a copy at first startup.
Rename to say what they are, and reword the docs to position the two mechanisms as
alternatives rather than old and new. The one-shot at startup only ever adopted
credentials stored in the app DB, so name it for that instead of for migrating away from
something we are keeping.
* Type the LLM provider logo lookup and move it out of the icon component
The logo map lived inline in ProviderTypeIcon as a Record<string, string>, so a new
provider type silently fell back to the generic icon instead of failing the build.
Give provider types a LlmProviderTypeName union and key the map on it, so adding a type
without deciding on a logo is a compile error. The map moves to metabot/constants.ts and
the lookup to metabot/utils/llm-provider.ts, matching how databases/ splits ENGINE_LOGO
from getEngineLogo.
* Focus the first credential field once a provider is picked
* Show the Metabase logo for the managed provider
* Only badge a provider connection when something is actually wrong
The green check was driven by `usable`, which only asks whether the required fields are
non-blank. The connect form already refuses to submit until they are, so every connection
made through the UI got a check — including one whose API key the provider rejects, right
above a "Couldn't load models" error saying so.
Drop the check and keep the warning, which still carries a real signal for connections
that arrive incomplete from MB_LLM_* or MB_LLM_PROVIDERS, and label it so it says what is
wrong rather than just showing a red triangle.
* Lighten the provider list to divider-separated rows
Each connection was a bordered Card, but the list already sits inside SettingsSection,
which is itself a bordered, padded card — so every row read as a card inside a card.
Follow DatabaseInfoSection instead: one container, rows separated by a divider. Also drop
the icon from 40 to 32, since the tile no longer has to anchor a card of its own.
* Tighten the provider list spacing and align the add button with the rows
The list and its add button sat in the same lg-gap stack as the model picker, so a control
that belongs to the list was pushed 24px away from it, as far as the next setting. Group
them in their own sm stack and keep lg for the gap to the picker.
The subtle button also carries horizontal padding, so its + sat indented from the provider
logos below it. Drop the padding, matching the Advanced settings toggle on the connect
form, so both start on the same edge.
Tighten the section header gap from lg to md via the stackProps SettingsSection already
exposes for this.
* Fetch provider models once and report failures on the provider that failed
A model-loading failure was only visible under the model picker, which reads as a problem
with the picker rather than with Anthropic. It also meant the failure was invisible
anywhere a picker was not rendered.
/api/llm/models already returns every connection in one response with a per-connection
error, and RTK Query already caches it under the llm-models tag, so the fetch is shared by
construction. Wrap it in useLlmConnectionModels, which hands back the grouped select
options and the errors keyed by connection. Any future picker goes through that hook and
gets the same cache entry and the same keying, rather than re-deriving either.
The provider row now renders its own error and the picker no longer lists them; the picker
keeps the request-level error, which is a different failure.
* Pick the provider from a pasted API key
Paste a key anywhere on the provider grid and it jumps straight to that
provider's form with the key filled in, ready to connect.
The registry already declared a `:prefix` per credential field — the backend
validates against it — so this just exposes it and reuses it for recognition.
Anthropic, OpenAI and OpenRouter carry one; nothing else is guessable.
Longest prefix wins, since OpenAI's `sk-` also matches an Anthropic
`sk-ant-` key. Text that matches nothing is left alone.
* Leave the paste-to-detect shortcut unannounced
Drop the hint under the provider grid. The shortcut stays; it is just for
people who find it. `canRecognizeApiKeys` existed only to decide whether to
render that line, so it goes too rather than sitting unused.
* Gate the managed provider usage panel on the features that fill it
The panel was shown whenever the metabot plugin was enabled, which is a
weaker condition than having usage and pricing to report. Move the check
behind a hasMetabaseManagedProviderDetails plugin hook so the enterprise
side decides from the premium features, and OSS answers false.
* Register the Mistral and Z.AI provider types in the connection registry
master added both providers to the single-provider settings architecture this branch
replaces, so rebasing dropped them from the admin UI: the adapters, the model listers and
the MB_LLM_MISTRAL_*/MB_LLM_ZAI_* settings all survived, but nothing offered either type
as something an admin could connect.
Registry entries restore that, and adding them to `single-provider-settings` is what keeps
an instance already configured through those env vars working — the same entry drives both
the env-resolved connection and the app-DB adoption at startup.
* Resolve provider connections in the LLM call tests
`parse-provider-model` stopped deriving a provider from the model string's first segment
and started resolving it against a configured connection, but this file still called it
with nothing configured, so every test that reached an adapter failed on "No LLM provider
connection named". Configuring the default connections is the fixture those tests were
always implying.
The invalid-format cases now assert the error the new resolution actually raises: an
unparseable string and an unknown connection are the same failure, because a name that is
not a connection is all "unknown provider" can mean once the registry is the source.
* Move the onboarding AI step onto the provider connection list
The setup wizard's AI step was built against the single-provider form this branch removed,
so it still imported `AIProviderConfigurationForm` and the provider enum and no longer
compiled. It now renders `AIProviderSetup`, the same connect-then-pick-a-model view the
ad-hoc modal uses, so onboarding and the admin page stay one flow rather than two.
Preselecting `metabase` goes away with the dropdown it was written for. The backend already
sorts the managed provider first in the type picker, which is what the preselection was for,
and picking it in a grid of logos is not a step the wizard needs to skip on the admin's
behalf.
* Add the Mistral logo and make a missing provider logo fail the build
LlmProviderTypeName is written by hand and had drifted: the registry gained mistral and
zai, the union did not, so the exhaustive LLM_PROVIDER_LOGOS record was exhaustive over a
stale set and both types fell through to the generic icon without a type error.
Add them to the union, which does make the record fail to compile until each is decided.
Mistral gets its mark from svgl; svgl has no Z.AI logo, so zai is an explicit null and
keeps the fallback icon until we have a real asset.
Nothing links the registry to the union, so add a backend test over the registry's type
names that names both frontend files to update.
* Thread per-connection credentials through the Mistral and Z.AI call paths
Both adapters took :credentials in list-models but ignored them in mistral-raw/zai-raw,
which read llm-mistral-api-key / llm-zai-api-key directly. So a connection's models listed
under its own key while every actual chat call went out under the global setting: a second
Mistral connection would silently answer with the first one's key, and a connection with no
global setting configured would fail outright.
Match the openrouter/claude shape — credentials first, configured settings for whatever
they do not carry — for both the key and the base URL, and cover the call path, which no
adapter had a test for.
* Give Z.AI its logo
Taken from z.ai's own logo.svg, since svgl carries no Z.AI mark. Stripped the Illustrator
export's ~10.5KB of unused .st0-.st194 declarations — only two of them were referenced —
and kept the geometry and colours exactly as shipped.
Every registered type now has a mark, so the fallback is only reachable when the backend
serves a type this union has not caught up with. Test it that way instead.
* Let instances that are only offered managed AI reach the purchase flow
The managed provider tile is enabled by `type-available?`, which asks whether
the LLM proxy URL is readable. That setting was gated on `metabase-ai-managed` or
`metabot-v3` — features granted only by the AI add-on products — while
`offer-metabase-ai-managed` comes from the starter and pro-cloud plans. Every
cloud instance eligible to buy the add-on therefore saw the tile as unavailable
and had no way to reach the purchase flow that grants the feature.
* Cover per-connection credentials on the remaining providers' call paths
Every provider tested credentials through list-models and none through the call path,
which is why Mistral and Z.AI could list models under a connection's own key while sending
every chat request under the global setting.
Claude, OpenAI and OpenRouter resolve credentials inline like Mistral did, so they get the
same explicit/blank pair. Azure and Bedrock instead forward :credentials into a shared
request helper the list-models tests already exercise, so what is worth pinning there is
the forwarding: with no settings saved, the request only goes out if the passed-in
credentials arrive.
* Pin every provider type's default model
The removed settings-put-connect-*-defaults-model tests asserted this per provider; the
connection API replaced them with one test that a first connection gets a model selected,
and provider-types-test only spot-checked anthropic and azure. A typo in mistral's or
zai's :default-model would have shipped silently.
* Alias metabase.llm.test-util as llm.tu, not lct
The letters do not spell anything in the namespace, so at a use site like
lct/default-connections there is no way to tell which namespace it came from. The repo
convention for a module's test utilities is <module>.tu — lib.tu, notification.tu.
Add it to kondo's :consistent-alias map too; it was absent, which is why the alias could
be picked freely in the first place.
* Make the whole provider header toggle the usage details
Only the chevron was clickable, so the icon and name next to it did nothing — unlike
Accordion.Control, which is what makes the entire header hit the toggle everywhere else.
Promote the header itself to the control and leave the chevron as the affordance, with
the options menu as a sibling rather than nested inside it. SetByEnvVar moves below the
header for the same reason: it carries a docs link, and a link inside a button is invalid.
Not Mantine's Accordion, because expandability is per-row here — only the managed provider
has a panel, and wrapping the rest would give them a control that opens nothing.
* Let the provider modal play its closing animation
The modal hardcoded opened and the parent mounted it conditionally, so closing unmounted
it outright and Mantine never got to run the exit transition. Opening animated because
mounting still runs the enter one.
Drive it with opened instead and keep it mounted, the way ConfirmModal a few lines below
already does. Clearing the edited connection moves to onExitTransitionEnd so the title
does not flip to "Add a provider" on the way out.
* Fix the edit modal never closing
Openness was derived from isAdding || editing != null, but closing only cleared isAdding
and left editing to be cleared after the exit transition — which could not start, because
editing still held the modal open. Editing a provider trapped it.
Track openness in its own disclosure so closing is one flag, and let editing carry only
which connection to show, cleared after the transition. Adding worked before, which is why
the existing test missed this; cover closing from edit too.
* Revert the provider modal close-animation work
Reverts 7e78358857d and 1a7c37d6412. The missing close animation is not this component's:
the Modal theme attaches enter-only CSS keyframes (popInFromBottom, fadeIn) to every modal
in the app and animation.module.css defines no exit counterpart, so driving this one with
opened bought nothing but state to keep in sync — and the first attempt at it trapped the
edit modal open.
Back to mounting it conditionally. Keeps the fetch-mock teardown from the reverted commit,
which is unrelated hygiene: without it this spec's routes leak into
MetabotAgentDataSourcePills and fail it in a combined run.
* Serve a request from its own connection's credentials, nothing else
Every adapter fell back to the single-provider `llm-*-api-key` setting when
the credentials it was handed came up blank. That made sense while the two
systems coexisted, but the env vars are themselves connections now, so the
fallback could only ever fire by lending one connection another's key — a
connection whose key an admin cleared would silently keep working on someone
else's, while `connection-usable?` reported it as broken.
Resolution now fills each field's registry default into the config, so
`:credentials` carries everything the adapter needs, including the base URL
it used to read from the settings. The adapters take credentials and nothing
else, and throw when they are missing.
Tests drive the adapters the way production does — by passing credentials —
rather than by setting a global and calling with none.
* Finish removing the settings fallback: Mistral, Z.AI, and the docs
Mistral and Z.AI landed alongside the previous commit and were copied from
the old shape, so both still reached for their `llm-*-api-key` setting when
the credentials came up blank. A half-applied invariant is worse than either
state — the next adapter gets copied from whichever one you happen to open.
The docstrings across all seven still advertised the fallback I had already
deleted, including the canonical `LLMRequestOpts` contract. Code that lies
about itself in the one place people go to read the rule is how the rule
comes back.
* Drop the editorializing line from the credentials doc
The entry should say what the field is, not argue for it.
* Reject runtime writes to the per-provider credential settings
These configure a connection only when set by an environment variable, which
llm.provider/connections resolves on every read. Writing one to the app DB landed somewhere
nothing reads: the settings API returned 200 and the request kept using whatever the
connection held. The e2e specs are the proof — one pointed llm-anthropic-api-base-url at a
mock server and would have talked to the real Anthropic instead.
Mark all sixteen :setter :none, so a write raises "you cannot set X; it is a read-only
setting" instead. Tests keep working because with-temporary-setting-values passes
:bypass-read-only?; connections are managed through /api/llm/providers.
Give each a :doc, since env-var-dox drops a read-only setting from the generated docs unless
it has one, and format-config-name then labels them environment-variable-only.
The custom setters and normalize-llm-base-url are unreachable now, so they go; base URLs on
connections were never normalized, only the setting path was.
* Disable the managed-AI Connect button instead of hiding it
An admin who had not ticked the Terms box saw no Connect button at all, so
the modal gave no sign that connecting was possible or what unlocked it.
Show it disabled: the checkbox is now visibly the thing standing in the way.
The gate moves out of `connectAction`, which keeps meaning "there is nothing
to do here" — already connected, or not an admin — and stays null in those
cases so the button remains hidden.
* Let a connection name the model it serves, so Azure is selectable
Azure's listing endpoint returns the regional catalog rather than the customer's
deployments, so list-models always came back empty and the picker had nothing to offer: you
could connect Azure and never pick a model. Master collected the deployment alongside the
credentials; the connection API dropped that and nothing replaced it.
Give the registry a :model-field naming the config key holding the model, and Azure a
required Deployment field. /api/llm/models still makes the call — it is what verifies the
credentials — but reports the configured deployment instead of the empty list. The connect
form needs no frontend change: it is descriptor-driven, so the field renders itself.
Two things this pulled in. config-complete? now demands the deployment, which is right for
"can this connection serve a request" but wrong for the adapter's auth check, so that
gets credentials-complete?, which ignores the model field. And the environment path needs
to express the deployment too, or an env-configured Azure connection reports unusable, so
llm-azure-model joins the single-provider settings.
Also give the unsupported-model error a 400: the deployment is typed by hand, so a bad
prefix is a bad request, not an outage.
* Import the provider logos instead of pointing at asset paths
The filename map plus a path-building helper came from ENGINE_LOGO, but that is the older
pattern: 57 places import an SVG as a module, and DatabaseHostnameWithProviderField's
ProviderIcon does exactly this — vendor logos colocated in the component directory, imported
and mapped by provider name.
So colocate them and import them. The bundler resolves the reference, which means a renamed
or missing file fails the build rather than 404ing at runtime, and the path string stops
being something to keep in sync by hand. The map moves next to the imports, which drops
metabot/utils/llm-provider.ts and the constants entry entirely.
The per-file src assertions go: jest maps every SVG to one stub, so what is left to check
is which of the three marks renders — the Metabase logo, a vendor image, or the fallback.
* Judge the onboarding AI step on a usable connection, not the first listed
connections[0] answered three questions it could not answer well. A listed connection is
not necessarily one that can serve a request — one configured by env var with a field
missing reports usable false — so onboarding would call itself connected, hide the skip
button, and report that provider to analytics.
Ask for the first usable connection instead. The skip button also waited on the list being
empty, which is equally true while the request is in flight, so it flashed on every load;
gate it on isLoading as well.
The completed-step title inferred skipping from a missing label, but the label comes from
the provider-types request, so a connected provider with that request still in flight read
as skipped. Pass whether a provider connected separately from what it is called.
* Match the loading skeleton to the list it stands in for, and colocate it
It still described the card-per-connection layout: two 4rem blocks with rounded corners and
a gap between them. The list has been divider-separated rows for a while now, so the
skeleton was a size and a shape that no longer appear anywhere.
Rebuild it from the row it mimics — PROVIDER_ICON_SIZE plus the row's py="sm" — with a
divider between and the button that follows, and move it into AIProviderList.tsx beside
those constants, so the next layout change has it in view rather than a file away.
Cover it too: nothing asserted the skeleton rendered at all, which is how it drifted.
* Keep the Metabase mark on the managed provider when whitelabelled
LogoIcon resolves through PLUGIN_LOGO_ICON_COMPONENTS, which the whitelabel plugin fills
with the customer's logo. That is right nearly everywhere, and wrong here: this row is
Metabase's own hosted AI service, so a whitelabelled instance was labelling it "Metabase"
— the registry string is not rebranded — beside someone else's logo.
Use DefaultLogoIcon, as WelcomePage and SdkUsageProblemBanner already do where the mark has
to be Metabase's regardless of branding.
* Stop standing in for an unknown provider with a real one
Gemini is a provider we could plausibly add, and the day we do, every test asserting it is
unknown starts asserting the opposite of what it means. Use a name nobody will ship.
Same for the Azure family placeholder, which picked gemini for a family Azure does not
serve. The genuine google/gemini-2.5-flash model references stay.
* Match on the secondary button instead of nesting a && inside a ternary
isEditing ? (onCancel && <Cancel/>) : <Back/> reads as two branches but is three, and the
one that renders nothing is the one you cannot see. The rest of this component already
picks its shape with match, so this now does too — and onCancel is narrowed by the pattern
rather than guarded separately.
* Pick the Azure model provider from a dropdown again
The rewrite collapsed master's Model provider select and Deployment name
input into one text field where the admin types `openai/` as a prefix. Split
them back apart and compose the model from the two, so the wire family is
picked rather than spelled.
`:model-field` becomes `:model-fields`, a vector composed with `/`, which is
the shape the Azure adapter already parses back out. A required field the
registry gives a `:default` now counts as filled — the form shows it
pre-selected, and resolution supplies it — so the family select does not
block Connect on a connection that simply accepted it.
* Drop two explanatory comments
Both restated what the line below already says.
* Shrink the loading skeleton to the row's content height
The skeleton painted the full 3rem row, padding included, so it read as two
heavy slabs butted against the divider. Wrap it in the row's own py="sm" and
size the bar to the provider icon instead: the row still occupies the same
height, so nothing shifts when the list arrives.
* Name the provider on the closed model picker
A model name alone does not say who serves it, so the picker gave no way to tell which
provider was selected without opening it. With several connections — two Anthropic keys,
say — that is the part you most need to see.
The option label carries the provider, since that is what a closed Select renders. The
dropdown keeps rendering the model alone through renderOption, because the group heading
above it already names the provider.
* Only treat a usable connection as one worth moving on from
Setup moved past the provider picker as soon as any connection was listed,
while the step around it counted only usable ones. A connection that exists
but cannot serve a request landed between them: the picker was replaced by a
model dropdown with nothing in it, the step still offered to skip, and there
was no way to connect anything.
That is reachable, not theoretical — the single-provider env vars synthesize
a connection as soon as one credential field is set, so MB_LLM_AZURE_API_KEY
on its own produces one Azure needs three fields to use.
* Serve the disconnect test's stream from the connection under test
The fake OpenRouter server was reached through the per-provider base-URL
setting, which no longer feeds a request now that credentials come from the
connection: the stream went to openrouter.ai and the turn finalized as an
error rather than an abort.
Disabling clj-http decompression on the LLM request also had to move from
http/post to the fn the adapters actually call, so the aborted stream no
longer races a ZLIB EOF against the cancel.
* Stop shipping an icon name the frontend never reads
The frontend keys its logos on the provider type and fails to compile for a
type it has no logo for, so the registry icon was only ever reachable for a
type the server knows and the client does not — which cannot happen when the
two ship as one artifact. Seven of the eight entries said "ai" anyway, and
the eighth said "metabot" for the managed provider, which is drawn from the
Metabase logo component before the icon is consulted.
Drop it from the registry, the response, and the type, and let
ProviderTypeIcon own the generic fallback it was already defaulting to.
* Point the setup wizard's AI e2e tests at the connection API
All three drove the single-provider flow this branch replaced: a Provider Select that is
now a grid of buttons, and PUT /api/metabot/settings, which no longer exists. The Anthropic
one also aimed the mock server via llm-anthropic-api-base-url, which stopped being writable
when those settings became read-only — it now types the base URL into the connection's own
advanced settings, which is where a connection reads it from.
The managed test additionally stubs GET /api/llm/provider-types and /api/llm/providers: the
managed type is only connectable once the LLM proxy is configured, and that has no backing
in e2e, so it is patched the same way the token features already are.
* Match the required API key label the way the unit specs do
Mantine appends a required marker to a required field's label, so the setup
spec's exact-string lookup found nothing once the API key field came from the
provider registry with :required? true. The unit specs already match on a
regex; the e2e spec was the last one on the bare string.
Also name do-with-entitlements! for the thread-unsafe settings it binds, which
Kondo flags.
* Edit the stored connection list rather than the environment-resolved one
The environment winning on read is right, but writes rebuilt the list from
connections, where an env-configured connection replaces the stored one with
the same key. Saving anything else then dropped the shadowed credentials from
llm-providers for good, so taking the env var back off did not restore them.
Editing the stored list also lets PUT report a shadowed key as env-configured
instead of 404ing, and stops the startup managed-connection write from landing
in the app DB when the whole list comes from MB_LLM_PROVIDERS -- which would
have pointed Metabot at a connection that never resolves.
* Configure an Azure deployment from the environment the way the form does
MB_LLM_AZURE_MODEL landed under a :model config key that nothing reads --
azure declares :model-fields [:model-family :deployment-name] -- so an
environment-configured Azure connection composed no model and counted as
incomplete, which hid the Metabot UI instance-wide behind
llm-metabot-configured?. Replace it with one variable per config key.
The same completeness rule dropped Azure from the startup adoption entirely,
because the credentials that existed before this branch never named a
deployment: they carried it in llm-metabot-provider. An upgraded instance was
left pointing at a connection that did not exist, with Metabot failing on every
request. A deployment names what to call rather than what authenticates the
call, so stop requiring it, and recover the pair from the model reference
during adoption so the connection can still offer it in the picker.
* Add a provider alongside the managed one instead of cancelling it
Running out of included tokens is a reason to bring your own key, not to end the
subscription. "Use a different AI provider" was wired straight to the add-on
cancellation: it charged ahead with no confirmation, offered no provider to
switch to, and left the metabase connection and llm-metabot-provider pointing at
each other afterwards, so nothing on screen changed.
Let the button open its own modal again. AIProviderSetup short-circuits to the
model picker once any connection exists, which is exactly wrong here, so let a
caller start on the connection form; the managed connection is left alone and
the model picker follows the save. Removing the connection outright is already
the list's Remove action, which cancels the add-on server-side.
* Let the single-colour provider marks follow the theme
The marks were loaded through <img>, and an SVG in an <img> is its own document:
currentColor resolves against that document's initial color, black, with no way
to reach the color the tile sets. Anthropic, OpenAI and OpenRouter therefore
painted black on a near-black tile in dark mode, and Bedrock's navy "aws"
wordmark left only the orange smile visible.
Inline those four instead, so currentColor picks up the tile's text colour --
dark on light, light on dark, which is how each brand publishes its monochrome
mark. Mistral, Z.AI and Azure keep their own colours and stay images. Bedrock's
wordmark becomes currentColor, reproducing AWS's own light and dark variants.
OpenRouter pointed at a clipPath the file never defined; harmless while it was
sandboxed in an <img>, but an id to collide with once inlined.
* Optimize the provider logos and normalize them to one size
The files came from seven different sources and showed it: viewBoxes ranged from
24x24 to 512x512, some carried fixed width/height, and each baked in its own
arbitrary padding. Rendered into the same 20px tile the marks came out anywhere
from 12px to 20px tall, so the row read as a jumble.
Run everything through SVGO (viewBox kept, dimensions and editor cruft dropped),
then re-cut each viewBox to a square centred on the mark's real ink extent. Every
mark now fills the tile's width, with padding controlled by the tile alone rather
than by whoever exported the file. Z.AI is inset 12%: its mark is itself a filled
rounded square, so at an equal bounding box it reads far heavier than the open
marks.
* Say when the provider lists fail to load
Both components defaulted the two queries to empty arrays and never read
their error, so a failed request was indistinguishable from having nothing
configured. The provider-types failure was the worse of the two: it rendered
an empty picker grid — no providers to choose, no explanation, no way on.
The connections failure is quieter but wronger, since an admin with several
providers would be told to connect their first one.
Report the error where the list or the picker would have gone, using the
same getErrorMessage the rest of this feature already uses.
* Tighten the provider section's spacing and hang the icon off the name
Sloan flagged three gaps as feeling high even where they match other sections: drop the
section's header gap from md to sm, and the gap out to the model picker from lg to md. The
row-to-button gap was already the tightest of the three and stays.
The icon was centred against the whole text block, so it sat slightly off the name and
drifted further as an error or env-var line appeared beneath it. Give the name the icon's
height and centre it there, so the two line up on one row however many follow.
* Collapse the add button's box, which was most of the spacing
Trimming gaps barely moved this because the space around "Add another provider" was not a
gap. Metabase's Button defaults to size md, so it is 42px tall; p={0} removes the padding
but not the height, leaving its ~20px label centred with about 11px of dead space above and
below it. That is the two gaps flagged around the button, and no gap value could reach them.
Let it take its label's height once it is an inline link. Rows go from py="sm" to "xs" as
well, since a 32px icon does not need 8px above and below it.
* Give the add button its height back
Collapsing it to its label bought vertical space but cost the button its shape. Keep the
tighter rows from the same change.
* Draw every provider logo on one 24x24 grid
Making each viewBox square left seven different squares -- 24, 88, 300.23, 256,
259.53, 511, 30.72 -- so the files only matched once a consumer sized them. Bake
each mark's scale and centring into its path data instead, so every file is
literally width=24 height=24 viewBox="0 0 24 24" with the mark centred on that
grid and its longest side spanning it.
Z.AI keeps its 12% inset, for the same reason as before: its mark is a filled
rounded square and reads heavier than the open marks at an equal box.
* Sit the row actions on the icon band and cut the gap to the add button
* Fit a provider's name and type inside the icon's height
* Give each provider row a little more room
* Inline the remaining provider logos instead of loading them by URL
Mistral, Z.AI and Azure rendered as broken images. Their files are valid; the
copies the bundler emitted were not. BannerPlugin is configured with no test, so
it prepends the licence header to every emitted asset -- and re-applies it on
each watch rebuild, so the served .svg started with six stacked comment blocks
before its <svg>, which no browser will parse as an image. Editing these files
repeatedly is what re-emitted them and made it visible.
Inline all seven the way the other four already were: SVGR turns them into
modules, so nothing reaches the asset pipeline and one import style covers the
set. Azure's gradients were on ids a/b/c, which are too generic to share a
document with, so they are namespaced now.
The banner covers every other emitted asset too, so any .svg re-emitted during a
dev session hits this; that is worth fixing in the build config separately.
* Call the managed provider Metabase AI service and tighten the add-provider modal
Drops the redundant 'Selected provider' subtitle and 'About Metabase AI service'
heading, puts the free-token and pricing lines in their own callout, and swaps the
row's ellipsis button for a neutral ActionIcon.
* Pin the base URL the credential-adoption tests read
* Mock the provider endpoints the onboarding AI modal calls
* Give the explorations e2e helper a provider connection instead of read-only settings
* Make Moonshot AI a provider type you connect to
* Regenerate the settings docs
* Answer the review on the model listing endpoint
- Drop the per-provider :group heuristics. The picker groups by connection
name and never reads the field, so it was computed on every admin page load
for nothing.
- Key the model cache on a hash of the connection's config rather than on the
config itself, so a credential is not held in a process-wide cache, and a
rotated one gets its own entry instead of the old list.
- Take the model a connection composes from its own config into account when
Metabot's selection has to move: Azure has no default model, so it was never
a fallback, and editing its deployment left the selection naming one that no
longer exists.
* Migrate the per-provider credential settings instead of adopting them at startup
The startup adoption left the settings it copied from behind, frozen: nothing
writes to them any more and they are read-only, so an instance rolled back to
code that still reads them would run on whatever was last stored there rather
than on what is configured. A reversible migration fixes both ends — it clears
the settings on the way up, and writes the connection list back into them (and
rewrites a model reference older code cannot resolve) on the way down.
Also pulls the copy of `trimmed-string` the two LLM namespaces shared into
`metabase.util`.
* Wait for the model list before opening the picker in its spec
* Make Google Gemini a provider type you connect to
Carries the Gemini Enterprise Agent Platform provider over to the connection
list: a registry entry with the same form every other provider gets, credentials
resolved from the connection rather than from the `llm-google-*` settings (which
become environment-only like the rest), and the connection naming the model it
serves — the platform has no listing endpoint worth trusting, so connecting
validates the model with a free countTokens probe, the way Azure validates its
deployment.
* Cover the Google credentials in the settings migration
* Name the Gemini models Metabot works best with
* Use Moonshot AI's own mark, not Kimi's
The file was Kimi's rounded-square K — Kimi is Moonshot's chatbot, not the
company we connect to. Icon sets ship the two separately and it is an easy
one to pick wrong.
Replaced with the MoonshotAI mark, kept in the same shape as the other
single-colour logos here so it takes the theme's text colour.
* Give Google the form the upstream PR built for it
Three field kinds the registry could not express before, so the connection form
can offer what the settings page did:
- a fixed model list, replacing the free-text field and the paragraph of advice
about which IDs work;
- an authentication method to pick, rendered as a segmented control;
- a file to upload for the service account key, read into the config the way
every other credential is typed into it.
A field can now hang off another one's value (`show_when`), which is what makes
the credential follow the method — and a field the form hides is cleared on
save, so switching methods drops the credential it replaced.
* Let Gemini's models come from the shared picker
A connection only names its own model when nothing else can name it — Azure,
whose deployments its listing endpoint does not return, and which serves one of
them. Gemini's models are a known list, so pinning one per connection put a
second model picker inside the form and hid the other model from the one below
it. The list becomes the type's fixed catalog instead, the way the managed
provider's is, and both models show up per connection in the model picker.
A fixed catalog no longer skips the provider call: that is what verifies the
credentials, so it is made against the catalog's first model and the catalog is
what comes back. The managed provider still answers from the registry alone —
there is nothing to call, and instances that cannot reach the proxy would fail.
* Answer the migration review notes
Rename the frozen table's :credentials to :required-keys — they are the keys a
provider needs to count as configured, and "credentials" made the optional ones
sound like something else.
Stop the downgrade encrypting every value it writes. write-setting! ran
maybe-encrypt unconditionally, so base URLs, regions and the model reference —
settings declared :encryption :no — came out of a rollback holding ciphertext.
Reads decrypt opportunistically so nothing broke, but a setting should hold what
its declaration says it holds. Only the :sensitive? settings are encrypted now,
and a test pins the raw storage both ways.
The Google row is also renamed to Google Gemini Enterprise alongside the
registry, to keep the adopted connection's display name in step.
* Close the review's holes in the provider registry and its API
Redact the Google service account key. secret-field-keys covered only
:password fields, and the key is a :file upload, so GET /api/llm/providers
returned the whole credential — private key included — in plaintext. Fields can
now be marked :secret? regardless of input type, which also makes the masked
echo-back flow cover the key on edit.
Require what Google actually requires. Every field was individually optional
because either credential will do, so an empty config counted as complete —
usable: true on a connection that can serve nothing. Provider types now declare
alternative credential groups (:required-any): a service account key on its own,
or an OAuth token together with the project ID it bills to. Completeness and
validation both honor the groups.
Treat blank credentials as absent. The form clears the field it hides when the
authentication method changes, but the empty string it sent was stored and
passed to the adapter, which fed "" to the service-account parser — the
"no JSON input found" from the review. Blank values are dropped on save, and
the adapter trims what reaches it so an env-supplied blank cannot shadow the
other credential or the project ID a key carries.
Reserve the metabase connection key. Any type could claim it, splitting
managed-model-ref? from managed-type? — a bring-your-own-key connection whose
requests would be tagged as proxied in metabot_message and confuse the managed
free-tier limit. Only the managed type may hold the key now, a squatter blocks
managed connection with a clear message instead of colliding, and the unused
proxied-model-ref? is gone.
Also: per-field :validate hooks in the registry (Google's project ID and
location get the setting-specific validation the old defsettings had), select
values are checked against their options, with-entitlements takes a kwargs map,
and the cond-> in self.clj is realigned.
* Pick the Google model in the connection form
Connecting a Google provider validates the credentials with a countTokens probe
that needs a model, but the form collected none, so the probe ran against a
hardcoded default the chosen location may not serve — a confusing rejection the
admin had no control over, and no guarantee the model they wanted works at all.
Provider types now ship their fixed catalog and alternative credential groups to
the frontend. A type with a catalog gets a model picker in its connection form,
the probe runs against the picked model, and Metabot starts on it. The Connect
button also honors the credential groups, so it no longer offers to connect a
Google provider with nothing filled in.
* Quiet the kondo warnings CI caught
u/lower-case-en over str/lower-case in the reserved-key check, and the
validate-config! additions split into their own deftests instead of growing the
original past the one-concern-per-test lint.
* Report the provider Metabot landed on, not the first usable connection
The setup step resolved its connected provider with find(usable) over the
connection list, which is insertion order. Connect two providers during setup
and pick the second one's model, and the step still reported the first — in its
title and in what submitAiConfig records. Resolve the connection from the
llm-metabot-provider reference instead, the way the pre-connection-list code
compared against the saved value.
* Spell the field-level secret flag :sensitive?
Same word as the defsetting option it mirrors, so a grep for :sensitive?
surfaces the registry's secret fields alongside the settings.
* Give the LLM settings migration its own test file, and a managed round trip
Twice now a reviewer could not find the migration's coverage, because the tests
sat in the shared custom_migrations_test and referenced the migration only by
its changeset id. They live in a file mirroring the migration's path now, with a
docstring naming the change class that connects the two.
The round trip crisptrutski asked about also surfaced what he predicted: the
managed connection is not in the migration's provider table, so every rollback
of a managed-AI instance logged a false 'no equivalent setting: metabase'
warning. It holds no credentials and its model reference survives the rollback,
so it is dropped silently now, and a new test walks the managed round trip.
* Let the environment shadow connections field by field, and settings write through
Answers crisptrutski's review of the environment and settings story.
The MB_LLM_* variables now shadow a stored connection's config per field, the
way an env var shadows any other setting, instead of swapping the whole
connection out. A lone MB_LLM_ANTHROPIC_API_BASE_URL reaches the stored
connection's base URL rather than doing nothing; everything the environment
does not supply stays editable, and env_fields tells the form exactly which
inputs to disable. A standalone env connection still only comes into existence
when a credential variable is set — which is why Azure's base URL is no longer
marked as one: alone it used to synthesize an unusable connection that shadowed
the stored credentials wholesale. A metabase/ reference pinned by
MB_LLM_METABOT_PROVIDER synthesizes the managed connection it names, so that
reference resolves without an app-DB row.
The per-provider settings are a view over the connection list rather than
read-only. :setter :none broke config.yml provisioning outright — the
provisioner calls setting/set! on every entry and nothing catches the throw, so
the instance failed to start — and orphaned generate-sql and semantic search,
which still read llm-anthropic-api-key and llm-openai-api-key directly after
the migration deleted the rows underneath them. Getters resolve the connection
field with the environment and registry defaults layered in; setters write
through to the connection, creating it on first write and keeping the old
setters' field validation. Both docs files are regenerated: the config
template carries the settings again.
Also from the review: deleting the managed connection requires superuser — it
cancels the Store subscription, and everything else that can cancel add-ons is
superuser-only — and the confirmation dialog says so; the automatic
llm-metabot-provider writes (first connection, edit follow-up, delete
fallback) are skipped when the env var pins the selection, restoring what
check-not-env-shadowed! used to guarantee; the edit form starts the model
picker on the model the connection is actually serving and a changed pick
follows through to the selection for fixed-catalog types; and redact masks
every field of a provider type the registry does not recognize, instead of
none of them.
* Reconcile the restack onto bot-1875-gemini-models-fe
Mechanical conflict resolutions during the rebase left a few files at the wrong
side of the divergence; this restores the reviewed merge result: the shared
tool-function conversion in the Claude and OpenAI adapters, the settings
endpoints stripped from the relocated metabot API slice, and
gemini-3.7-flash — new on the Gemini branch — joining the fixed catalog.
* Restore the stop-reason propagation the restack dropped from the adapters
* Strip trailing slashes off base URLs when a connection resolves
* Slug-proof the reserved key, follow the effective config, and seed the models cache
* Count a blank-config connection as dropped on rollback
* Warn that deleting the anthropic or openai connection turns off the feature reading it
* Regenerate the environment variable docs against current master
* Drop the env-var doc section master removed in the backend hardening
* Update src/metabase/llm/settings.clj
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Mark the dev rollback subqueries so the app-DB honeysql guard allows them
* Gate the settings-access delete test on EE, where advanced permissions exist
* Wait for the AI setup buttons to render before clicking them
---------
Co-authored-by: Mike Appleby <86076+appleby@users.noreply.github.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
* Add a versioned embedding provider SPI
Summary
- Add a runtime registry for embedding providers with readiness, model resolution, preparation, and batch embedding.
- Define immutable embedding-space descriptors and legacy deterministic identities.
- Validate provider results, dimensions, counts, and finite numeric vector components.
- Make SPI version 1 a compile-time constant; provider artifacts must embed the literal version they were built against.
Scope
This defines the neutral provider contract only. It does not migrate semantic-search consumers or ship a provider plugin.
Testing
- Registration and version compatibility.
- Identity stability and revision round trips.
- Registry snapshot consistency and malformed provider output.
* Rename shadowing local in provider/prepare!
The local bound from :prepare! shadowed the prepare! var (eastwood local-shadows-var).
* Add the Arctic embedder plugin
Summary
- Build a separate runtime plugin containing the pinned Snowflake Arctic embedding model and DJL/ONNX dependencies.
- Bundle arm64 and AVX2 exports, lazy-load native inference, and cap local batches.
- Register literal embedding SPI version 1 and derive immutable space identity from the exact model/runtime bundle.
- Fail readiness closed for unsupported platforms, musl, and glibc older than 2.34.
- Disable DJL network telemetry and revision-address bundle URLs so model upgrades cannot reuse stale cached content.
Scope
This PR ships and registers the provider but does not yet select it from semantic-search settings. Consumer integration follows upstack.
Testing
- Model bundle and plugin uberjar build tests.
- Catalog, classloader, platform-readiness, identity, cache-key, and batching coverage.
- Artifact-only real inference with zero outbound network attempts.
* Follow prune-provided-libs! rename in the embedder plugin build
* Merge nested let in embedder-plugin artifact test
Kondo flagged the inner let as redundant; fold both binding vectors into one.
* Deref the now-delayed core provided-libs in the embedder build
* Bundle MiniLM for in-process complexity scoring
* Activate the embedder plugin in its artifact smoke test
* Activate lazy embedding providers on demand
* Use plugin initializer in artifact smoke test
* Make lazy provider activation best effort
* Log lazy embedding provider activation failures
* Declare the embeddings plugin dependency
* Keep embeddings provider internal
* Activate the in-process embedder during bootstrap
* Fix embedder validation and optional plugin startup
* Declare the embedder plugin as a module
The deps graph only scanned src, enterprise/backend/src, and modules/drivers, so the plugin's
namespaces belonged to no module and anyone linting them got six unresolved-dependency
warnings. Scanning modules/embedder/src too lets the generator record what the plugin actually
takes from the host.
* Move embedder model caching into the weekly cache generator
Review feedback on #78310:
- Drop the redundant persist-credentials: false checkout options.
- Stop writing the model cache from the CI job. The weekly cache-generator
workflow is the single cache writer; the build job now restores only.
The downloader is content-addressed (SHA-256 verified, refetches deltas),
so a stale weekly cache still protects against HuggingFace rate limits
without affecting correctness.
* Bundle snowflake-arctic-embed-xs instead of Arctic-L
The bundled models are a test tier: they exercise the packaging, DJL
runtime, tokenizer, and pooling machinery without shipping the 1.1 GB
Arctic-L artifacts. xs keeps the same family, query-prefix scheme, and
cls pooling as Arctic-L at 23 MB per architecture. A release-tier build
with the full model comes later, when the plugin is published for
customers or offline use.
At ~90 MB of pinned downloads there is nothing worth caching, so the
cache-generator wiring is reverted and the CI job downloads directly.
* Fail loudly when a catalog entry lacks this architecture's export
resolved-model computed the embedding-space-id from
get-in spec [:architectures arch :sha256] without checking it was
present. A model missing this architecture's export would silently
bake a nil into the identity hash instead of failing, producing a
syntactically valid but bogus embedding-space-id.
* Pin the embedder plugin manifest name to one constant
"Metabase In-Process Embedder" was duplicated in core.clj (the
registered?/load-plugin! lookup key) and the artifact test. A rename
in modules/embedder/resources/metabase/embedder/metabase-plugin.yaml
without updating both call sites would silently disable the plugin,
since registered? just returns false for a name it doesn't recognize.
* Downgrade the hashed-table-name log line to debug
Hashing fires deterministically for every identifier derived from a
long provider/model pairing (e.g. "in-process" + the bundled xs
model), so it was logging a warning on every lookup rather than once
at creation.
* Cover the missing-architecture catalog guard
The error path added alongside the guard had no test, so the earlier
silent nil-hashing behaviour could come back unnoticed.
* Assert the reason on the missing-architecture guard
The message alone leaves `:reason :architecture-not-bundled` untested,
which is the part callers actually branch on.
* Drop the hashed-table-name log line
It fires from a pure name-derivation helper on every lookup. The table
creation it hints at is already logged at info by the caller, and the
parameters behind the hash are in index_metadata.
* Extract the missing-architecture check out of the smoke test
The guard's assertions pushed the smoke test to 155 lines, past the
150-line deftest limit, so Clj-Kondo failed. Pull it into a helper the
way the platform-support checks already are.
* Update README with new image URL and bug reporting info
Updated the image URL for the Metabase product screenshot and added a bug reporting guide.
* put metabase-product-screenshot-updated.png to the docs
* fix link in the readme
---------
Co-authored-by: Timur Mashara <timur@metabase.com>
* Use Breadcrumb component in Detail View (GDGT-2596)
Detail View had its own Breadcrumb, duplicating the shared one introduced
in #75114. Point TableNav, ModelNav and CollectionBreadcrumb at
metabase/common/components/Breadcrumb and delete the local copy.
The shared component hardcoded showTooltip={false}, which would have lost
the full-text tooltip Detail View shows on truncated crumbs, so make it a
prop. It defaults to false, leaving every other consumer unchanged.
Crumbs now take the shared component's sizing (0.875em, bold) instead of
inheriting 14px regular; the separator is sized to match so the row stays
consistent. Colour and icons are unchanged.
* Use URL helpers for the Detail View breadcrumb links
TableNav built the database and schema URLs by hand. Extract databaseSlug
from browseDatabase so browseSchemaBySlug can reuse it, then build both
links from the helpers. Both now carry the database slug rather than only
the id.
* Style official collections in the Detail View breadcrumbs
Collection crumbs hardcoded icon="folder", so an official collection
rendered like a regular one. Use CollectionBadge, which resolves the icon
from the collection's authority level, the same way the model page does.
Drop the text-secondary override too: it made Detail View darker than
every other breadcrumb in the app bar.
* Align Detail View breadcrumbs with the rest of the app bar
The detail view nav carried a -13px left margin, putting its first crumb
13px closer to the logo than the collection breadcrumbs shown on every
other page. Drop it so both start at the same offset.
* Match the Detail View breadcrumb separator to the app bar
Its color, font size and line height all differed from the separator the
collection breadcrumbs render. Use the same values, and Box rather than
Text so it inherits the line height instead of setting its own.
* Update the database breadcrumb href expectation
The database crumb now goes through Urls.browseDatabase, which appends
the slug, so the link is /browse/databases/1-sample-database rather than
/browse/databases/1.
* Make query builder breadcrumb text the same size (GDGT-2609)
The database and schema crumbs rendered smaller than the rest of the
breadcrumb. HeadBreadcrumbs spread {...rest} after className, so a caller
forwarding className -- even as undefined -- wiped the computed Container
and headVariant classes, and those crumbs fell back to Breadcrumb's
0.875em. Merge the incoming className instead of letting it clobber.
Size the head-variant dividers and the leading icon to match the crumbs
too, so the whole row reads as one line of text.
* Address review comment
Keep the props spread last and exclude className from it, rather than
reordering the spread ahead of className.
* Stub the evals trigger
* use ref arg
* dummy to trigger build
* Clean up
* Use appropriate token
* Simplify pr message
* Use .evals_ref for default ref
* Comment cleanup
* Adjust ref
* Use evals branch
* Use general automation token
* readme for .evals_branch
* Use main of evals for label trigger
* Revert "dummy to trigger build"
This reverts commit e6502c6ff076a47d94eb829b516fc45b37cd7680.
* whitespace
* Address review remarks
* Use ai benchmark naming so we can trigger from pr
* Address review comments
* Remove now redundant branch pinning
* Review remarks
* Enable passing commit to uberjar from other workflows
* Unify ai-benchmark and pr-env build logic
* linter
* checkout action
* separate build step
* Use wip branch
* adjust job output
* Make image building manually triggerable
* tmp comment
* Address review remarks on the build extraction
- ai-benchmark's `image` regained the label guard `uberjar` used to carry; without
it any label on any same-repo PR built ee+oss and pushed to ECR.
- New `resolve` job turns the dispatch `pr`/`ref` inputs into one full sha, and
feeds it to both the image build and the eval-run dispatch. `pr` was ignored
before, so `pr: N` benchmarked the launch branch while reporting against the PR;
`ref: master` looked for an artifact named after a branch.
- prepare-uberjar-artifact takes a `commit` so the upload key matches what
check-existing-artifacts/fetch-artifact look up. Without it a build for an
explicitly passed commit uploaded under the event sha and then failed to
retrieve itself.
- uberjar's build checkout honours an explicit commit, but stays on the event ref
otherwise so PR builds keep compiling the merge ref.
- Concurrency group includes the calling workflow, so a re-label on one caller no
longer evicts the other's queued run.
- ECR existence check targets the pushed registry explicitly and no longer hides
real errors behind "not found".
- Scoped `secrets: inherit` down to the two secrets dockerize needs, added
read-only default permissions, dropped the dead `jar xf`, restored the comment
explaining `finalize`, and reset EVALS_BRANCH to main.
* Point smoke evals back at the evals-docker-registry branch
Temporary while metabase/evals#32 is unmerged — the ECR image logic is only on
that branch. TODO comment retained so this cannot merge as-is.
* Align ai-benchmark pr/ref precedence with evals eval-run
eval-run.yml resolves both inputs with `ref` overriding the PR head; this
resolved `pr` first, so the same two inputs meant opposite things either side of
the dispatch. Flip precedence and the run-name to match, and point the `ref`
description at eval-run's containment rule instead of claiming pr wins.
* Cleanup the uberjar worfklow
* Smoke should be triggered on !cancelled()
* Reset pointer to evals branch
* Give List, Map, ObjectDetail and PivotTable their own definition file
* Let a visualization register a loader for its component
* Load every chart component on demand
* Load the chart chunk while the query is still running
* Delete the echarts lazy boundary the chart split made redundant
* Dedupe modules shared by the SDK's async chunks (#79748)
The chunked SDK entry's splitChunks filter only matched chunks whose
name starts with "embedding-sdk-chunk", so the unnamed chunks created by
import() were excluded from splitChunks entirely. With every chart now
loaded through import(), each echarts-based chart chunk bundled its own
copy of echarts, zrender and the shared viz code, growing the reachable
SDK total from 3.3MB to 6.2MB gzipped.
Include async chunks in the filter and add an asyncCommons cache group
(minChunks: 2) so shared modules move into a common async chunk. The
vendors group keeps its own name-based filter so async modules are not
merged into the eagerly loaded vendor chunks. Initial payload is
unchanged; the legacy monolith and the bootstrap stay excluded.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Lower the size floor for the SDK's shared async chunks
* Load chart components before a story renders
* Retry a chart chunk that fails to download
* Keep one loading state while a chart chunk loads
* Delete the leaflet and pivot-table lazy boundaries the chart split made redundant
* Say why onInit is late now that the renderer is not lazy
* Register the charts a test renders before it runs
---------
Co-authored-by: Nicolò Pretto <info@npretto.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Adapt the collection bulk action bar to pinned, unpinned, and mixed selections
* Align the bulk action bar per-state actions with the Figma spec
* Show the bulk action overflow for unpinned selections and fix its trigger styling
* Move bulk action logic into UnarchivedBulkActions and address review feedback
* Center the bulk action overflow trigger icon via a label class
* Make BulkActionButton code nicer
* Address review comments
* Don't test MySQL/MariaDB AppDB on anything but latest outside release/master.
* generate matrix up front
# Conflicts:
# .github/workflows/app-db.yml
* bugfix
* parameterize it all
* make things more uniform
---------
Co-authored-by: Ryan Laurie <iethree@gmail.com>
* Use an isolated executor pool for test isolation
* Fix another test
* Fix some other likely flaky tests
* Consistently use the mock executor in the tests that need isolated behavior.
* Check via .getDataset instead of .listDatasets when all datasets are exactly named
* Use perf/every?
* Handle some corner case differences between .listDatasets and .getDataset method behaviors
The `latest_qe` CTE joined `query_execution` to its per-card max
`started_at`, which returns every execution tied at that timestamp
rather than one. A card whose two most recent runs landed in the same
clock tick therefore fanned out into two identical rows on
`/monitor/errors` — same card_id, duplicate row keys in the table, and
an inflated `total_count`. Rank the tied executions and keep one.
Also give the ORDER BY a unique tiebreaker: no sort column is unique,
so tied cards were ordered arbitrarily and could repeat on one page of
LIMIT/OFFSET results while going missing from another.
* Update SelectButton styling to match Select (#GDGT-2705)
SelectButton diverged from the new Mantine Select: bolder text, a smaller
lighter chevron, a taller radius and, on long values, wrapping to two
centered lines instead of truncating.
Match the md Select input on every measurable property — 40px height, 4px
radius, regular weight, left aligned, 16px chevron in text-primary, and
Select's disabled tokens — and truncate long values with an ellipsis. The
clear affordance now sits alongside the chevron rather than replacing it,
matching Select's layout.
Migrate the component from Emotion to CSS modules while here.
Also fixes three Select issues surfaced by comparing the two side by side:
- long values cut off mid-glyph instead of ellipsizing
- with a clear button, the value ran underneath it, because the right-hand
padding was pinned to a flat 40px while Mantine widens that section to
60px (md) / 41px (xs)
- the disabled right section painted an opaque background over the field's
translucent disabled fill, showing a square-cornered box on any
non-white surface
* Remove comment
* Address review comments
- use !! instead of a null check, onClear is never null
- drop size={16} on both icons, it is Icon's default
- drop overflow: hidden from .SelectInput; the input already computes
overflow: clip, so the declaration never applied and the ellipsis works
without it
- cover sm, lg and xl in the clear-button padding fix, not just md and xs
* Stop hydrating the collections entity slice
* Delete the unread collections entity slice
* Drop the orphaned createMockEntitiesState import
* Wait for the collection query in the TableCollection link test
* Await the collection tree in the SavedEntityPicker tests
* security center: don't let a failed matching query override the version check
an advisory whose matching query names a table the instance doesn't have yet
fails at parse time, and we mapped that to :error. the notification task treats
:error the same as :active, so instances that the version range had already
ruled out got notified anyway. that's how SC-2026-015 reached 1.58.
affected means in-range AND matched, so an undeterminable query result shouldn't
outrank a version check that already said no. same as SQL giving FALSE AND NULL
= FALSE. in-range instances with a query we genuinely couldn't run still report
:error.
* security center: split evaluate-advisory!-test to stay under the kondo length limit
* Read database and field display data from RTK Query, not state.entities
* Read table and database breadcrumb data from RTK Query, not state.entities
* Delete the legacy useTableQuery and useDatabaseQuery entity hooks
* Delete the legacy useDatabaseListQuery entity hook
* Keep FileUploadStatus on the mirror-backed table hook
* Add Gemini Enterprise Agent Platform provider settings
* Invalidate the cached models list when credentials change
A credential write can change which models the provider serves, e.g. a different Bedrock region, Google location, or
Azure resource.
* Give the FileInput placeholder and clear button a visible color
The theme resolves these to transparent making them invisible withouth the override.
* Review feedback: whitelist recommended models
* Fix useUpdateMetabotSettingsMutation import
* Add gemini-3.7-flash to list of recommended models
* Add llm-google-* settings for the Gemini Enterprise Agent Platform
The location, project ID and model become path segments of every request, so each is validated as URL-safe.
* Wire the google provider into Metabot provider settings
* Add Google Gemini provider via streamGenerateContent API
Add new provider supporting Gemini models via the Gemini Enterprise Agent Platform
- New ns `metabase.metabot.self.google`. Handles generic provider concerns like
- contructing appropriate endpoint urls for the given model and location
- validating caller-provided credentials via a countToken call
- credentials handling
- validating config
- remapping upstream errors with improved error messages
- toplevel request entrypoint
- New ns `metabase.metabot.self.google.stream-generate-content`
- handles wire format and aisdk chunk tranlation for Google's streamGenerateContent API
* Wire Google credentials into the admin API
Optional credential settings that are set by an env var no longer block connect or disconnect.
* Skip the provider setting write when shadowed by an env var
* Drop the unused llm-bedrock-configured? setting
Nothing read it: the frontend never referenced it, and its last backend caller went away when
configured-provider-credentials switched to provider-credentials-complete? for the bedrock branch.
* Extract the shared tool->function conversion into schema/tool-function
The mu/defn doc-stripping + filter-schema-by-features + JSON Schema transform boilerplate was copy-pasted across the
claude, openai, chat-completions, and google adapters.
* Review feedback: allow settings to be cleared even if env shadowed
There was a mismatch where check-not-env-shadowed! allowed nil values but set-setting-unless-env-shadowed! did
not. Update set-setting-unless-env-shadowed! to preform nil writes so that a disconnect fully clears the appdb
settings even if some are env shadowed.
Change `get_transform_python_library_details` to read from `:source` instead
of `:content` to match what the API sends. The code is wrapped in a Markdown
fence longer than any backtick run inside it, prefixed with a line telling
the model to treat it as data, and cut off at 100k characters with a
truncation marker when the library is bigger than that.
* Rename dashboard-click-drill and its unit spec to TypeScript
* Convert dashboard-click-drill to TypeScript
* Address review feedback: discriminate click-behavior types and replace non-null assertions with checkNotNull
* Deduplicate base intersections in click-behavior union types
* Keep remove-table button in the row when table names are long
The table selector laid its label button and remove button out in a Group
that wraps by default. The label button is a flex item, so it kept the
default min-width: auto and refused to shrink below the width of its
longest line. A long database or schema name made it claim the whole row
and pushed the remove button onto a line of its own, at the left edge.
Stop the group wrapping, let the button shrink, and truncate the label
lines instead. The lines use max-width rather than width so a short line
keeps the left alignment the Stack gives it -- Button centers its label,
so a full-width line would be centered.
Also space the two label lines apart slightly.
* Space the remove button with margin instead of padding
Padding right sat inside the ActionIcon, so its hover background ran past
the icon and the glyph sat off centre. Margin keeps the same gap from the
row edge with a symmetric 28x28 hit area.
* Use Ellipsified for the table selector label lines
Manual truncation clipped the text with no way to read the full name.
Ellipsified brings the overflow styles and a tooltip that appears only
when the text is actually cut.
Dropping the Stack's align=start matters here: with flex-start each line
sized to its max-content width, so it never overflowed itself and the
truncation check never fired -- the button label clipped the text
instead, without an ellipsis. Stretched lines re-expose the button's
centered text-align, hence ta=left.
* Drop no-op layout props from the table selector
The button root class duplicated miw={0}, which already lands as an
inline min-width. The Stack's w/miw/justify did nothing: it stretches to
the button label by default, the label already carries min-width 0, and
its height is content-driven. ActionIcon has min-width equal to its
natural size, so it cannot shrink and flex="0 0 auto" changed nothing.
Verified by removing each and re-measuring at 800/1440/2560px.
* Drop redundant truncation styles and the empty-state Ellipsified
Mantine's button label already sets overflow hidden, and Ellipsified
carries its own min-width 0, so both declarations restated what was
already there. The css module is back to its state before this branch.
"Select a table..." is a fixed short string that never truncates, so it
goes back to a plain Box.
* Add Data Studio guide page and reorganize sidebar navigation (#76707)
* adjustments
* transform tabs, e2e adjustments
* oss test
* redirect first timers to the guide page and clean things up
* PR Feedback
* OSS copy, re-adding tests
---------
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
Notebook-built transform sources now reach Metabot as the same portable JSON that `construct_notebook_query` consumes, with tables and fields by name, while native SQL still passes through verbatim. Card entity IDs inside an exported query now come from the permission-checked content store, so a source card the reader can't open renders no query at all instead of falling back to an EDN dump.
* Invert visualizer click-object formatting via a Visualization prop
Visualization no longer imports formatVisualizerClickObject from the
visualizer module; DashCardVisualization supplies a transformClickObject
callback instead, so the dashboard side owns the visualizer coupling.
* Give the transforms and Data Studio pages their own chunks
* Give the monitor and account pages their own chunks
* Load the transform pages as one chunk
* Give the chart-rendering feature pages their own chunks
* Load the metric pages as one chunk
* Fetch the Data Studio Library metric pages when their route is matched
* Load the metric pages and their Data Studio wrappers as one chunk
* Say why the metric wrappers share the core chunk
* Split the app shell's dashboard selectors into their own module
* Declare the dashboard card actions beside their type constants
* Keep the dashboard reducer off the parameter and data-fetching actions
* Split the tab thunks off the tab reducer
* Fetch the embed setup modals when one is opened
* Give the dashboard its own chunk
* Keep the query builder URL sync from replacing a pending navigation
* Keep the table picker's automatic selection off a pending navigation
* Read the pending navigation from the router, not from a render
* Wait for the code-split embed modal before probing for its terms card
* Drop the note restating what import type already says
* Outrank the grid placeholder style the dashboard chunk now loads later
* Show column descriptions in the Detail visualization via an info icon tooltip
* Reveal the Detail column description icon on hover instead of always
* Reserve the description icon's width so hovering never shifts the Detail table
* Address review comments
* Remove delay showing info icon
* Add a shift-pressed hook and shift+click selection for rows and pinned cards
* Add a Select entry to the collection item overflow menu
* Cover shift+click and the Select menu entry with an e2e test
* Keep shift+click on row actions from selecting the row
* Simplify collection item selection callbacks
* Address collection selection review feedback
* Scope pinned card queries now that pinned items are listed as rows
A pinned item renders both as a card and as a table row, so unscoped link
queries by item name now match twice. Scope them to the pinned section and
wait for the pinned query before asserting.
* Address review comments
Drop the quarantine-conflict flag: a driver marked `skip` in ci-test-config
whose own `modules/drivers/*` files changed now simply runs, instead of
spinning up a runner that fails immediately and demands a `ci:run-<driver>`
label. Changes to another driver's files, or to shared code, leave the skip
in place.
Rename the decision logic's "quarantine" vocabulary to "skip", matching the
config's own `status: skip`. "Quarantine" now refers only to ci-conductor's
suite-level gating rules.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Make useListSelect key-safe and pinned cards selectable
* Cover shared pinned and table selection with tests
Adds a CollectionContent selection harness and end-to-end coverage for
selecting the same item from its pinned card and its table row, and points
the pinned item menu helper at the card element so it still resolves once
the card becomes a selection target rather than a link.
* Swap the pinned card icon for a checkbox on hover in select mode
* Reset pinned card hover state outside select mode
* Target projected metric dataset requests in e2e
* Simplify pinned selection and select-all handling
* Simplify pinned selection inputs
* Share one selection entry between pinned cards and table rows
Pinned items now appear in the collection contents list (#79679), so the
pinned grid and the table render the same logical item twice. Both read and
write the single useListSelect entry keyed by id plus model, and select-all
covers the listed page once instead of composing a separate pinned set.
* Simplify selected pinned card styling
* consolidate driver CI labels under ci:run-<driver>
break-quarantine-<driver> and ci:run-<driver> were two spellings of the
same override: ci:run-* is checked at Priority 3, ahead of the quarantine
check, so it already forces a quarantined driver to run.
Drop the break-quarantine-* family and point the quarantine-conflict hint,
the mage help, and the workflow error messages at ci:run-<driver>.
* rename ci:all-cloud-drivers to ci:run-all-cloud-drivers
Every other driver force-run label is spelled ci:run-*.
Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
* Stop the alert run-status icon from shrinking
The warning icon sits in a flex row next to the run's error message, and
with flex-shrink at its default of 1 a long message squeezed it down to a
few pixels wide while its height stayed at 16px.
* Use FixedSizeIcon for the alert run-status icon
Ten Metabot settings were marked :doc false, excluding them from
environment-variables.md. Remove the flag and write real descriptions
so MB_METABOT_NAME, MB_METABOT_ICON, MB_METABOT_SHOW_ILLUSTRATIONS,
the three system prompt settings, and the four EE usage-limit/
permissions settings show up in the generated docs.
* Give the admin section its own chunks
* Load the admin pages as two chunks
* Fetch the tenant people and group pages when their route is matched
* Fetch the permissions pages when their route is matched
* Export AdminApp and DataPermissionsPage by name
* Say why the tenant wrappers get their own chunk
* 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.
* Type the visualization registry as components or bare definitions
* Convert the dashboard viz register module to TypeScript and use typed named exports for Action and IFrameViz
* Strip large literal IN lists before GraalPy SQL parsing
Large literal IN lists (like large VALUES clauses) blow GraalPy's ~114KB/AST-node
memory cost and its 30s call timeout: a 300k-item list that sqlglot parses in
1.7s on CPython times out at 30s on GraalPy, poisoning a pooled context on every
background dependency-analysis pass over the offending query.
Extend the JVM-side pre-processing to also replace literal-only IN lists with
more than 100 items with `IN (NULL)`. Only lists consisting purely of literals
(numbers, quoted strings, NULL/TRUE/FALSE) are stripped; column references,
function calls, subqueries, and bind parameters disqualify a list, since
stripping those would change analysis results or parameter counts. Lists nested
inside unstripped IN subqueries are still found.
All callers get the same handling: the stripping happens in the one shared
strip-large-literal-lists (renamed from strip-large-values) used by every
analysis entry point, and is-single-stmt-of-type? keeps the #74284 restore
behavior — validate the stripped SQL, return the original text — so
impersonated queries still execute with their literal lists intact.
With the fix, field-references on a 300k-item IN list drops from a 30s timeout
to ~80ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify literal-list stripping to a single shared pass
Review cleanup: collapse the two per-keyword scanners into one walk over the
SQL driven by a combined VALUES|IN pattern with small per-keyword handlers,
extract the quote/doubled-quote scanning into a shared skip-string-literal
(also fixing count-top-level-commas, which didn't handle doubled-quote
escapes), and replace the substring-allocating literal-only-list? check with a
single index-based early-exit pass, so IN (SELECT ...) is rejected without
scanning or copying the subquery region. No-strip calls now return the
original string identity.
field-references on a 300k-item IN list drops from ~80ms to ~27ms; all
existing stripping tests pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add exhaustive tests for literal-list stripping
Cover threshold boundaries (100 vs 101) for both VALUES and IN, every accepted
literal shape, a disqualifier grid (17 non-literal items x 3 positions),
byte-for-byte preservation of surrounding SQL, nesting (IN inside VALUES
tuples, stripped-VALUES swallowing nested lists, deep IN-subquery chains),
idempotency, original-instance identity on the no-strip path, malformed input
(unbalanced parens, unterminated strings, empty lists), keyword false
positives (margin, JOIN, INT, quoted identifiers), and an end-to-end pass of
every analysis entry point over stripped SQL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Also strip large ARRAY literals and literal tuple IN lists
Two more static-data shapes reproduce the GraalPy 30s timeout / severe CPU
burn: id = ANY(ARRAY[...]) (the Postgres idiom ORMs emit instead of IN; 200k
items hit the 30s timeout) and composite-key tuple lists
(a, b) IN ((1, 1), (2, 2), ...) (50k tuples burned 21s).
Generalize the flat literal-list scanner to a parameterized closing delimiter
and reuse it for ARRAY[...] -> ARRAY[NULL], and add a tuple-list scanner that
requires every tuple to be literal-only, stripping to IN ((NULL, ...)) with
the first tuple's arity. Subscript access (arr[1], arr[1:5]), subquery tuples,
and any non-literal item still disqualify a list. The is-single-stmt-of-type?
restore behavior covers both new shapes.
After the change: tuple-IN 50k tuples 21.6s -> 525ms; ANY(ARRAY[200k])
30s timeout -> 1.1s. CASE WHEN chains and UNION ALL literal tables remain
unstripped (no lossless strip exists for them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Treat double quotes as a disqualifier in literal-list scanning
In identifier-quoting dialects (Postgres, Snowflake, ...) "x" inside an IN
list or ARRAY literal is a column reference, not a string, so stripping a
large list of double-quoted items would drop real field references from
analysis. The literal-list scan is dialect-agnostic, so be conservative: only
single-quoted strings count as literals; a bare double quote leaves the list
untouched. skip-string-literal still skips both quote styles where it is used
for paren/comma balance scanning, where either interpretation is a
skip-region.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use [[strip-threshold]] docstring links instead of backticks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix Eastwood reflection warning: cast close-ch to primitive char
(= ch close-ch) compared a primitive char against the untyped close-ch
parameter, which Eastwood flags as an unresolvable Util.equiv call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Ignore keyword matches in strings/comments; reject empty list elements
Review follow-ups:
The keyword matcher previously rewrote text inside string literals and
comments — SELECT 'IN (0, ..., 149)' became SELECT 'IN (NULL)', and an
unbalanced quote inside such a region could corrupt the statement. The walker
now gap-scans between matches with a string/comment-aware cursor
(code-region-end) and copies any match that sits inside '...', "...",
-- ..., or /* ... */ through untouched.
The flat-list scanner also accepted empty elements, so invalid SQL like
IN (,,,) with >100 commas was rewritten into valid IN (NULL), masking syntax
errors from validation. Both the flat and tuple scanners now require content
in every element and reject leading/trailing/consecutive commas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify IN/ARRAY stripping to one simple-list scanner
Stripping feeds fail-soft analysis only, and execution paths restore the
original SQL, so the defensive machinery accumulated from review rounds
(literal word allow-list, tuple/arity validation, empty-element checks,
string/comment-aware outer scanning) bought correctness for cases that don't
matter at the cost of real complexity. Replace it all with one rule: a list is
strippable when, from its opening ( or [, it contains only digits, signs,
single-quoted strings, commas, whitespace, and balanced parens (which covers
tuple lists with no special logic), and holds at least 100 commas. Flat IN,
tuple IN, and ARRAY all collapse into a single ~20-line scanner and one
decision fn; NULL/TRUE/FALSE items now conservatively block stripping. VALUES
keeps its pre-existing count-based path. The best-effort contract is stated in
the public docstring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Seven sites across four drivers were escaping SQL string literals the same way. This consolidates, and there's a single place to warn not to use escape-sql to sanitize user input.
No behavior changes.
MBQL treats `java.math.BigInteger` and `clojure.lang.BigInt` interchangeably. Only the Clojure class was in the parameter table, so a BigInteger fell through in one of my tests to `:default` and bound as a string.
* Show the output schema and table for a transform run
Adds a TransformOutput component that renders the run's target as `schema › table`
breadcrumbs with a copy button. Both crumbs link out when they exist: the schema to
the data model, the table to the query builder. Wired into the run sidebar
("Output table" row) and the job run sidebar ("Output:" line).
Also fixes the job run sidebar not shrinking below its content width (missing
min-width: 0), which let long table names push it past the viewport.
* fix tests
* drop newTab prop, always open output links in new tab
* addressing PR's comments
* fix race condition
The three preceding PRs removed every shared-tier import from the module: the
route guards, the redux location mirror, and the styled-Link re-export. All that
is left is metabase/utils, which basic may import.
basic rather than lib: router is a React component library, the same shape as
metabase/ui, and nothing in basic/* imports it, so lib would only widen what
lib/utils is allowed to reach for no gain.
* fix(sdk): sdk crashes when resizing column
* do the same on ExecutionOutputTable too
* reword test to make it better
* make sure cols/rows are not new arrays every render when output is undefined
* Derive Tabs value type from the tab values
Mantine types the Tabs value and onChange argument as `string | null`. Make
TabsProps generic over the tab value so callers get their own union back in
onChange instead of a bare string.
* Drop Tabs onChange type workarounds at call sites
Every `<Tabs>` call site now annotates its own tab union instead of casting or
re-narrowing the change value: the six `as SomeTab` casts (CommentsSidesheet,
PythonEditorResults, GroupLimitsSettingsSection, McpAnalyticsPage,
CliAnalyticsPage, ConversationStatsPage), the four in the admin/permissions and
tenants listings, and the handlers that laundered `string | null` through a
lookup or a literal comparison (AddDataModal, NotificationsTabs, TableSection,
the date pickers, TabPanel). AddDataModal's `isValidTab` guard existed only for
this and is gone; DatasetFieldMetadataSidebar's TAB map is now `as const`.
* Cleanup
* Apply suggestions from code review
Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
---------
Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
* Mark metabase/router as side-effect free for rspack
* Route deep metabase/router imports through the barrel
* Let static-viz import the router barrel instead of a deep module
* Guard the side-effect-free contract and scope the include to the directory
metabase/router/Link.ts re-exported metabase/common/components/Link so every
routing import had one home. But that Link is not a routing primitive: it wraps
RouterLink with a Tooltip from metabase/ui, a CSS module, and disabled/variant
props, so the barrel dragged shared/common and basic/ui into the router module
and looped back into router/router-link.
Call sites import it from metabase/common/components/Link again. RouterLink and
RouterLinkProps stay in metabase/router, which is where the primitive belongs.
The boundaries linter never flagged this: element-types checks imports, not
'export ... from'. So the edge had to go rather than be waived, or a lower tier
would have been green while the dependency was still there.
* Fix N+1 query patterns in GET /api/database and GET /api/native-query-snippet
GET /api/database?saved=true&include=tables issued ~6-7 app-DB queries per
saved question (one metabase_database select-one plus a field select and
4-5 hydrations each) — ~3000 queries on instances with a few hundred
eligible cards:
- card->virtual-table: only fetch the database when include-database? is
set, instead of unconditionally per card
- extract cards->card-id->metadata-fields so the result_metadata fields of
all cards are fetched and hydrated in one batch (reused by
batch-fetch-card-query-metadatas), and pass it from cards-virtual-tables
- ids-of-dbs-that-support-source-queries: one select instead of
select-pks-set + per-id select-one
GET /api/native-query-snippet ran user-has-any-perms-of-type? — an
uncached EXISTS against data_permissions — once per snippet via
mi/can-read?, even though the answer is per-user, not per-snippet:
- user-has-any-perms-of-type? now primes the request-scoped permissions
cache for all databases (the same pattern query-creation-capabilities
already uses on GET /api/user/current) and answers by scanning the
cached entries; repeated calls in one request are pure cache hits
- prime-all-dbs-cache records completeness with an ::all-dbs sentinel in
the existing :db-ids set so the database id list is fetched only once
- the uncached EXISTS path is kept for cross-user checks and
disable-perms-cache
Note: the cache prime excludes permission rows for inactive tables, so a
grant existing only on a deactivated table no longer satisfies
user-has-any-perms-of-type?.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Track full-prime state under a dedicated :all-dbs-primed? cache key
Move the all-databases-primed marker out of the :db-ids set (where it sat
as a ::all-dbs sentinel among real database IDs) into its own
:all-dbs-primed? key on *permissions-for-user*. prime-db-cache now
preserves unrelated keys by assoc-ing instead of reset!-ing the atom.
Make prime-all-dbs-cache public with an optional pre-fetched-ids arity,
and switch query-creation-capabilities — the one caller that manually
primed all databases — to use it, so the full-prime work is shared with
user-has-any-perms-of-type? within a request.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify prime-all-dbs-cache: single arity, :all-dbs? key
Drop the pre-fetched-ids arity — the function always fetches the database
ID list itself — and rename the completeness marker from :all-dbs-primed?
to :all-dbs?.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Align :all-dbs? docstring continuation line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add call-count regression tests for the two fixed N+1 endpoints
Both tests measure warm app-DB call counts via t2/with-call-count at two
entity counts and assert the count doesn't scale:
- GET /api/database?saved=true&include=tables with 2 vs 18 eligible cards;
allows the 1-per-card toucan2 identity-query overhead from
do-after-select but fails on the pre-batching one-select-per-card
behavior (verified failing against the pre-fix code).
- GET /api/native-query-snippet with 2 vs 10 snippets as a non-admin;
fails on the per-snippet permission EXISTS check (verified failing
against the pre-fix code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use mt/with-model-cleanup in call-count tests
Replaces the try/finally + manual delete pattern, so fixture rows are
cleaned up even when insertion or the warm-up request throws (addresses
review feedback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rework any-perms-of-type caching to a dedicated request-scoped cache
Instead of priming the full permissions cache for every database and
scanning it, cache per [user-id perm-type] the set of database IDs
granting the most permissive value, loaded across all databases in a
single query. This answers every :exclude-db-ids variant from one small
fetch and keeps semantics identical to the uncached query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perms cache
* nil fix
* fix
* current state
* cleanup
* Test that inactive-table-only grants are ignored by aggregate perm checks
user-has-any-perms-of-type? previously counted grants existing only on
inactive tables (its raw EXISTS query had no active-table filter); now
that it is served by the distinct-perms cache it ignores them, matching
every other cached check. This is intentional — cover it with a test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Apply code-review fixes to the permissions cache rework
- Restore batch priming in with-can-run-adhoc-query via a new public
prime-full-perms-cache (table-level checks otherwise load the cache
one database at a time, up to 2 queries per database on card lists)
- Scope the non-cached distinct-perms load to one database instead of
scanning all of the user's permission rows per call
- Extract the shared FROM/JOIN/WHERE of the two cache queries into
perm-rows-query-base so their row sets cannot drift apart
- Simplify the full-perms interner binding and drop a dead fnil and a
redundant double-check inside the distinct-perms swap!
- Select only :id and :engine in ids-of-dbs-that-support-source-queries
(full rows decrypt :details per database, twice per request)
- Own the batched virtual-table Field fetch in a new
cards->virtual-tables entry point; the helper is private again
- Use mt/with-dynamic-fn-redefs in permission-caches-load-per-type-test
- Document that the request caches are point-in-time snapshots
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Treat cached false as a hit in fast-interner
or-based lookups skip false, so interning false re-ran canon-fn and
putIfAbsent on every call (result was still correct). Use if-some so
presence, not truthiness, decides the hit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document why distinct perms load all databases and why the uncached interner exists
Capture the review-thread answers in the docstrings: distinct-perms
preloads every database because most consumers ask cross-database
questions, and full-perms builds a per-call interner even without a
request cache because group values are highly repetitive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Batch-prime the full perms cache in the remaining per-table check loops
Endpoint tracing found three more loops that fill the full permissions
cache one database at a time now that priming is on demand: the
include=tables branch of GET /api/database (per-table can-read?/
can-query?/can-write?), batch-fetch-query-metadatas* (per-table
can-read? behind dashboard/card query_metadata), and the EE
filter-tables-by-data-model-perms loop. Each now batch-primes the perm
types it checks, one query per type instead of up to 3 per database.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify perms caches to all-types loading; rename to table/schema caches
Per-type bookkeeping wasn't worth it — nearly every consumer needs
several permission types, and there are only a handful. Both caches now
load every permission type at once: the table-level cache (was
*full-perms-cache*) per database, the schema-level cache (was
*distinct-perms-cache*, table_id collapsed so schema is the finest
granularity it answers) for all databases in a single query on first
use. prime-table-perms-cache takes just database IDs and no-ops for
superusers, whose checks never read the cache.
Also batch-prime the remaining per-table check loops found by a
repo-wide audit: search result filtering, GET /api/table, recent views
(also reusing the already-fetched table instead of re-selecting it),
typed-schemas table listings, and the segment/measure list endpoints.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop the search prime and scope the recents prime to actual table recents
Benchmarks on a 10M-row data_permissions Postgres (200k rows for the
test user) showed both primes regressing their endpoints: search went
40ms to 840ms because priming all databases loads every permission row
of the user on each request while matching results only touch a few
databases, and recents primed the whole instance because table-recents
returns every table when given no IDs. Search now relies on lazy per-db
loads; table-recents returns nothing for empty IDs so the prime no-ops
naturally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Load the schema perms cache per database, with a priming function
Benchmarks showed the all-databases SELECT DISTINCT costing ~230ms per
request at 200k user rows, taxing endpoints that ask about one database
(dashcard query 228ms -> 445ms). The schema cache now mirrors the table
cache: single-database questions load one database at a time (indexed,
all permission types at once), loops over many databases call
prime-schema-perms-cache, and cross-database questions load everything
once, recorded under :all-dbs? so repeated checks stay one query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Load perms caches via next.jdbc/plan instead of toucan2 rows
JFR allocation profiling showed toucan2's per-row machinery (row delays,
transient instances, realize maps) dominating the cache loads' share of
request allocation. plan reads columns straight off the ResultSet with
no per-row maps; the folds are unchanged. Runs on the current toucan2
connection so transactional writes stay visible. On the 10M-row bench
this cuts per-request allocation ~10-15% further (e.g. /api/user/current
570MB on master -> 376MB) and 50-90ms of latency on load-heavy
endpoints. Note plan queries are invisible to t2/with-call-count, so
one test now spies on the load fn instead of counting calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Store perms caches as mutable Java collections, skipping persistent copies
The folds now build nested HashMaps/HashSets that the caches store
as-is: no per-row persistent update-in churn and no whole-structure
persistent copy at the end. Clojure's lookup/seq functions read Java
collections directly, so consumers are unchanged. Only the two outer
levels (perm-type, db-id) are ConcurrentHashMaps that later loads extend
via putAll of missing databases; everything below is written once by its
load and then only read. Numeric keys are canonicalized to Long at both
fold and lookup time — JDBC hands back Integer for int columns and Java
maps compare boxed keys with equals, not Clojure's equiv. The entry
interner is gone with the persistent copy; instances with thousands of
identical per-database profiles lose that sharing (#76077), which is the
trade-off for the allocation win.
On the 10M-row Postgres bench (200k rows for the user) vs master:
/api/user/current 570 -> 268 MB allocated per request, GET /api/database
?saved=true&include=tables 1773 -> 1032 MB and 4.1s -> 2.5s,
GET /api/table 1152 -> 865 MB and 1.9s -> 0.9s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Answer cross-database permission questions from a light per-function cache
Instances with thousands of databases and groups make loading tuples
for every database (schema cache with :all-dbs?) scale badly: the
distinct tuple set alone reaches hundreds of thousands of rows. The
questions that actually span databases only need one value per database
for one permission type, so most-permissive-database-permission-for-user
(signature unchanged) and user-has-any-perms-of-type? now share
*most-permissive-perms-cache*: {user-id {perm-type {db-id value}}},
loaded by one indexed query returning distinct (database, value) pairs
and coalesced per database. The :all-dbs? machinery is gone and
query-creation-capabilities needs no priming — its per-database loop
hits the cache after one query.
On the 10M-row bench this takes GET /api/user/current from 722ms/268MB
allocated to 556ms/182MB — 5MB above the response-validation floor
measured with all permission checks short-circuited (master: 833ms/570MB).
Also reorganize the caching section (atoms, then loaders, then the
functions using them; coalesce moves above since a loader needs it) and
relax the xrays call-count expectation: permission-cache loads go
through next.jdbc and are invisible to t2/with-call-count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Coalesce permissions in SQL and give every check its own per-function cache
Each permission-checking function now owns one atom, one hand-written
aggregate query, and a cache structure fitting exactly its question,
organized per function. Values map to ranks (value-rank-case) and
GROUP BY collapses tables, duplicate values and — via two nested levels
of aggregation for the most-restrictive-within-group-then-most-
permissive-across-groups questions — group identity too, so result sets
scale with tables/schemas/databases rather than raw row counts
(dbs x groups x tables x values). A bucket's [min max] rank pair
reconstructs coalesce exactly, verified exhaustively per permission
type including :perms/view-data's special rule; full-schema keeps its
per-group db-level union by expanding db-level rows into every schema
inside its query. native-download delegates to full-db-permission
(identical semantics), user-has-any derives from the most-permissive
cache.
With the row-count multiplier gone the loads are back to plain t2/query
and persistent Clojure collections: the next.jdbc/plan machinery, the
mutable Java collections, the Long key canonicalization, and the
interner are all gone, and cache loads are visible to
t2/with-call-count again. No public API changes; the batch primer is
now prime-db-perms-cache and warms every per-database cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop a docstring reference to the removed full-perm-rows
The ~15x realize-before-key-access observation stands on its own; the
function it pointed at no longer exists now that the permission caches
load SQL-side aggregates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rewrite app-db NOT IN (SELECT ...) queries as NOT EXISTS anti-joins
NOT IN (subquery) degrades to a per-row subplan on Postgres once the
subquery result exceeds work_mem. Rewrites all runtime occurrences to
correlated NOT EXISTS (or a literal comparison where the subquery was
0/1 rows). MySQL keeps the double-wrapped subquery in
delete-database-fields! since it cannot reference the DELETE target
table in a subquery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep is_audit predicate as NOT EXISTS in set-default-database-permissions!
Tests create audit DBs with random ids, so the fixed audit-db-id
constant is not equivalent to the is_audit = true subquery here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Snowflake: don't over-escape schema names in the dynamic-table probe
The FK sync path pre-escaped schema/table names (`_` -> `\_`) before handing
them to `dynamic-table?`, which uses the schema as an identifier in
`IN SCHEMA <db>.<schema>`. `"RAW\_DATA"` is not a real schema, so Snowflake
threw for every table in any underscore-named schema and we logged a full
stack trace each time.
`dynamic-table?` now takes raw names and escapes only the `LIKE` argument,
where `_` is actually a wildcard. Escaping for the JDBC metadata call moved
to the call site. Also log the caught exception without the stack trace,
since it fires once per table.
Fixes#78541
Co-authored-by: da <paoliniluis@gmail.com>
* Generate a fresh title for a forked conversation from its first new message
* Show "Forked conversation" instead of "New conversation" in fork header
* Rename first-valid-user-message to first-non-forked-user-message
* Align map value columns in metabot conversation schemas (PR review nits)
* feat: fork a Metabot conversation at an assistant message
* feat: surface Metabot conversation fork origin in analytics
Record forked_from_conversation_id on the conversation and
forked_from_message_id on each copied message, expose them through the
metabot-analytics API and audit views, and link a fork back to its
original thread.
* Update git_branch icon
* Show source conversation title in fork boundary and header
* Revert fork boundary to the attempt where the fork happened
Showing the fork boundary divider on every regenerated attempt was
misleading: the fork only happened on the inherited attempt, so the
divider matches that single message again.
* Refine fork boundary rendering and tests
* Exclude forked (copied) messages from v_metabot_messages
* Address PR review: unify message-action guard, extract ForkBoundary, assert fork toasts via UI
* Reuse createMockMetabotConversationDetail for the fork test fixture
* Replace fork props on Messages with a renderAfterMessage slot; move ForkBoundary to EE
* Drop the redundant UndoListing now that the metabot test setup mounts it
* Index and FK-constrain forked_from_conversation_id so deleting an origin nulls the link
* Split forked messages out of usage metrics with new_message_count
v_metabot_conversations gains new_message_count, which leaves out the
messages a fork copied in from its origin. message_count keeps counting
the whole thread, so the conversations list, the detail page and the
shipped model all report what the reader can actually see.
The usage stats charts aggregate the new column instead, which makes
SUM(new_message_count) reconcile with COUNT(*) FROM v_metabot_messages.
metabot-stats leaves copies out of :metabot-queries and :metabot-users
for the same reason — copies inherit role/ai_proxied and are stamped
with a fresh created_at, so a fork was reporting LLM calls that never
happened.
* Don't offer to fork while a turn is streaming
Only the streaming message's actions were hidden, so earlier assistant
messages kept their fork button mid-turn. Forking then swapped the
conversation out from under an in-flight stream whose reducers are keyed
by agentId alone, so the old turn's remaining deltas rendered inside the
fork.
Withholding onFork while isDoingScience is set is preferable to
cancelling the request: the user asked to branch off an earlier message,
not to stop the response, and loadConversation already avoids cancelling
so an aborted turn isn't recorded.
* Realign the analytics schema maps and fix a migration author
The fork columns are longer than the alignment column the surrounding
maps used. ConversationDetail had already drifted into two alignment
columns before this branch; both maps are now uniform.
Extends the dynamic JSX formatter pattern from #77658 (which did links)
to markdown and email. formatValue and formatEmail now call a registered
renderer through the registry instead of importing ReactMarkdown/Mustache
and ExternalLink directly; the renderers live in ui.tsx and are wired up
at app boot by registerJsxFormatting (called from registerVisualizations).
This keeps the react-markdown/micromark/mustache stack and the mailto
ExternalLink out of the static-viz bundle, which imports the formatting
engine through shared echarts code but never registers the JSX renderers
(so jsx formatting degrades to plain text there, as it already did for
links). No behaviour change in the app.
Static-viz bundle: 3,450,947 -> 3,297,087 bytes (~150KB, 4.5%).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
metabase.metrics.core re-exports sandbox-restricted-fields from metabase.metrics.permissions, and metabase.metabot.tools.entity-details calls it through that API. The passthrough to metabase.permissions.metric was missing, so importing the re-export threw at load and broke every namespace downstream of metabase.metrics.core.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transform failure emails list root-cause failures by name and summarize dependents that never ran as a count, so the test asserts the tally instead of the dependent's name.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Metric Dimensions section
* [metrics] Field IDs are not unique! Multiple FKs to same table.
Fixes a few places where the field ID alone is assumed to be a unique
key for dimension mappings.
* [metrics] Add basic CRUD API for metric v2 dimensions
Fixes UXW-4631.
Fixes UXW-4633.
* [metrics] Add `/api/metric/$ID/dimension/set-default` endpoint
Fixes UXW-4632.
This sets the dimension of a Metric v2 with the specified
`dimension_id` (a body parameter) to be the `:default true`.
All other dimensions have `:default` removed, so there is at
most one default dimension for any metric.
* [metrics] Metric dimensions create dependencies on the tables
Fixes UXW-4636.
* [metrics] Serdes support for curated Metrics v2 dimensions
Fixes UXW-4634.
Previously `:dimensions` and `:dimension_mappings` were marked as
`:skip` on serdes since the dimensions were always synced on read.
Now that they're curated and only synced when blank, we need to
properly serialize dimensions and mappings.
* Adjust Metric dimensions UI + connect to api
* Add dimensions reorder with dnd
* Add metric dimensions usage in metric viewer
* Change Metric Viewer to group dimensions by type
* Implement shared dimensions logic, add e2e tests
* Use default dimension in Metric Viewer
* Align headings in panels / Make trash and add buttons the same height
* Move and simplify the Set as default button
* Make about page use the default curated dimension in the preview chart
* [dimensions] Bump `:card_schema` to 24; backfill `:dimensions` in old style
Fixes UXW-4808.
This gives pre-existing v2 Metrics the full `breakoutable-columns` as
their dimensions, rather than just the main group as is now the
default. That preserves the old behaviour for the dimensions of the
metrics.
* Respect default metric dimensions on dashboards / Add projectDimension lib function (#77910)
* Respect default metric dimensions on dashboards
* Consolidate metric dashboard E2E coverage
* Remove default time dimension from metric editor
* Implement a few design tweaks in Dimensions tab UI
* Use curated metric dimensions on dashboard metric card filters
* Add dimension picker to metric About page
* Auto-pick default dimensions for new metrics
* Additional cleanup to the About page dimension select element
* Expand all metrics in See all dimensions
* Remove table names from metric filters
* Don't show sub-headers in 'See all' dimension view in Metric Viewer
* Remove metric overview tab
* [UXW-4859] Handle empty curated dimension list and removed default dimension edge cases
* Fix default viz type when adding metric to dashboard
* Remove the 'See all' option on explorer page for single metric visualization
* [UXW-4863] Don't use custom display name as field reference in dimension detail column
* [backend] Fix persistence of upgraded metric dimensions
* [UXW-4880] Re-add Overview tab, show curated dimensions
* [UXW-4848] Make sure we show all shared dimensions in the Shared Dimensions view, if the underlying fields match
* [UXW-4771] Make pinned metric in collection respect default curated dimension
* Update e2e tests to account for PR updates [part 1]
* Seed explicitly joined metric dimensions
* Fix metric dimension serialization dependencies
* Fix/adjust backend unit tests
* Fix metric dimension lint issues
* Run metrics with saved card permissions
* Update e2e tests to account for PR updates [part 2]
* Update kondo ratchet for metric serialization test
* Update metric permissions e2e expectation [part 3]
* Fix BE sandbox unit test
* Prefix upgraded metric dimensions with connected table names (#78410)
[UXW-4896] Prefix upgraded metric dimension names
* Address FE issues from July 22 reviews
* use curated dimensions on series breakout list / fix dimension display name on breakout legend and dimension pill bar
* Optimize getMetricMappingOptions
* Adjust about page dimension select margin-bottom and dropdown positining
* Fix curated metric dimension selection across duplicate foreign key paths
* Improve logic to show correct dimension label in the CenterControls button
* Exclude orphaned dimensions from runtime metric metadata
* Address Ethan's comments / Clean up
* Use metadata field cache when checking user permission / Improve field validation when adding dimension
* Address latest reviews
* Update serialization baseline card_schema 23 -> 24
* Tighten docstrings and comments per review feedback
Remove caller references, ticket refs, and usage context from docstrings
and comments; describe contracts instead.
---------
Co-authored-by: Braden Shepherdson <braden@metabase.com>
Co-authored-by: Anderson <anderson@metabase.com>
Co-authored-by: Anderson <ands.alves.nunes@gmail.com>
* Stabilize SSO group-mapping delete flake by re-reading settled state
Selecting groups for a mapping optimistically patches the settings cache,
but the setting update also invalidates and refetches session properties.
A trailing stale refetch can revert the last-added group after the test's
per-selection checkbox assertion has already passed, leaving the mapping
with only one non-admin group. The delete then fires a single deleteGroup
request and cy.wait times out waiting for the second.
Re-load the auth settings page between adding groups and deleting the
mapping, and assert the row shows its full group summary before acting.
The fresh page load aborts in-flight refetches and reads a single settled
server state, so every mapped group is present when the delete fires.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Serialize SSO group-mapping edits against their setting round-trips
Adding a group to a mapping PUTs the group-mappings setting (built from the
widget's current mappings cache) and invalidates session properties, which
triggers a background refetch. The widget optimistically patches the cache,
but a trailing stale refetch can revert the just-added group; the next
selection then builds its PUT from that stale set and overwrites the previous
group server-side (last-write-wins). The mapping ends up with fewer groups
than the test expects, so fewer deleteGroup requests fire and cy.wait times
out waiting for the second one.
Wait for each mapping mutation's PUT and its trailing session-properties
refetch to settle before the next interaction, in createMapping,
addGroupsToMapping (per group), and deleteMappingWithGroups, so every edit is
built from a fully committed round-trip. deleteGroup/clearGroup invalidate
only permissions-group, so updateSetting is the sole source of session
refetches and the waits stay aligned one-to-one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* update getExtraTagsForVersion to append -lts if isLts is set
* add ability to check if a version is an LTS version
* set islts
* update function to determine if we should tag with lts
* add condition for multiple LTS releases so we only tag the latest
* fix actionlint errors
* update lts tag to include major version
* await result of getExtraTagsForVersion
* fix type
* fix merge conflict, don't throw error
* GDGT-2851 Remove color-mix from theme color definitions
color-mix can only be resolved by a browser, so any theme color using it
breaks static viz, which renders on the server (see UXW-4690, where the
gauge failed on background_page-tertiary-inverse).
Per design's proposal, introduce a new orion-95 stop and use solid scale
values for the page backgrounds:
- dark: page primary/secondary/tertiary -> orion 95/100/110
- light: page primary/secondary/tertiary inverse -> orion 95/100/110
The remaining mixes are collapsed to their literal equivalents:
shadow-default to a plain rgba, and tooltip-background-focused to the
next stop down from tooltip-background.
The mixes that derive from whitelabel colors (core-brand, core-filter,
core-summarize) are left alone, since their values aren't known ahead of
time. A unit test now guards against reintroducing color-mix anywhere
else in the theme definitions.
* Update Loki Snapshots
* Remove var() references and non-hsla literals from theme definitions
Static viz feeds theme colors straight into Color(), which only parses
literals, so a var() throws there exactly like a color-mix does — even
when it points at a perfectly ordinary color. Collapse the remaining
var() references to the underlying base color, and normalize the few
hex and bare-keyword values to hsla so every theme color is a literal.
The guard test drops the transitive resolver it no longer needs and just
asserts that every non-whitelabel color is an hsla literal.
* Fix regression in pivot table
* Empty commit to trigger CI
* Manually update pivot table loki snapshot
* Update e2e colour assertions for the new background tokens
Dark background_page-primary moved from orion-100 to the new orion-95,
so anything asserting rgb(7, 23, 34) for it now expects rgb(12, 28, 39).
The dark mode body assertion was matching color(srgb 0.0204 0.06792
0.0996) — Chrome's computed form for the old color-mix on
background_page-secondary. That token is a plain hsla now, so Chrome
reports it as rgb(7, 23, 34).
* Fix e2e
* Fix e2e
* Revert changes in assertDarkMode
* Fix oss e2e spec
* Revert SDK theming assertions to the deprecated background token
The SDK theme presets resolve colours through the first entry of
SDK_TO_MAIN_APP_COLORS_MAPPING, and for `background` that is the
deprecated `background-primary`, which still points at orion-100.
`background_page-primary` sits at index 1 and never gets read, so
table-root is unaffected by the move to orion-95.
* Address review feedback on the colour theme changes
Move the gold and upsell-gem literals into base-colors, since each was
repeated across both theme files. Drop the intermediate staticColors
binding in the guard test in favour of a second filter, and drop the
`?? {}` on theme.colors, which is a required field.
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Fix click-behavior multi-stage e2e flake: settle mapping list between selections
The "navigating to questions with filters applied in every stage" test sets 11
click-target mappings sequentially, using index-based selectors (.first(),
.last(), .eq(2)) against the unset-click-mappings list. That list re-renders as
each mapping moves from the unset to the set section, but there was no barrier
between steps, so an index-based lookup could resolve mid-re-render and land on
the wrong target — yielding a stable-but-wrong filter set (a duplicated
"Product → Title" filter replacing the dropped "Reviews - Product → Reviewer"
one, which is exactly the assertion that fails).
Extract source selection into selectClickMappingSource(), which asserts the
source picker popover has closed before returning, so every subsequent
index-based lookup runs against a settled list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix click-mapping settle barrier to assert popover closed correctly
The previous settle barrier used `H.popover().should("not.exist")`, but the
`H.popover()` helper internally chains `.filter(":visible").should("be.visible")`,
so the assertion is self-contradictory: it requires a visible popover to exist
and then asserts it does not, failing deterministically the moment the source
selection closes the popover.
Assert the popover is gone with `cy.get(H.POPOVER_ELEMENT).should("not.exist")`
— the idiomatic pattern used across the e2e suite — so the mapping list still
settles between index-based selections without the contradictory visibility check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Scope click-mapping settle barrier to the visible popover
The unset-click-mappings list re-renders as each mapping moves unset->set.
A bare `cy.get(POPOVER_ELEMENT).should("not.exist")` can never settle in the
click-behavior sidebar: it keeps ~60 mantine popover/combobox dropdowns
mounted-but-hidden, so they are continuously found in the DOM. Filter to
`:visible` so the barrier only waits for the open source picker to close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Export queryToSearch and searchToQuery from metabase/router
* Serialize the query with queryToSearch at the navigation call sites
* Read search, not query, off a navigation descriptor
* Drop query from LocationDescriptorObject
* Thread QB popState off the routing slice
* Thread dashboard edit-mode off the routing slice
* Thread Metabot profile selection off the routing slice
* Thread document Metabot context off the routing slice
* Migrate component location readers to useLocation
* Retire the state.routing slice
* Convert getExpandedCollectionsById to TypeScript and fix the stale ExpandedCollection type
* Type fake collection ids as literals instead of casting to CollectionId, dropping the String() workaround
* Audit log statements: no stack traces, no queries/user input, no pretty-printing
- Replace throwable args to log macros with (ex-message e) so ex-data and
stack traces never reach the logs (~450 sites)
- Remove SQL/MBQL queries, prepared-statement params, HTTP request/response
bodies, PII, and user-authored names from log output (IDs kept)
- Replace u/pprint-to-str in log calls with pr-str, or drop the value
- Silence sqlglot parser warnings in the GraalPy bootstrap (they quote raw
chunks of customer SQL to stderr)
- Rewrite leftover functional colors (hsl/hsla/rgb/rgba) to hex in static-viz
SVG output so Batik never errors (and stack-dumps) on them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Use logf variants for exception messages; restore serdes --full-stacktrace
- Convert print-style `(log/warn "msg:" (ex-message e))` calls to
`(log/warnf "msg: %s" (ex-message e))`
- Serdes import/export logs the full stack trace again when the
--full-stacktrace CLI option / full_stacktrace API param is passed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update swap-clauses tests for clause-free warning messages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update log-content test assertions for the new logging policy
Log calls no longer attach throwables (entries have :e nil) and append
ex-message text to the message instead; body/secret leak tests now assert
the payload never appears in log output.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review comments
- rethrow-api-error!: update docstrings — response body is never logged,
it travels on the thrown ex-data
- expand-macros: log the segment filter-clause count once instead of an
identical trace line per clause
- settings cache: log the whole SQLException message chain (the old
print-sql-exception-chain walked .getNextException; ex-message alone
dropped the chained causes)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add Claude Opus 5 to the anthropic openrouter and bedrock whitelists
- anthropic claude-opus-5
- openrouter anthropic/claude-opus-5
- bedrock anthropic.claude-opus-5
* Scope the invite group picker to the groups with access to the shared item
* Imply All Users membership only for invites with a target, and mock the scoped groups endpoint in share menu specs
* Show all groups when inviting to view and mark which ones can access the item
* Make the invite picker's group section labels visible and tidy up UserForm's membership mapping
* Generalize the group picker into ordered labeled sections with group a11y roles, keeping invite-target semantics in UserForm
* Use the compact Alert size for the invite picker's no-access warning
Suppress api-key-missing exception when failing to generate sample question.
Not having an API key is the default situation and should not result
in an error being logged.
* Let library retrieval use pgvector on the app db
The tables were bare-named, so the drop-and-rebuild in ensure-tables! would have
resolved against the application database's default schema. That is what kept
the feature dedicated-only.
Table names now come from the pgvector mode, the way semantic search picks
between its two index-metadata configs: bare for a dedicated store, so no
existing index moves, and qualified by a library_retrieval schema when sharing
the app db. Its own schema, not semantic search's, whose migration drops every
table in it.
Raw SQL interpolating a qualified name into "%s" would read it as one
identifier, so those sites go through quote-table.
The sync task's scheduling gate stays boot-safe: a dedicated URL, or the license
plus a Postgres app db, decided without resolving the mode. available? checks
the license before the store for the same reason.
* Cover the app-db round trip end to end
Opt-in like the semantic-search one, and picked up by the same appdb-mode CI
job. Asserts the tables land in library_retrieval, the application schema is
untouched, retrieval returns the indexed entity, and a semantic-search wipe
leaves the library index alone -- the reason the schema is separate.
* Check our own schema before reporting the store ready
Semantic search's predicate answers for semantic_search. A role that can use
that schema but cannot create library_retrieval passed it, and then failed every
reconcile inside ensure-tables!. Probe for our schema too: present, or creatable
in a rolled-back transaction, cached because available? runs on every write
nudge and search.
* Gate the round trip on the capability the feature checks
Gating on semantic search's check let the harness pass where a role that can use
semantic_search but not create library_retrieval fails in production.
* Check schema privileges, not schema existence
A role with USAGE but not CREATE passed the existence check and then failed
creating tables. Ask Postgres for both privileges instead, and only fall back to
the rolled-back CREATE SCHEMA when the schema isn't there at all.
The whole probe is inside the try now: the existence query wasn't, so a
transient database failure could throw out of request-entity-sync!, which the
write hooks call fire-and-forget.
* Put the dedicated index in the schema too
A dedicated store kept the index in the default schema, which is exactly what
semantic search's migration wipes -- so enabling or migrating semantic search
dropped the library index and silently re-embedded the whole library. It fires
whenever the stored schema version trails the code, not just on first init.
Both modes now use the library_retrieval schema, so default-tables and
app-db-tables collapse into one. An index built before the schema existed is
moved into it with ALTER TABLE ... SET SCHEMA, keeping its rows; the move runs
before the IF NOT EXISTS creates, which would otherwise leave an empty table
beside it. Creating the schema now needs CREATE on the dedicated database, so a
failure says so.
* Drop only semantic search's own tables in a dedicated store
A dedicated store's default schema is shared -- library retrieval keeps its
index there until it moves -- so wiping every table in it could drop that index
before entity retrieval had a chance to adopt it. Both run as background jobs,
so nothing ordered them.
Scoped to the index_/dlq_/repair_ prefixes, and only in dedicated mode: the
module's own schema in app-db mode holds nothing else, so a reset there is still
free to clear leftovers. The app-db sentinel refusal still reads every table in
scope, since an app db is recognized by tables we would never drop.
* Match the names semantic search generates, not their prefixes
A cohabitant in a shared dedicated store is free to call something
index_history, which the prefix check would have dropped. Match the control
tables exactly, index tables through index-table-name? -- the same contract
orphan cleanup uses -- and DLQ and repair tables by their generated shapes.
Also covers the adoption path end to end: a legacy unqualified index with a row
in it, moved by ensure-tables! and still holding the row afterwards. Asserted
there rather than after the reconcile, which GCs documents the library no longer
implies.
* Stop the availability tests depending on the app db
available?-gates-test left the real schema probe live while stubbing only
the pgvector mode. The probe is Postgres-only SQL, so it threw and read as
unusable on the H2 and MySQL runs, and the case failed there.
The round trip's legacy table declares a vector(4) column before
ensure-tables! runs, and the support check verifies CREATE EXTENSION in a
transaction it rolls back, so nothing has installed the type by then.
* Answer review on the dedicated-store reset
Rename ours? to semantic-search-table?, and point the two docstring
references at namespaces the reader can resolve.
Cover the case where a legacy bare table and a qualified one both exist:
adoption has to leave them alone, since SET SCHEMA onto an occupied name
errors and would abort the whole ensure-tables! transaction.
* Latch the app-db schema check once it passes
The check catches everything and returns false, so on a plain TTL a single
transient app-db failure took retrieval offline for five minutes: available?
went false, search returned nothing and the write nudge no-oped.
A confirmed yes now sticks for the life of the process. Only a no keeps
re-checking, so a privilege granted while we run is still picked up.
* Stub the evals trigger
* use ref arg
* dummy to trigger build
* Clean up
* Use appropriate token
* Simplify pr message
* Use .evals_ref for default ref
* Comment cleanup
* Adjust ref
* Use evals branch
* Use general automation token
* readme for .evals_branch
* Use main of evals for label trigger
* Revert "dummy to trigger build"
This reverts commit e6502c6ff076a47d94eb829b516fc45b37cd7680.
* whitespace
* Address review remarks
* Use ai benchmark naming so we can trigger from pr
* Address review comments
* Remove now redundant branch pinning
* Review remarks
* feat: add lookback
* validate lookback
* feat: apply lookback
* tests
* fix
* fix test
* feat: only support lookback on temporal checkpoints
* update test
* FE
wip
* add :type/Time test
* add test with watermark row removed
* refactor to avoid double get-in
* only support date/datetime
* tests
* fix: support coerced cols
* add more tests
* refactor
* more tests
* test for coerced instant
* use utils in FE too
* chorec
* further validate
* use lib utils
* use strict comparison
* allow clearing lookback
* handle race
* chore: migrate registerVisualization to typescript
* chore: drop 'as unknown as Visualization' cast in custom viz plugin loading
applyDefaultVisualizationProps now takes the plain component and returns
the typed Visualization via Object.assign. The SDK's date_style format
option is widened to string | null to match the ColumnSettings value the
host actually passes to checkRenderable.
* Unit test the native editor run button's unmapped-tag tooltip
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Unit test the Save button's unmapped-tag tooltip and disabled state
Covers both dimension and temporal-unit template tags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Cover temporal-unit tags in the canRun template tag validation test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Drop the unmapped field filter e2e test in favour of unit coverage
Every assertion it made is now covered by NativeQueryEditorRunButton and
ViewTitleHeader unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Read useLocation and useParams straight from react-router v7
* Re-export react-router's Outlet instead of shimming it through context
* Disable the command palette by path instead of route props
* Drop the now-unused route props plumbing
* Add mdb/with-unshared-connection: appdb connections exempt from ambient reuse
A connection holding fragile long-lived state -- a streaming result set
(postgres portal/cursor), or FOR UPDATE row locks -- must not be picked
up by ambient connection reuse: a borrower that inherits it via
*current-connectable* and commits destroys that state. This is the
failure mode behind the first-boot hang in #78238, the revert (#77048)
of appdb streaming (#76645), and "cursor/portal does not exist" errors
when field updates run mid-sync-stream.
Built on toucan2's new unshared-connection! (camsaul/toucan2#212): the
connection is used when passed explicitly -- (t2/reducible-query conn
...), (t2/with-transaction [t conn] ...) -- but never bound as
*current-connectable*, so toucan calls that resolve their connection
ambiently run (and commit) on other pooled connections.
mdb/with-unshared-connection is checkout -> mark -> body -> close; in
every other respect the connection is ordinary JDBC, configured by the
body (e.g. .setAutoCommit false to stream). The pg test reproduces the
exact #77048 failure on an unmarked control connection (portal killed by
a nested commit mid-reduction) and shows the unshared connection
streaming to completion.
deps.edn temporarily pins toucan2 to the PR sha via :git/sha; a
follow-up commit will switch back to :mvn/version once a toucan2
release includes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Use released toucan2 1.0.572 for unshared-connection support
camsaul/toucan2#212 merged and released; drop the :git/sha pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Hold the audit DB cluster lock on a detached connection (fixes boot stall)
Since #76551 the audit install/load/sync pipeline runs inside a cluster
lock whose SELECT FOR UPDATE ties lock lifetime to transaction lifetime:
the whole pipeline became one long transaction on the lock's connection.
The serdes load's ~3,600 per-entity nested transactions piled up as
savepoints and subtransaction XIDs, and every row lock was held until a
single commit at the end -- stalling first boot for 10-25+ minutes and
blocking concurrent work like config-file database provisioning (#78238).
with-cluster-lock now supports :detached? -- the lock rows are held on a
dedicated unshared connection (toucan2 unshared-connection!) while the
body runs on ordinary pooled connections, committing incrementally in
its own short transactions. Locks release at body completion (commit) or
throw (pool check-in rollback). Re-acquiring a lock this thread already
holds detached is a no-op in both detached and transactional form;
upgrading a detached :share hold to :exclusive throws instead of
self-deadlocking; :retry-transient? is rejected (a re-run would
double-apply incrementally-committed work).
The audit pipeline opts in: per-entity serdes isolation (#74412) is
restored, and a crash mid-pipeline is repaired by the next boot (the
checksum only advances on completion; reconcile runs every boot), which
is what makes incremental commit safe here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Address review: confine detached retry to acquisition; repair dialect-sync window
Review findings (galdre), all majors addressed:
- Major 1: retry/with-retry wrapped the whole detached body, so a
retryable-looking error thrown by the body (e.g. a nested cluster lock
timing out inside the serdes load) re-ran an incrementally-committed
body and then surfaced as "Failed to obtain cluster lock", which
ensure-audit-db-installed! swallows as benign contention. An acquired?
volatile now confines both the retry predicate and the acquisition-
failure wrapper to the pre-acquisition phase in detached mode; body
errors propagate raw with intact cause chains.
- Major 2: a failed or killed engine-changed dialect sync was never
repaired once the content checksum had advanced. New durable setting
audit-db-dialect-sync-pending: set before the sync attempt, cleared on
success, a third sync trigger on every boot.
- Major 3: the mysql/h2 engine-flip visibility window under detached
mode is documented as a KNOWN WINDOW at adjust-audit-db-to-source!
(restores pre-#76551 semantics; overlay-based resolution is follow-up).
- Majors 4-6: reviewer's contract tests adopted (throw-path lock
release, check-in rollback, behavioral pin on :detached?, crash-mid-
load self-heal, plus the two Major-1 reproductions); public docstrings
updated to describe all three lock modes and the upgrade throw.
Minors: unified cross-mode reentrancy registry with eager share->
exclusive upgrade validation (fail-fast in any detached interleaving;
legal same-transaction upgrade preserved); h2 read->write upgrade throws
instead of hanging; lock-row INSERT gets a query timeout (bounds the
N-node cold-start pile-up); install-database! and each reconcile group
are single transactions; unshared checkout goes through *application-db*
so the restore-time read-lock gate applies; registry conveyance and
explicit-tx-on-unshared-connection caveats documented; dual kondo
lint-as entries; test hygiene (finally cleanup, nil ambient contract).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Right-size the engine-flip visibility comment
The committed, briefly-visible engine flip is this pipeline's
longstanding behavior; only #76551's single-transaction era hid it for
one release. A neutral note fits better than a warning block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Tighten comments to constraints; drop review-context narration
Comments now state what the code can't show (crash windows, why raw
JDBC, why the marker precedes the sync) and drop justification written
for reviewers: issue-number history, caller name-drops, repeated
tellings of the retry-confinement story, and cost reassurances.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
* Make the detached lock a bespoke entry point instead of a with-cluster-lock mode
Reviewer pressure on the :detached? option had generalized
with-cluster-lock into a two-mode API whose cross-mode interleavings all
needed guarding: a held-locks registry, mode-upgrade validation, an h2
upgrade guard, and an acquired? volatile threading retry confinement
through the shared machinery — all protecting call patterns nothing in
the tree performs.
with-detached-cluster-lock is now its own function: exclusive-only,
single lock, documented non-reentrant. Structure replaces machinery —
the body sits outside the acquisition retry, so body errors are never
retried nor rewrapped as acquisition failure (no volatile), and
same-name re-entry throws via a two-line dynamic-set guard instead of a
registry (nested *different* locks, which the audit body does take,
work unchanged). with-cluster-lock returns to its previous shape; the
lock-row INSERT keeps its new query timeout, which bounds the
multi-node cold-start pile-up in both modes. Error shape is unchanged,
so audit-lock-contention? still classifies correctly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Compact in-memory format for the per-request data-permissions cache
The request-scoped cache in metabase.permissions.models.data-permissions
previously stored raw data_permissions rows as full Toucan instances, keyed
{user-id {perm-type {db-id [row ...]}}}. On instances with thousands of
databases and users in many groups a single prime-db-cache call could
materialize over a million instances (hundreds of MB retained per request,
plus the matching GC churn).
Store a compact entry per (perm-type, db-id) instead:
{:groups {group-id perm-value} ; db-level rows
:tables {table-id [schema-name {group-id perm-value}]}} ; table-level rows
and intern the group->value maps at load time: on big instances they are
typically identical across most databases, so the retained size becomes
proportional to the number of distinct permission profiles instead of
groups * databases.
Also fetch only the columns the checks actually consume (group_id, perm_type,
db_id, table_id, schema_name, perm_value) instead of p.*, and fix an O(n^2)
already-cached filter in prime-db-cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* small fix
* Use metabase.util.performance helpers in the cache hot path
perf/concat + perf/mapv in entry-group-value-pairs. Deliberately NOT
perf/update-vals for the interning pass: it skips replacing values that
are = to the old ones, which would undo the interning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop perf helpers from entry-group-value-pairs
perf/concat over an already-built vector is just (into a b), and reduce-kv
iterates the map faster than mapv over entry seqs. Plain into + reduce-kv,
one less require.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address code-review findings on the compact permissions cache
- Duplicate data_permissions rows (no unique constraint on
group/type/db/table) no longer silently last-win: cache slots promote to
a set when duplicate rows disagree, so every value reaches the consumers'
coalescing logic exactly as raw rows did. Regression test added.
- schema_name is stored per table-level row ({:schema .. :value ..})
instead of first-row-wins per table, since it is denormalized and rows
for one table can disagree.
- The interner now lives in the *permissions-for-user* atom, so value
sharing spans every prime in the request (db-at-a-time priming
previously got a fresh pool per call), and it reuses
metabase.util.memoize/fast-interner instead of a bespoke pool.
- rows->cache-entry returns nil for empty rows so cached and non-cached
get-permissions branches agree; the non-cached branch skips interning.
- schema-permission-for-user and most-permissive-database-permission-for-user
now go through entry-group-value-pairs instead of hand-rolled traversals.
- Dropped the impossible-nil guards in prime-db-cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix
* Address CodeRabbit nitpicks
- gid->slot -> group-id->slot (no abbreviations)
- native-download-permission-for-user reuses most-restrictive-per-group
instead of re-implementing the grouping inline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rename local intern -> interner (Eastwood local-shadows-var)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Explain the slot rationale in conj-slot/slot-seq docstrings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* simplify slots
* Document interning with a worked example in the cache docstrings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014V8QbHjoEkJ8eZLG13sYUe
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
* Restyle the sharing popover with Copy link and Embed buttons above the actions list
* Reserve the copy button width with hidden pseudo-elements instead of inline styles
* swap subscriptions action with auto-refresh action
* hide Subscriptions for non-admins if slack and email aren't set up
* small adjustments
* e2e adjustments
* more e2e adjustments
---------
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
* Convert the legacy chart legend components to TypeScript function components
* Share the legend prop types and extract a typed getChartLayout helper with tests
Deleting a Database hard-deletes its Cards with raw SQL, but nothing
rewrites the `parameters` JSON on Cards/Dashboards that used one of
those Cards as a dropdown source. The parameter keeps a
`values_source_config.card_id` pointing at a row that is gone.
On export that made `*export-fk*` throw `:target-not-found`, which
`log-and-extract-one` swallows as a skip — silently dropping the whole
Card or Dashboard from the archive.
Export each parameter on its own now, and when one carries a card
values-source that can no longer be resolved, emit it without
`:values_source_type`/`:values_source_config`. That is the same shape
the app writes when a source Card is archived, so the parameter comes
back as a plain dropdown over its connected fields.
A source Card that merely isn't part of the export is unaffected —
`*export-fk*` only throws when the row is absent, so those still fail
dependency validation as before.
Fixes GHY-3259
The helper inserts straight into the table to bypass the model's transforms,
which bypasses the hook that fills in entity_id too. #78452 made that column NOT
NULL, so every App DB Tests job has failed on it since.
* Eagerly load tenant auth-provider; fail tenant SSO logins loudly (UXW-4898)
Root cause: metabase-enterprise.tenants.auth-provider — the login! method
that validates a login's tenant claim and stamps :tenant_id onto the user
data — was never eagerly loaded. The JWT/SAML providers only `derive` from
its dispatch keyword, which does not load the namespace, so on a freshly
started instance the tenant step was silently absent from the login!
method chain until unrelated traffic (e.g. /api/user/current hydrating
user attributes) lazily loaded it. A JWT provisioning login in that window
created the user with tenant_id = nil even though the token carried a
valid @tenant, the tenant-group assignment failed and was swallowed, and
the login returned 200 — permanently wedging the account (every later
login 403s with "Cannot add tenant claim to internal user").
Fix, plus defense in depth so no future tenant-drop can wedge an account:
- Add metabase-enterprise.tenants.init requiring the auth-provider, wired
into metabase-enterprise.core.init, so the tenant login step is always
registered at startup.
- create-user! throws instead of silently dropping :tenant_id when the
sso-user-fields field list would strip a tenant assignment stamped by
the upstream tenant flow.
- sync-group-memberships*! no longer swallows real tenant/group mismatch
errors (bad pairs whose :group-is-tenant? is non-nil); the login fails
loudly instead. Nonexistent groups keep log-and-continue. This also
covers EMB-2118 (JWT omitting @tenant with a mapped tenant group).
- The login! provisioning chain (tenant creation, user create/update,
group sync) runs in one transaction, so a failed login leaves no
half-provisioned user row behind.
* Fix cljfmt formatting in jwt_test.clj
* Stop the location-syncing call sites from replacing the url they just read
* Cover the documents /new to /new warning with a unit test
* Adopt v7 navigation semantics, drop the v3 parity guards
The v7 provider mirrored every location into state.routing by calling
useDispatch itself, which is what kept metabase/router bound to the store.
createLocationMirror takes a plain dispatch function instead, so the router
publishes locations without knowing redux exists. app.js and the test harness
supply it as RouterProvider's onLocationChange prop. V7ReduxBridge is renamed
V7RouterBridge now that nothing in it is redux-specific.
metabase/router now passes a basic-tier boundaries trial with zero violations.
Guards are authorization policy, not routing primitives. They were the largest
source of shared-tier imports inside metabase/router (redux, plugins, and four
selector modules), blocking the module from dropping to a lower tier.
The generic factory and the app-wide auth/setup guards move to a new shared
metabase/route-guards module. The monitor and data-studio guards move into their
own features, which lets getMonitorRoutes and getDataStudioRoutes stop taking
them as parameters.
Router violations under a basic-tier trial go from 13 to 3; the rest are the
redux location mirror in the v7 provider.
* Add GeoJSON error mocks and cover map load failures in unit tests
`setupGeoJSONEndpoint` could only produce a valid FeatureCollection, so the
error branches of the custom map flow had no unit coverage.
- `setupMalformedGeoJSONEndpoint` returns a 200 with a payload the client-side
validator in `metabase/api/geojson` rejects, since those checks run in
`transformResponse` and need the payload to reach the client.
- `setupGeoJSONEndpointWithError` returns a non-2xx with a bare string body,
matching what `api-exception-response` sends for an `ex-info` carrying only
a status code.
- `createMockProjectedGeoJSONFeatureCollection` supplies projected coordinates
so every bounds corner falls outside lat/lng range.
Covers the invalid-location, missing-features, and out-of-bounds errors in
CustomGeoJSONWidget.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Drop map GeoJSON error e2e tests in favour of unit coverage
All three asserted error strings are now covered more cheaply. The two
backend-owned messages are pinned by metabase.geojson.api-test against mocked
HTTP, and the modal rendering them is covered by CustomGeoJSONWidget. The two
client-side validation messages come from transformResponse in
metabase/api/geojson, which the e2e tests reached with the backend fully
stubbed or bypassed.
Two of them also drove real outbound requests from CI — one to www.metabase.com
and one to a file on the public repo's master branch.
projected.geojson had no other consumer; the equivalent payload is now built by
createMockProjectedGeoJSONFeatureCollection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Drop admin settings e2e tests that duplicate existing unit coverage
Each of these asserts the same condition as a unit test that already exists,
not merely an adjacent one:
- the HTTPS redirect check failure message is asserted in HttpsOnlyWidget
("should display an error message if the site is not accessible via https")
- the billing info fetch failure is asserted in LicenseAndBillingSettings
("renders error for billing info for store managed billing and info request
fails")
- the absent license input for hosted instances is asserted in
LicenseAndBillingSettings ("does not render an input when token has hosting
feature enabled")
mockBillingTokenFeatures and its feature-key constants had no remaining
callers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Persist disabling a collection from remote sync (GHY-4189)
Disabling a collection deleted its RemoteSyncObject rows outright, so the
removal left no dirty state to push and the collection's files stayed on the
remote. A later pull then re-imported the collection (its YAML still carried
is_remote_synced: true), re-enabling it.
Mark the collection's and its contents' RemoteSyncObject rows as "removed"
instead — a pending deletion the next export pushes to the remote — while
dropping rows still in "create" (never pushed, so nothing to delete remotely).
* Don't delete contents when a collection is re-synced before the push
Disabling a collection records a pending removal on its contents'
RemoteSyncObject rows. Re-enabling it re-tracked only the collection's own row,
so the contents stayed marked for deletion and the next export removed their
files from the remote.
Clear the recorded removal when a collection is re-synced before that removal
was pushed. Restore to "update" rather than "synced": edits made while the
collection was un-synced are not tracked, so the entity must be re-serialized
for the remote to be guaranteed to match local.
* Stop core-test leaking RemoteSyncObject rows between tests
Disabling a collection now marks its RemoteSyncObject rows rather than deleting
them, so rows seeded by a test outlive it and leak dirty state into every later
test. The old delete-on-disable behavior had been doing this cleanup by
accident.
Reset the table around each test with the clean-object fixture.
* Assert the pull succeeds in the disable round-trip test
The test discarded impl/import!'s result, so an errored pull would leave the
collection un-synced for the wrong reason and the following assertion would
pass vacuously.
* Re-check eligibility at write time so a pending removal isn't resurrected
An event handler computes eligibility from its event payload, then writes the
RemoteSyncObject row much later. A disable committing in between left the
handler writing a tracked status over the "removed" status the disable had
recorded, so the entity stayed on the remote after being un-synced.
Re-check eligibility against current DB state before letting a tracked status
overwrite a pending removal, and let the removal stand when it no longer holds.
* Row-lock the sync entry so the un-sync race has no remaining window
Re-checking eligibility at write time narrowed the race but did not close it: a
disable could still commit between the re-check and the update, and the losing
write would take effect.
Row-lock the entry for the transaction so a tracking write and a concurrent
un-sync settle in a fixed order instead of losing one of the two: whichever
locks first commits, and the other observes that result — the un-sync re-marking
the row, or the eligibility re-check seeing the entity leave the synced set.
The race test drives the disable on its own thread and unparks the handler
without waiting for it, since the parked handler holds the row lock the disable
needs. Its assertion holds for either order, so the choreography only biases
which one is exercised.
* Address review: honest race coverage, insert-path guard, tighter asserts
- Split the concurrency test into two, each parking the handler at the point
that exercises one ordering: disable-first (guarded by the write-time
eligibility re-check) and handler-first (guarded by the row lock, which the
disable now provably blocks on). The prior single test parked on the plain
existing-entry read, so it only ever exercised the re-check ordering while its
comment claimed lock contention.
- Re-check eligibility in the insert branch too: a stale tracked-status event
that finds no row inserts a fresh one, a phantom the row lock cannot cover.
This narrows, but cannot fully close, that window.
- Round-trip pull test asserts = :success rather than not= :error, so a
:conflict result can no longer slip through.
- restore-removed-rsos! docstring no longer claims it only clears removals from
record-removed-rsos!; it clears any 'removed' row in the subtree.
* Re-track never-pushed contents when a collection is re-synced
Disabling a collection drops its contents' never-pushed 'create' RemoteSyncObject
rows (the remote never had them, so nothing to delete there). Re-enabling only
restored the rows that were marked 'removed', so a card added-but-never-pushed
before the disable was left with no row — and the next incremental export, seeing
no dirty row for it, silently omitted it.
On re-enable, insert a 'create' row for every eligible content entity in the
re-enabled subtree that has none, so the next export pushes it. The
RemoteSyncObject table only records pending changes, so already-synced content is
legitimately untracked; re-marking it 'create' re-serializes it harmlessly (a
'create' onto its own path is a no-op).
* scaffold: two-step SDK release process (EMB-1518)
Replaces the hardcoded version/dist-tag computation in
release-embedding-sdk.yml with a new sdkRelease metadata block in
package.template.json (distTag, tagAsLatest), plus a new
sdk-version-bump-pr.yml workflow that bumps the version via `npm version`
and opens a PR labeled sdk-release-trigger for review. Merging that PR
triggers the release workflow directly.
Unverified: scaffolded per EMB-1518 ahead of Nicolo/Roman's review, since
this can only really be exercised by triggering a real SDK release. See
.claude/kelvin/2026-07-09-emb-1518-simplify-sdk-release-process-and-checklist-maintenance/
for the plan and open questions (particularly around distTag semantics for
stable/patch/minor releases).
* fix: correct distTag comment, <major>-stable is real (EMB-1518)
npm dist-tag ls @metabase/embedding-sdk-react confirms 51-stable
through 62-stable are already live, actively-used tags - not a new
concept as the prior comment assumed. Resolves the one open question
this scaffold couldn't settle from the proposal text alone.
* fix: address review cycle 1 findings (EMB-1518)
Fixes release-breaking and correctness issues found in review cycle 1
of the SDK release process scaffold:
- beta release_type now defaults to <major>-beta dist-tag instead of
the literal (non-existent) "beta" tag
- validate branch/release_type combinations that the old
getNextSdkVersion used to hard-error on (master requires alpha,
release branches reject alpha)
- pull_request trigger on release-embedding-sdk.yml now scoped to
package.template.json via a paths filter, and the concurrency group
no longer cancels an in-flight release on unrelated PR closures
- readme-PR follow-up steps now skip cleanly when the readme was
already up to date, instead of failing and firing a false alert
- tagAsLatest now always resets to false rather than carrying forward
a prior true, and the actual value is shown in the summary/PR body
- version-bump branch name includes the run id to avoid collisions on
retry
- renamed a step whose name didn't match what it does
See the "Review cycle 1 (fable)" section and the new "Fixes applied
for review cycle 1" subsection in the plan file for full detail on
each finding and its disposition.
* fix: address review cycle 2 findings + simplify version bump (EMB-1518)
Cycle-2 findings: add a preminor release_type so master's next-major
alpha bump no longer needs a hand-made PR (#9); make the Slack
failure notification independent of an artifact upload that may
never have run (#10); guard against two open version-bump PRs racing
each other (#11); stop interpolating the free-text branch input
directly into shell scripts (#12).
Also replaces the npm-version rename dance (rename to package.json,
bump, rename back, plus a .bak copy) with direct calls to the semver
package the bump logic already wraps, writing the result via jq
instead - no functional change, removes the dead .bak file (#13).
* fix: address review cycle 3 findings + rename resolve-release job (EMB-1518)
Fixes findings #14-#16 from the third fable review cycle:
- #14: the failure Slack alert's messageId interpolation was unquoted,
rendering a JS syntax error whenever send-slack-notification-start was
skipped (the exact scenario cycle-2's fix#10 was meant to cover). Now
quoted, and falls back to sendSlackMessage (new top-level message)
instead of sendSlackReply when there's no thread to reply to.
- #15: finished the cycle-2 injection-hardening pass by routing the two
remaining inline branch-value usages (workflow-summary's heredoc,
publish-npm's gh pr create --base) through env: instead of ${{ }}.
- #16: added a semver validity pre-check before the bump step, so an old
release branch's 0.N.x placeholder version fails with a clear ::error::
instead of a silent exit 1 from the semver CLI.
#17 (minor's version-collision caveat on old release branches) is left as
an open question for Nicolo/Roman rather than removed - documented with a
comment and Judgment call #6. #18 (merge-commit-sha race) is deferred -
it fails loudly rather than silently, lower priority than the others.
Also renames the resolve-release job to prepare-release throughout
release-embedding-sdk.yml, per explicit request (not a review finding).
See the plan file's "Fixes applied for review cycle 3" section for the
full verification detail.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec
* feat: auto-compute tagAsLatest from vars.LATEST_VERSION (EMB-1518)
Resolves Judgment call #2: the SDK version-bump workflow previously
always defaulted tagAsLatest to false because there was no reliable
way to know if a branch is the current "latest" major. The repo
already exposes vars.LATEST_VERSION (major version of the most recent
gold/stable release) for exactly this, so patch/minor bumps now set
tagAsLatest=true when MAJOR_VERSION matches it, false otherwise
(including when the variable is unset). alpha/beta/preminor stay
false. The PR review gate still allows overriding the computed value
before merging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec
* refactor: use github.ref_name instead of inputs.branch (EMB-1518)
github.ref_name is already set by GitHub's "Use workflow from" ref
picker on every workflow_dispatch run, so the custom inputs.branch
free-text field was duplicating a selection that was already made -
and could silently diverge from it. Switch every functional read of
inputs.branch to github.ref_name in both release workflows; keep the
branch input field itself, but make it explicitly non-functional
(required: false, no default, description points at the ref picker).
type: choice was considered and rejected again here: it would close
the typo risk but can't accept an unregistered branch, breaking
one-off custom-branch releases without editing the workflow file per
release - exactly the maintenance burden EMB-1518 removes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2Va4ryWuZbQvmUt2N85ec
* fix: remove dead readme-update mechanism (EMB-1518)
update_readme's two sed patterns match zero occurrences in the current
README (restructured by 15f7d3d20e, no version-pinned content remains),
so publish-npm's readme-follow-up-PR step has been a silent no-op on
every release, not just re-runs. Deleted the entire dead block (7 steps,
the env vars and pull-requests:write permission it alone used) and the
now-unused update_readme function.
* fix: drop minor release_type option (EMB-1518)
`semver -i minor` on a release branch jumps to the next major's
version line, colliding with that major's own branch. `patch` already
covers graduation and `preminor` already covers the legitimate
next-major jump (scoped to master). Drop `minor` outright rather than
add compensating validation - resolves review-cycle-3 finding #17 /
Judgment call #6.
* fix: add explicit reviewer request for embedding team on bump PR (EMB-1518)
CODEOWNERS is deliberately stripped on release branches (a repo-wide
practice to cut auto-backport review noise, unrelated to this
workflow), so the bump PR's "code owner review is automatic" comment
was false there - it got zero reviewers requested on release
branches. EMB-1000 documented the identical gap in the old system's
follow-up PR. Fix targets the metabase/embedding team (not a named
individual - the old system's hardcoded reviewer has since left).
* fix: restrict release types by branch, add custom releases (EMB-1518)
A one-off branch could publish as the stable release. Validation only
guarded master and release-x.*, so `patch` on e.g. sdk-data-apps stripped
the prerelease id (0.62.5-data-apps.0 -> 0.62.5), computed the
<major>-stable dist-tag, and - when the major matched vars.LATEST_VERSION
- set tagAsLatest, shipping a feature branch to npm as the stable release.
Only check-git-tag stood in the way, and only if that version had already
shipped.
Each branch class now gets only the release types that make sense for it:
master takes alpha/preminor, release branches take beta/patch, any other
branch takes custom. tagAsLatest is additionally gated on a release-x.*
branch, so nothing published from master or a one-off branch can take the
npm `latest` tag.
The custom type keeps one-off branches (esbuild, data-apps) releasable
under that restriction: prerelease_id is set on the first cut and becomes
both the version's prerelease id and the <major>-<id> dist-tag, matching
the shape already live on npm (56-esbuild, 63-data-apps). Later bumps
leave it empty and semver reuses the id from the committed version.
Also drops the dead release_utils.bash (zero functions, zero callers),
replaces both free-text dispatch inputs with single-option choices that
cannot be typed into, points the release workflow's form at the bump PR,
and removes the scaffold banner comments.
* fix: read vars.LATEST_MAJOR_VERSION, not LATEST_VERSION (EMB-1518)
The variable is LATEST_MAJOR_VERSION - it is what release.yml already
reads to decide Docker's `latest` tag, and what the release checklist has
a github admin bump each cycle. `vars.LATEST_VERSION` does not exist, so
tagAsLatest would have computed false on every bump, including at gold,
without any error - the fallback for an unset variable is false.
* refactor: make the release concurrency group readable (EMB-1518)
Same three cases as before, but each arm now names what it produces
(sdk-release-<branch> for a dispatch or a merged bump PR, sdk-release-noop-<run id>
for a closed PR this workflow ignores), and the comment above maps them in
the order the expression tries them.
The noop arm is not decoration: concurrency is evaluated before any job's
`if:` runs, so a closed PR that prepare-release skips would still enter the
release group without it. cancel-in-progress stays false so a release is
never interrupted mid-publish.
Groups are prefixed rather than bare because concurrency groups are
repo-wide, not per-workflow.
* refactor: build the release scripts once, in their own job (EMB-1518)
The Slack jobs each got at release/dist/index.cjs a different way:
send-slack-notification-start checked out and built it (then uploaded it as
an artifact), send-slack-notification-failure built its own second copy, and
send-slack-notification-successful downloaded the artifact. The duplicate
build existed because the failure job has to fire even when the metadata read
fails - in which case the start job never ran, so its artifact never existed.
A build-release-scripts job now builds it once and uploads it, and all three
Slack jobs download it. The job hangs off prepare-release only, so the
artifact is there no matter how early the release fails - which removes the
reason the second build existed.
* fix: quote the Slack messageId in the success notification (EMB-1518)
A Slack ts is a string. Interpolated bare it becomes a JS number literal and
drops trailing zeros - 1783602196.214000 renders as 1783602196.214, and the
reply targets a thread that does not exist. Pre-existing on master; the
failure job already quoted it, so this makes the two consistent.
* refactor: resolve the release branch in one expression (EMB-1518)
The env: BRANCH indirection was injection hardening from when the value was
the free-text `inputs.branch`. It is github.ref_name now - a real branch
name, same as github.event.pull_request.base.ref, which the next line already
inlined - so it guarded nothing and made the step asymmetric.
base.ref is empty on a dispatch, so `base.ref || ref_name` picks the right one
without branching on github.event_name.
* refactor: drop the duplicate-bump-PR pre-flight (EMB-1518)
It guarded a case the design already handles safely. Two open bump PRs
against the same branch both compute from the branch's committed version, so
both propose the same next version: the first to merge publishes and tags it,
and the second dies at check-git-tag with "Tag ... already exists" (npm would
reject the republish too). Nothing is published twice, and the error says
exactly what happened.
Getting there needs someone to dispatch the bump twice and leave the first PR
open, with both PRs sitting labelled in the list. That is not worth an API
call and 16 lines on every bump.
* chore: trim the bump step's comments (EMB-1518)
* refactor: drop the semver validity pre-check (EMB-1518)
Every supported release branch (57 through 63) already carries a valid
semver, and the new process never writes a `0.N.x` placeholder - gold is a
patch bump (0.63.0-beta.3 -> 0.63.0). The placeholder only ever existed
transiently under the old checklist, which rewrote it back to a concrete
version after each release.
Also saves an npx invocation on every bump.
* chore: spell out PRERELEASE_ID in the bump workflow (EMB-1518)
* refactor: spell out the semver call in each release type (EMB-1518)
alpha and beta shared an arm that passed --preid="$RELEASE_TYPE", and patch
passed -i "$RELEASE_TYPE" - you had to know the variable's value to know what
command ran. Each arm now names its own call.
The release_type description now leads with the phase (before gold, at gold,
after gold), which is what the person dispatching actually knows. It also
corrects preminor: master moves to the next major *after gold*, not at branch
cut - the 62 cycle did it in the after-gold checklist (#75487, 0.62.0-alpha ->
0.63.0-alpha), and 61 before it (#74279).
* docs: stop calling the bump PR a review gate (EMB-1518)
Nothing blocks on approval: the bump PR is not auto-merged but it is not
gated on a reviewer either, and workflow_dispatch can publish with no PR at
all. The computed distTag/tagAsLatest are defaults made visible and editable
- whoever dispatched the bump has already decided.
* docs: note EMB-2095 (auto-merge the bump PR) in the workflow (EMB-1518)
* docs: tighten the tagAsLatest comment (EMB-1518)
* refactor: collapse the tagAsLatest case into one condition (EMB-1518)
* docs: say what the sdkRelease write does (EMB-1518)
* refactor: drop the label-creation step (EMB-1518)
The sdk-release-trigger label is repo-level, so it only ever needed creating
once - it now exists. Re-running `gh label create ... || true` on every bump
swallowed every error (including auth) to no purpose.
* fix: fail on a leftover bump branch instead of side-stepping it (EMB-1518)
The run-id suffix made every retry a fresh branch, so an abandoned bump left
a branch on origin forever and the name was unpredictable. The branch is now
named after the version, and an existing one is an error: an earlier bump to
the same version was never merged, which is worth knowing before opening a
second PR alongside it.
* refactor: drop the no-backport label step (EMB-1518)
Backporting is opt-in - auto-backport.yml only fires on a `backport` label -
and backport-reminder.yml only demands a decision label when a PR touches
docs/**. The bump PR touches one JSON file, so no-backport guarded nothing.
Keeps the reviewer rationale, moved next to the --reviewer flag it explains.
* feat: show the target branch and computed values in the bump PR body (EMB-1518)
The PR body did not say which branch it targeted, which is the first thing
you want to know when a release PR shows up. Branch, release type, version,
dist-tag and tagAsLatest are now a table at the top.
* fix: pin semver and validate prerelease_id (EMB-1518)
npx --yes semver resolved whatever the registry served at dispatch time, on
a job that installs no dependencies - so release-critical version math could
change under us while package.json already pins ^7.6.3. Pinned to semver@7.
prerelease_id was passed to semver unchecked. An id like `DataApps` bumped
fine and then died three steps later in the dist-tag read-back, whose regex
only matches [a-z][a-z0-9-]*, with an error blaming the computed version
instead of the input. It is now rejected up front, with a message naming the
rule.
* fix: take semver's version from package.json (EMB-1518)
Pinning the workflow to semver@7 fixed the drift but created a second
version to keep in step. Read the range the repo already pins instead
(devDependencies.semver, currently ^7.6.3) and hand it to npx, so bumping
semver in package.json is the only place it happens.
* refactor: rename bump workflow input to read_first (EMB-1518)
* feat: reject prerelease_id on non-custom release types (EMB-1518)
* refactor: drop the preminor bump-only warning from the PR body (EMB-1518)
* refactor: drop the duplicate rerun wording in tag-exists error (EMB-1518)
* fix: reject reserved alpha/beta as a custom prerelease_id (EMB-1518)
* refactor: tidy and clarify comments in the SDK bump workflow (EMB-1518)
* fix: satisfy actionlint in the SDK release workflow (EMB-1518)
* feat: extract embedding SDK release logic into a tested helper (EMB-1518)
* refactor: remove dead embedding SDK helpers from version-helpers (EMB-1518)
* fix: drop trailing blank line in the SDK bump workflow (EMB-1518)
* refactor: run the SDK version bump from a tested helper, not inline bash (EMB-1518)
* docs: note the env block guards github-script against injection (EMB-1518)
* refactor: drop the unused LATEST_MAJOR_VERSION fallback in the bump script (EMB-1518)
* feat: show the target branch in the bump PR title (EMB-1518)
* docs: drop the manual-edit invitation from the bump PR body (EMB-1518)
* refactor: tidy the SDK release helper and its tests (EMB-1518)
* refactor: read the SDK release metadata via the tested helper (EMB-1518)
* refactor: env-route the branch name in the bump PR title (EMB-1518)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156DAaukfiU6xceXd3brbZN
* refactor: simplify SDK release concurrency group (EMB-1518)
Collapse the three-case concurrency expression to one per-branch group, and
switch every multi-item needs array to a block sequence.
The dropped noop group guarded a compound case: GitHub keeps one pending run
per group and cancels any earlier one, so a closed PR touching the template
could evict a release waiting to start. That needs two same-branch releases
in flight at once, so we take the simpler expression. The clean fix is
queue: max, which actionlint cannot parse yet.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Deflake transform revision history spec
The "view and revert transform revisions" test changed the transform
name twice, each firing a "Transform name updated" success toast that
lives for the default 5s undo timeout. Unlike every other update flow in
this spec, these toasts were never dismissed. On a fast runner the test
reached the failed-revert error toast within that window, so a lingering
success toast coexisted with it and H.undoToast() (a single-element
findByTestId on [data-testid="toast-undo"]) failed with "Found multiple
elements".
Dismiss each success toast right after it appears, matching the existing
convention used elsewhere in this file, so exactly one toast is present
when the error toast is asserted.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Nemanja <31325167+nemanjaglumac@users.noreply.github.com>
* Add semantic search health checks
* Split HNSW recovery from semantic health
* Use HoneySQL literals for repair queries
* Use dynamic redefs in semantic search test
* Auto-scroll to a dashcard whenever it becomes the scrollTo target, and keep the scroll until the grid settles
* Drop a stray brace from the auto-scroll spec's first visit URL
* Simplify the auto-scroll correction to a fixed frame window
* Delete the react-router v3 engine
* Sever the react-router package dependency
* Give the SDK data-app router its own browser history on v7
* Rename the react-router-v7 alias to react-router
* Rename getDataAppHistory to getRawBrowserHistory, drop dead getCurrentHistory
Scope existence preconditions to their table; lint against name-only checks
A foreignKeyConstraintExists precondition with only foreignKeyName (or
indexExists with only indexName) forces Liquibase to snapshot the entire
schema to answer the check -- ~2s per precondition on a large postgres
appdb. 27 such preconditions (24 FK, 3 index; 21 in one v59 workspace
block) added ~46s to every full migration: 50.2s of total changeset time,
of which 24 changesets cost 45.9s and the other 1,182 cost 3.7s.
Add the scoping key to all 27. Measured on a fresh postgres migration:
total changeset time drops 50.2s -> 5.1s, with zero changesets over 1s.
Editing released changesets' preconditions is safe: Liquibase checksums
cover the changes, not preConditions. Verified two ways -- ChangeSet
.generateCheckSum is byte-identical before/after for all 27 (v9
checksums), and a clone of an already-migrated appdb re-validates
cleanly against the edited files.
lint-migrations-file: generalize require-primary-key-exists-has-table-name
into require-table-scoped-existence-preconditions, covering
primaryKeyExists/indexExists/uniqueConstraintExists (tableName) and
foreignKeyConstraintExists (foreignKeyTableName), walking the full
precondition tree in both its flat-map and list forms.
Claude-Session: https://claude.ai/code/session_01G7FfdpzW5pr7CX9aNwa98f
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fall back to .yaml if .yml not found
Update docs to mention `.yaml` support
* Fall back to .yaml if .yml not found
Update docs to mention `.yaml` support
* Update file.clj
* Fix tests
* Address feedback
* Drop field_usage table as it is no longer used.
* Stop saving preprocessed_query in metadata since it's not used any more.
This used to be used for saving off field usage, but that hasn't been
a thing for a very long time.
* Make model-illustration.svg work well in both light and dark mode
* whitelabel svg
---------
Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com>
* Migrate PinMap to a typed function component and extract a pure getPoints
* Type the pin metric as nullable, derive MapSettings from VisualizationSettings, and add createMockVisualizationProps
* Trim comments, replace the map ref cast with an instanceof guard, and cover instance clearing
* Don't block dashboard saves on pre-existing foreign internal cards
UXW-4870: the guard added in #77236 (reject assigning a dashcard's card
to a question internal to another dashboard) checked updated dashcards
unconditionally, so any dashboard with a pre-existing foreign internal
card association became unsavable — every edit, even a resize, returned
400 "Invalid Request.".
Scope the guard to newly-introduced card ids: card ids the dashboard
already references (as a dashcard's card or in a series) are
grandfathered, so existing associations never block saving — including
moving such a card to a different dashcard in the same save. Newly
assigning a question internal to another dashboard is still rejected.
* Clarify arg names in internal-dashcard guard
Rename the guard's params to existing-dashboard/new-dashcards to make
clear the grandfather set comes from stored state while the checked
dashcards are the incoming save.
* Guest embed filter dropdowns should use the router database (UXW-4881)
PR #68952 made guest-embed query execution bypass DB routing by wrapping it
in with-database-routing-off, but the parameter-values endpoints (dropdown
values, search, remapping) didn't get the same treatment: chain-filter runs
through the QP, where attach-destination-db-middleware throws "Anonymous
users cannot access a database with routing enabled."
- Wrap the query-running sections of the four param helpers in
metabase.embedding-rest.api.common in with-database-routing-off, fixing
all six /embed/{card,dashboard}/:token/params/* endpoints plus their
preview_embed equivalents.
- Wrap the guest-embed tiles endpoints, which have the same anonymous-user
bug.
- Wrap preview_embed's query-execution endpoints (query, dashcard, pivot,
tiles) so the admin's embed preview shows exactly what the published
embed will show (router DB data) instead of routing via the previewing
admin's user attribute.
- Replace the manual *current-user-permissions-set*/*is-superuser?*
bindings in the param helpers with the equivalent request/as-admin.
Public sharing has the same gap but whether routed DBs should work over
public links at all is an open product question, so it is intentionally
left out.
* Consolidate embed routing-off wraps into the shared execution helpers
Review feedback: rather than a macro that each endpoint must remember to
use, move with-database-routing-off inside the shared execution helpers in
metabase.embedding-rest.api.common (process-query-for-card-with-params,
process-query-for-dashcard, process-tiles-query-for-dashcard, and a new
process-tiles-query-for-card wrapper), which every /api/embed and
/api/preview_embed endpoint funnels through. Endpoints built on these
helpers now bypass routing automatically, so a future endpoint can't
reintroduce this class of bug.
Removes all per-endpoint wraps from embed.clj (including the two original
ones from #68952) and preview_embed.clj; the invariant now lives in one
file with one explanatory comment.
* Fix tooltip scrollbar flicker in TreeTable (GDGT-2822)
Hovering a cell tooltip in a virtualized TreeTable while scrolling made a
page-level vertical scrollbar flash on and off. The tooltip is portaled to
<body> and positioned with real top/left insets, and with the document root
sized to the exact viewport height, a tooltip positioned past the fold grew
the document and flashed a scrollbar.
Portal cell tooltips into the table's own scroll container instead of <body>,
so a tooltip positioned past the fold is clipped by the (already-scrolling)
table rather than stretching the page. This is wired through a small
TooltipPortalTargetProvider context so the metabase/ui Tooltip wrapper picks
up the target automatically — no per-tooltip wiring, and any TreeTable with
cell tooltips benefits (e.g. the notifications admin table).
* Address PR comment
* Fix Tooltip wrapper not forwarding ref
* Add monitor dir in metabase-enterprise
* GDGT-2676: Migrate Dependency Diagnostics under Monitor space
* GDGT-2677: Migrate Admin -> Tools under Monitor space
* GDGT-2676: Fix paths
* GDGT-2676: Create separate high-level directories for migrated features
* GDGT-2657, GDGT-2686: Monitor space + migrate Dependency Diagnostics (#76527)
* GDGT-2657: Introduce Monitor space layout and routing
* GDGT-2686: Migrate Dependency Diagnostics to Monitor
* Monitor: Add Monitor plugin. Cleanup + fixes
* Monitor: Update graphy ownership path for Dependency Diagnostics
* Monitor: Cleanup PR
* GDGT-2697: Update hotkeys hints for Toggle navbar
* GDGT-2684: Address Copilot review comments
* GDGT-2684: Address PR comments
* GDGT-2684: Rename SpaceLayout to AreaLayout. Split to separate files
* GDGT-2684: Refactor route guards + add tests
* GDGT-2684: Move Admin Tools pages to the Monitor space (#76606)
* GDGT-2684: Move Admin Tools to Monitor space
* Monitor: Extract diagnostics sidebar into shared Monitor sidebar outlet
Add a useMonitorSidebar context to MonitorContent so Monitor pages can
render sidebars in a dedicated right-hand region outside the padded,
scrollable main content area. Rewire Dependency Diagnostics to render its
detail sidebar through this outlet, and promote the resizable shell
(SidebarResizableBox) into shared monitor/components.
* Monitor: Add MonitorHeaderTitle component for consistent page headers
Introduce a shared MonitorHeaderTitle matching the small secondary text
style used by Data Studio breadcrumbs, and apply it across Monitor pages:
Dependency Diagnostics header, Jobs, Logs, Model cache log, Task list/runs,
Task and task-run details, Erroring questions, and Alerts management.
* GDGT-2684: Cleanup
* GDGT-2684: Update monitoring tools access permissions
* GDGT-2684: Add handling for not-found routes
* GDGT-2684: Fix not-found routes handling in Data Studio
* GDGT-2684: Extend route tests. Minor styling refactoring
* GDGT-2684: Refactor Alerts Management sidebar
* GDGT-2684: Move old SidebarResizableBox to ModelSibar
* GDGT-2684: Cleanup
* GDGT-2684: Refactor Sidebar solution to use Portal
* GDGT-2684: Fix Monitor Tools E2E specs (#76886)
* Format monitor router files after the master merge
* Import Route from metabase/router facade in AreaTab spec
Fixes the no-restricted-imports lint error banning direct react-router imports.
* GDGT-2713: Update Tasks page styling (#77088)
* GDGT-2713: Update Tasks page styling
Restyle the Monitor Tasks and Runs pages to match Dependency diagnostics:
- Replace the SettingsSection card + default tabs shell with the Monitor
frame (fixed-height page, pills tabs; only the table scrolls)
- Rebuild TasksTable and TaskRunsTable on the TreeTable data-grid
(sticky header, virtualized rows, skeleton loading, a11y labels)
- Move filters into a Group bar and pagination below the table with a
total results count
- Keep the 2-state sort contract (always sorted, desc/asc) on top of
TanStack's 3-state cycle; asc-first on all sortable columns
- Add MonitorHeaderTabs (Monitor-owned pills tabs, no data-studio
dependency) and extract the shared LinkTab router-link tab to
metabase/common/components
* GDGT-2713: Fix e2e test
* Migrate LinkTab to the metabase/router facade
* Remove unused Link import from PaneHeader
The Tasks styling switched the tabs to LinkTab, leaving Link unused.
* Address PR review comments and fix entity_name typing
Use strict undefined comparisons instead of loose null checks where
values can only be undefined (optional props, RTK Query error,
destructured SortingState), and strict null comparisons where the
value can only be null (Task.db_id).
Also fix TaskRun/RunEntity entity_name typing: the backend always
assocs this key (nil when the entity lookup misses), so it's
string | null on the wire, not optional.
* GDGT-2712: Update Jobs page styling (#77100)
* GDGT-2712: Update Jobs page styling
Restyle the Monitor Jobs page to match Dependency diagnostics:
- Rebuild the page on the Monitor frame (fixed-height page; only the
jobs table scrolls, with a sticky header)
- Render the scheduler info in a Code block instead of a bare pre
inside a settings card
- Rebuild the jobs table on the TreeTable data-grid (skeleton loading,
empty state, a11y label, keyboard row activation); row click still
opens the job triggers modal
- Move JobInfoApp into a folder and add unit coverage; add
TaskInfo/Job API mocks and a task-info server mock
* Make the Monitor sidebar outlet resizable
Move the resizing behavior from the Dependency diagnostics sidebar into
the shared Monitor Sidebar so every Monitor view gets it without
duplication:
- Relocate SidebarResizableBox from metabase-enterprise/monitor into
MonitorLayout and wrap the Sidebar portal content with it
- Sidebar API: optional resizable (default true) with containerWidth
required while resizable, optional defaultWidth (512 by default);
consumers measure their content area with useElementSize and pass it
- Protect the Monitor content area from text selection while dragging
inside Sidebar instead of per-view
- Simplify DiagnosticsSidebar/DependencyDiagnostics accordingly and
drop the now-empty metabase-enterprise/monitor/components tree
- Make the Alerts management detail sidebar resizable (fills the
resizable box, keeps its 560px default width)
* GDGT-2712: Jobs page styling fixes
* Replace the job triggers modal with a Monitor sidebar
Clicking a job on the Jobs page now opens the job's triggers in the
resizable Monitor sidebar outlet instead of a modal:
- New JobTriggersSidebar renders each trigger as a vertical key/value
block (all fields the modal table had), with a divider between
triggers and a close action that returns to /monitor/jobs
- The /monitor/jobs/:jobKey route stays, so deep links and back/forward
keep working; JobInfoApp reads the jobKey param and renders the
sidebar, measuring its content area for the resize constraint
- Delete JobTriggersModal; add a Trigger API mock
- Give the scheduler info Code block the old card padding and keep it
from shrinking
* Apply cross-review fixes for the jobs sidebar work
- Render trigger details as a dl/dt/dd list for proper label/value
semantics (no visual change)
- Cover the shared Sidebar resize side effect (user-select class on the
Monitor container across drag start/stop/unmount)
- Cover keyboard row activation, all trigger attributes with null
placeholders and the may-fire-again 'No' branch, and the no-triggers
empty state
* GDGT-2712: Cleanup
* GDGT-2712: Add distinct error message when job is not found
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Migrate JobInfoApp to the metabase/router facade
* Disable text selection on body while resizing the sidebar
Address review comment: dragging the resize handle otherwise causes
the browser to continuously select/deselect page text, since the
sidebar is portaled and doesn't share a DOM ancestor with the main
content to scope a CSS class to.
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* GDGT-2711: Update Logs page styling (#77160)
* GDGT-2711: Extract LogsViewer to common Monitor components
* GDGT-2711: Update Logs page styling
* GDGT-2711: Cleanup
* GDGT-2711: Address PR comments
* GDGT-2711: Address PR review comments
Rename LogsViewer's process prop to processUUID, drop the unused
logs-content id, and remove a few comments flagged as redundant.
* GDGT-2661: Update Erroring questions page (styling + refactoring) (#77167)
* GDGT-2661: Restyle Erroring questions page
Rewrite the EE /monitor/errors page off the legacy audit_app viz onto the
Monitor recipe used by the other Monitor tools pages, keeping the same
backend audit query:
- TreeTable with the 11 audit columns, 2-state sortable headers, and native
row selection; "Rerun Selected" moved to the standard bottom BulkActionBar
- Dependency-diagnostics-style debounced filter inputs
- Page no longer scrolls; the table body is the sole scroll region
- Pagination outside the table with a total count (new bad-table-count audit
query), hidden until the data has loaded, and synced to a ?page URL param
- Requests abort the previous in-flight one on paging/sorting/filtering
Replaces the AuditParameters/AuditTable/ErrorMode stack; deletes the old
ErrorOverview and its click-mode.
* Remove dead legacy audit-table v1 code
The Erroring questions page was the last consumer of the Audit v1 table
stack. With it rebuilt on TreeTable, these are unreferenced and removable:
AuditTable, QuestionLoadAndDisplay, AuditTableVisualization (the last
"audit-table" display), AuditParameters, the bad_table card def, and
AuditMode. The rest of audit_app (unsubscribe UI, InsightsLink,
metabot-analytics) is untouched.
* Fix duplicate/incorrect cards in the erroring-questions audit query
The latest_qe CTE joined query_execution to the per-card MAX(started_at)
subquery on started_at alone, so executions across cards that shared a
timestamp fanned out — surfacing duplicate rows (and cards whose latest run
didn't actually error). Join on card_id as well. Regression test covers a
shared-timestamp collision.
* GDGT-2661: Address review feedback
- Surface count-query failures in the page error state so a failing count
can't silently hide pagination
- Filter bar reflows (wrap) instead of clipping at narrow widths / zoom
- Skeleton renders the checkbox column to avoid a layout shift on load
- Centralize the internal-dataset-query cast to a single boundary
- Add tests: count db/collection filter clauses (backend), 202-body
data.error path, real filter-reset assertion, rerun failure recovery
* GDGT-2661: Fold pagination total into the rows query via COUNT(*) OVER()
The page ran two /api/dataset requests per view — the bad-table rows and a
separate bad-table-count — each recomputing the expensive latest_qe scan over
all query executions. Add a COUNT(*) OVER () total_count column to bad-table so
the full filtered total rides on the rows response, and drop the count query.
Halves the round-trips and eliminates the second latest_qe scan (~2.1s -> ~1.4s
of DB work on the Stats dump); the window count adds no measurable overhead
since it runs over the already-materialized result set before LIMIT.
* Migrate ErrorOverview to the metabase/router facade
* GDGT-2661: Return erroring-questions total_count at the response root
The bad-table query computes COUNT(*) OVER () as a column, which repeated the
total on every row. Strip that column via an xform and surface the value once
as a top-level total_count on the response; the FE reads it from there.
* GDGT-2661: Non-blocking per-row rerun with loader in erroring questions
Rerun now runs in the background per card instead of blocking on the whole
batch: clicking Rerun Selected closes the bulk bar immediately and fires each
request independently, so more questions can be queued while slow ones run.
A rerunning row shows a loader in place of its checkbox, is excluded from
Select all, and its loader clears when the request settles; the list refetches
once the last rerun finishes.
TreeTable gains a generic isRowLoading prop and makes the whole checkbox cell
a click target so a near-miss no longer navigates to the row.
Also hoist total_count onto the Dataset type and drop the local helper.
* Extract shared generic sorting helpers for monitor tables
The monitor tables (erroring questions, tasks, dependency diagnostics) each
duplicated getSortingState/getSorting/isSortColumn. Extract them into a generic
metabase/utils/sorting module keyed by the column type. getSorting is overloaded
so the un-sort behaviour is type-driven: pass the current sort for always-sorted
tables (direction flips) or omit it to allow an unsorted result.
Refactor the three monitor tables onto the shared helpers.
* Update erroring questions E2E selectors for the new table
Point the tests at the TreeTable-based ErrorOverview: erroring-questions-table
container, erroring-question rows, and the always-enabled filters / bulk bar
that closes on rerun instead of a disabled button.
* Bind *driver* when reducing audit internal queries
The MySQL timestamp reader calls results-timezone-id, whose driver arg is
malli-validated as a keyword in tests. reduce-results* bound the timezone
override but not *driver*, so it was nil and audit queries returning timestamps
errored on MySQL/MariaDB. Bind *driver* to the app-db driver alongside the
override.
* GDGT-2661: Address PR comments
* GDGT-2661: Merge erroring questions input into one, extend to support name search
* GDGT-2661: Fix checkbox cell width in rows
* GDGT-2661: Fix e2e test
* GDGT-2661: Address PR comments
* GDGT-2710: Update Model cache log page styling (#77277)
* GDGT-2713: Update Model cache log page styling
Restyle to match Dependency diagnostics: the page no longer scrolls
vertically, only the table does. Replace the hand-rolled AdminS.ContentTable
with a metabase/ui TreeTable (skeleton while loading, empty state, refresh
action), and drop the emotion-styled ModelCacheRefreshJobs.styled.tsx in
favor of a CSS module.
Model and Collection columns are the two stretching columns (fill remaining
width) and support client-side sorting, since the underlying /api/persist
endpoint has no sort param.
* Unify monitor tables on 3-state sortable headers
Erroring questions and Tasks forced a 2-state flip-forever cycle by passing
the current sort back into getSorting(); drop that so a 3rd click clears the
header's sort arrow and falls back to the page's default sort, matching
Dependency diagnostics and Notifications.
The default-sorted column on each page needs sortDescFirst matching its
default direction (desc), otherwise the very first click on that column is a
no-op: TanStack's per-column cycle treats desc as the removable/last state
when sortDescFirst is false, so a column that starts at desc goes straight to
removed on click one.
* Make all applicable columns sortable in Model cache log and Jobs
Both are fully-loaded, unpaginated lists, so enabling client-side sorting on
the remaining columns is safe. Model cache log's action column stays
unsortable (no header text or data). Tasks and Task runs are left as-is:
their backends only support a fixed set of sort columns (or none), and
sorting the rest client-side would silently reorder only the current page.
* GDGT-2710: Address PR comments
* GDGT-2709: Update Alerts management page styling (#77364)
* GDGT-2709: Update Alerts management page styling
Restyle to match Dependency diagnostics: the page no longer scrolls
vertically, only the table does. Move pagination outside the table into a
plain footer and show the total result count via PaginationControls'
showTotal.
* Restrict Alerts management to admins only
The /api/notification/admin* endpoints are superuser-gated on the backend,
but the frontend route and nav link were reachable by anyone with the
broader can_access_monitoring permission (shared with the other Monitor
Tools pages), so a non-admin holding it could reach the page while every
API call inside it 403'd. Add a dedicated canAccessAlertsManagement
selector and route guard so the route/nav link match the backend's
admin-only restriction.
* Fix misleading test name in MonitorLayout spec
The test asserts the link is hidden for a non-admin analyst, not that it's
shown for an admin.
* GDGT-2794: Update Task/Run details page styling
Extract the "Back to Tasks"/"Back to Runs" breadcrumb into a shared
MonitorBackLink component, styled and positioned like other Monitor
page headers. Wrap both detail pages in the same non-scrolling shell
used elsewhere in Monitor so only the content area scrolls internally,
instead of the whole page scrolling behind the floating AppSwitcher.
* Extract MonitorPageContent to replace SettingsSection in Monitor
SettingsSection carries admin-page title/description logic that Monitor
pages don't use. MonitorPageContent keeps the same card styling with none
of that logic, dropping the last two /monitor call sites still importing
from metabase/admin.
* GDGT-2709: Fix layout in Tasks Details page
* GDGT-2709: Refactor NotificationsTable row selection logic. Fix selection with spacebar
* GDGT-2791: Abort superseded in-flight list requests in Monitor (#77694)
* GDGT-2791: Abort superseded in-flight list requests in Monitor
Changing page/sort/filter on a Monitor list page left the previous, often
expensive, list request running to completion on the server. Subscribed RTK
useXQuery hooks only abort when their last subscriber unmounts, so introduce
a shared useAbortableQuery hook built on lazy triggers (whose promise exposes
.abort()) that cancels the superseded request on arg change, unmount, and
skip transitions, and suppresses the abort error so cancellations never flash
the error view.
Caching mirrors useQuery: cache-first by default, refetchOnMountOrArgChange
to opt into always-fetch; manual refetch() always hits the network. Every
converted call site keeps its pre-migration caching semantics.
Converted: TaskListPage, TaskRunsPage, TaskRunEntityPicker,
ModelCacheRefreshJobs, NotificationsAdminPage (main list),
DependencyDiagnostics, BrokenDependentsSection, and ErrorOverview (its
one-line useAbortableAdhocQuery wrapper is inlined and hooks.ts deleted).
* GDGT-2791: Cleanup
* GDGT-2791: Fix type narrowing in TaskRunEntityPicker
* GDGT-2791: Clamp pagination range to total
* GDGT-2791: Test that tag invalidation does not refetch superseded args
* GDGT-2791: Scope query aborts per hook instance via injected cache key
* GDGT-2791: Migrate _refetchDeps to the shared __rtkCacheKey mechanism
* GDGT-2791: Refine baseQuery typings. Cleanup
* GDGT-2791: Replace interface in params declarations with type. Refine BaseQuery params type
* GDGT-2832: Group Monitor navbar items into sub-menus (#77706)
* GDGT-2832: Move AreaContent to AreaLayout and normalize Monitor page spacing
* GDGT-2832: Group Monitor navbar items into sub-menus
* GDGT-2832: Cleanup. Fix AreaTab outline overflow
* GDGT-2832: Match Alerts Management tab spacing to Dependency Diagnostics
* GDGT-2832: Replace collapsible navbar groups with static labelled sections
* GDGT-2832: Update Monitor views headings to match navbar. Fix e2e test
* Monitor section design tweaks (#78007)
* icon and spacing updates
* remove info icon from background tasks page heading
* improve empty states
* GDGT-2810: Enable sorting in all Monitor views (#77842)
* GDGT-2810: Enable sorting on all Background tasks and Task runs columns
* GDGT-2810: Remove bold styling from first Monitor table columns
* GDGT-2810: Fix reflection lint warnings in task history API test
* GDGT-2810: Qualify task_run/task_history filter columns
The entity_name and task_count sorts add joins (report_card and
report_dashboard both have their own entity_id column), so unqualified
filter columns become ambiguous: /api/task/runs?entity-id=N&sort_column=entity_name
returned a 500. Qualify the filter columns and add a regression test
combining filters with the derived-column sorts.
* GDGT-2810: Cleanup
* GDGT-2810: Use kebab-case sort params on the task runs endpoint
* GDGT-2810: Extract shared useSortingStateChange hook for TreeTable sorting
* GDGT-2810: Extract shared page/sort URL param parsers
* GDGT-2810: Fix e2e tests
---------
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
* GDGT-2843: Integrate Monitor upsell pages (#77948)
* GDGT-2843: Migrate Monitor upsell pages into monitor scope, link to /upgrade/monitor
* GDGT-2843: Tighten monitor module boundaries to standard shared tier
* GDGT-2843: Change Monitor app icon from gauge to pulse
* GDGT-2843: Update pulse icon and Monitor logo
* GDGT-2843: Address PR comments
* GDGT-2843: Fix e2e tests
* Fix Monitor code review findings (#78106)
* Fix Monitor code review findings
* Remove obsolete dependencies redirects
* Monitor review: Cleanup
* Address review comments
* Cleanup
* Remove Dependency diagnostics link from Data Studio nav
It already lives under Monitor (/monitor/dependency-diagnostics); the
Data Studio route just redirects there now.
---------
Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
Co-authored-by: YB Tan <tybmeta@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Maz Ameli <maz@metabase.com>
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
* WIP: start flipping unit-test harness to v7
Default routerEngine to v7 and render bare (non-Route) children under a
catch-all route so v7's <Routes> renders them. role=link nav failures still
to diagnose. Suite not yet green.
* Give v7 tests a history handle and drop DOM-wrapped route config
The harness now owns the v7 memory history and hands specs a v3-shaped adapter
(getCurrentLocation/push/replace/goBack/listen), so the ~42 specs that drive and
assert navigation through the returned history keep working on v7.
Route config wrapped in a <div> is a v3 artifact that v7's <Routes> cannot read;
swap those two for fragments.
* Pin the v3-vs-v7 conformance suites to the v3 engine
They exist to prove the facade on v3 matches real v7 and are built from raw v3
route config, so they must not follow the harness default now that it is v7.
* Resolve a relative initial route in the v7 test history
* Buffer navigations dispatched before the v7 router registers
* Carry route props onto the v7 matched-route stubs
* Fix the v48 /browse/:dbId-:slug legacy redirect on v7
* Render facade routes in renderHookWithProviders on v7
* Migrate unit specs off v3-only routing behavior for the v7 flip
* Drop buffered v7 navigations when the router unmounts
* Default use-v7-router to true
* Re-home history@3 types onto the metabase/router facade
* Format the re-homed import blocks
* Merge duplicate router imports and drop unused lint directives
* Re-home the router-listen spec's history Location type
Wait for the DebouncedFrame to settle via ensureChartIsActive() before
clicking a group tile to drill in. While the frame transitions it carries
pointer-events: none, so the drill click was silently swallowed and the
breadcrumb button never rendered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Migrate AuditTableVisualization to a typed function component
* Fix Erroring Questions column sorting by forwarding sorting props through Visualization
* Extract typed viz definition to drop casts per review
* GHY-4016: Replace Field YAMLs with FieldUserSettings in git sync (PR 1/3)
Git sync currently exports one Field YAML per database column. For a
typical customer this is ~6396 files — 84% of the entire repo. Most of
that data (active, database_type, position, dimensions, ...) is sync
metadata that git sync has no business tracking; the user-curated part
lives in FieldUserSettings.
This PR switches git sync to export FieldUserSettings instead of Fields.
Only fields with user-curated metadata get a YAML at all.
Measured against the stats-remote-sync repo (196 published tables,
~20 columns each):
Import: 43s → 14s (3x faster, 7659 → 1263 files)
Export: 6.3s → 2.0s (3x faster, 2155 → 239 entities)
Files: 7659 → 1263 (84% reduction)
For a large customer (563k files, 522k Fields), ingest time alone drops
from 116s to 7s.
Full Field serdes is unchanged — non-git-sync export/import is
unaffected. FieldUserSettings already had complete serdes support
(generate-path, storage-path, load-find-local, make-spec); this PR
wires it into the git sync paths.
Changes:
- `serdes/descendants "Table"`: branches on `:user-edits-only` opts.
When set, emits `["FieldUserSettings" field_id]` only for fields with
a FUS row; otherwise emits `["Field" field_id]` as before.
- `spec.clj`: named `git-sync-extract-opts` constant (with
`:user-edits-only true`); public `pk-col` helper for the non-standard
FieldUserSettings PK (`:field_id` not `:id`).
- `extract.clj` (OSS): local `pk-col` for `:where [:in pk-col ids]`
(module boundary prevents depending on EE spec).
- `impl.clj`: `extract-chunk` uses `spec/pk-col` for FieldUserSettings.
- `models.clj`: adds `"FieldUserSettings"` to `data-model-in-collection`.
- `events.clj`: removes `:model/Field` from `register-events-for-spec!`
so field-update events no longer create Field RSOs. A dedicated
`::field-update-event` handler creates FieldUserSettings RSOs only
when a FUS row exists. This completes the incremental path: full
export uses `descendants` + `:user-edits-only`; incremental export
uses FUS RSO rows.
Tests assert on the full RSO table so stray Field RSOs fail immediately.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Address PR review: defensive get for dynamic pk-col, fix content-hash test for FieldUserSettings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* don't need maps, k/v pairs work
* Declare :model/FieldUserSettings module boundary export/import
remote-sync references :model/FieldUserSettings in events.clj, so it must
be exported by warehouse-schema and imported by enterprise/remote-sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove unused f1-id binding in extract test (kondo)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Test that Field and FieldUserSettings are serdes descendants leaf nodes
Cements the design discussed in review: Table's descendants method is the
only source of field-level entries, and the :user-edits-only branch is
exclusive — you get Field refs or FieldUserSettings refs, never both via
a transitive Field -> FieldUserSettings edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* WIP: dag run BE
* split into new table
* run utils
* remove dag changes on job runs
* new dag run model
* abstract over parent-run
* separate dag model/api
* factor common job/dag code in a coordinated-run primitive
* fixes
* dag test
* fix migration
* kondo
* missing resolution
* exclude model
* kondo config
* fix test
* fix: exclude for deser
* fixes
* fix: exclude for deser
* fix: missing fk
* random ids
* chore: rename
* feat: add unioned runs
* remove dead endpoints
* api tests
* extend filter semantics
* trim docstrings
* kondo
* gap: allow canceling job runs
* share code
* fix: job start should return proper job id
* fix: mig to 64
* simplify
* simplify further
* simplify
* dead code
* add unique constraint on dag runs
* tighten schemas
* tighten permissions
* fix test
* relax schema
* fix: set null on delete
* handle deleted jobs/transforms for dag runs
* appease the sql linter
* fix test
* simplify rollbacks
* simplify callsite
* fix tests from bad rebase
* fix: fallback when transform is deleted
* add transform_count
* fix tests
* log with context
* varchar 32 for status
* Add UI for running transform with upstream or downstream (#77367)
* GDGT-2625 wip: reprocess DAG UI for transforms
- Split "Run now" button (upstream/downstream) + confirmation modal
- runDag / dag-transforms / cancel-dag API + analytics event
- Tabbed Runs page: transform-graph runs (default) + individual transform runs
- Graph runs table (Run/Type cols), filter bar (Type/Transform/Started/Status),
sidebar with member transforms; shapes aligned to bronsa's /api/transform/runs
- List + members still served from a mock (transform-graph-runs.mock) via queryFn
* GDGT-2625 wip: align graph runs to /api/transform/runs, sidebar + cancel
- Reshape TransformGraphRun to backend RunSummaryResponse (run_type/entity_id/name)
- Filters mirror Transform runs: Type/Transform/Started/Ended/Status/Trigger
- Sidebar: title = Run name, Type as first metadata row, cancel section below
metadata (dag/job/transform), confirm modal; job-run cancel endpoint wired
- Mock covers job/dag/transform in all states (succeeded/started/failed/timeout)
- Reorder columns: Status/Trigger after Duration
* GDGT-2625 wip: sidebar header entity links
Add "View this job" / "View this transform" (+ dependency graph for
transforms) action links to the graph-run sidebar header, mirroring the
individual transform-runs sidebar.
* GDGT-2625 wip: migrate graph runs to real /api/transform/runs
- Swap list + member queryFn mocks for real queries (list endpoint, dag/job
member drill-down; standalone runs synthesized). Delete the mock fixture.
- Rewrite page spec against real endpoints via fetch-mock + new mock factories.
- Reword DAG run confirm modal (drop "in this order", bullet list) since members
can run in parallel.
* Adjust wording
* GDGT-2625 wip: scheduled state + cleanup
- Show a "scheduled" run state on the transform page after a DAG reprocess is
triggered, so the queued transform gives feedback instead of looking idle
- Drop dead DAG-run types/tag-providers/mock helpers left from earlier iterations
* Address code-review findings for reprocess DAG UI
Correctness:
- Migrate transforms.cy.spec.ts to /runs/individual (the /runs route now
renders the graph-runs page, not the individual run table)
- Gate the "Run all" DAG confirm button on the mutation, preventing a
double-click from triggering two DAG runs
- Toast when run-dag returns a null dag_run_id instead of silently no-op
- Stop the "Scheduled" banner from polling forever when the member-runs
request errors
- Keep the graph-run sidebar open when the selected run scrolls off the
current page during polling
- Invalidate transform state on cancelDagRun/cancelJobRun so member
transforms' last_run refreshes after a cancel
Cleanup:
- Translate whole phrases (with context) for DAG run names
- Replace a typecast with a type guard in TypeFilterWidget
- Memoize parsed params and filter options on the graph-runs page
- Drop the unused direction field from the scheduled-DAG-run state
- Convert the sidebar's IIFE switch to ts-pattern match
* Handle deleted entities in graph runs; fix run-list cache invalidation
- Graph runs table: grey a deleted job/transform's run name (italic +
"has been deleted" tooltip), keeping the row clickable
- Sidebar: keep showing member transforms for deleted DAG and standalone
runs (fetched by dag-run id / synthesized from the run); hide the section
only for deleted job runs, whose members can't be fetched without the
(now-null) job id
- api/transform.ts: invalidate the run lists on deleteTransform (a
transform's runs survive its deletion) and cancelCurrentTransformRun, so
the Runs pages reflect the change instead of relying on polling
- Trim narrating comments from the transform API types
* Cleanup
* Fix transforms cancel e2e: click renamed "Cancel run" confirm button
The transform-page cancel confirmation button was renamed from "Yes" to
"Cancel run", but two cancelation tests still clicked "Yes". The cancel was
never confirmed, so the slow (pg_sleep) transform kept running and held a lock
on the writable DB, hanging the next test's resetWritableDb and failing the
suite. Point those two clicks at the new label (the individual-runs-page cancel
still uses "Yes" and is left unchanged).
* Avoid type cast in RunSection spec to satisfy no-unjustified-type-casts
Replace `body as string` with a typeof guard when reading the run-dag request
body, which the new lint rule flags.
* Show dependency count in the graph-run label
Use bronsa's new transform_count to label DAG runs as
"{n} dependencies › {name}" (upstream) / "{name} › {n} dependents"
(downstream), n = transform_count - 1, joined by a small chevron icon.
Bold the job/transform name; fall back to the plain name when the count is
absent (job/standalone runs, or DAG runs predating the column).
* Replace runs tabs with Detailed view switch; polish graph-run name rendering
- Swap the two runs tabs for a Detailed view switch that toggles grouped
(/runs) and detailed (/runs/individual) views in place; add RunsPage shell
and DetailedViewSwitch, remove RunTabs
- Render the sidebar header run name like the table (dimmed deps count +
chevron), with wider gap and aligned font size/weight
- Keep the Detailed view switch label on one line
- Bump graph-runs page size to 25 to match the individual runs page
- Fix stale react-router-redux import
* Preserve shared filters across Detailed-view toggle; drop deprecated CSS util
Toggling the Detailed-view switch previously called the run-list URL helpers
with no arguments, discarding the active filters. Forward the params common to
both views (statuses, transform ids, start/end time, run methods) via a new
CommonRunListParams/pickCommonRunListParams helper; page and sort are dropped
since the two views paginate differently and use incompatible sort columns.
Also replace the deprecated CS.overflowHidden global utility class in the new
graph-run table with an inline overflow style prop.
* Track Detailed-view toggle in transform runs analytics
Emit a transform_runs_view_toggled simple event from the DetailedViewSwitch
toggle handler with the resulting view mode (detailed/grouped), so both the
grouped and individual run-list call sites are covered.
* Fix reprocess run status: don't re-show Scheduled after a member completes
- Derive the DAG scheduled state from this transform's own member run:
a member run row only exists once the transform starts, so its presence
means the transform already ran this cycle. Prevents isScheduled from
re-arming (and masking the completed status) after this transform's run
finishes while sibling DAG members are still running
- Keep the graph-run sidebar polling while the top-level run is canceling,
mirroring isPollingNeeded
* Cleanup
* Address PR review nits from Uladzimir
- Extract isActiveRunStatus helper and use it for the duplicated
started/canceling checks (RunButton, both isPollingNeeded, sidebar
shouldPoll, RunSection dagFinished)
- DetailedViewSwitch: replace styles prop with a CSS module class
- Convert renderRunName/renderRunEntityName to RunName/RunEntityName
components
- Drop redundant String() around an already-string cell value
- Cover RunButton onRun click and the run-options menu show/hide
* Adapt runs routes to the react-router migration on the rebased base
The base migrated transforms routes to element=/withRouteProps with
Outlet-based layouts. Make RunsPage an Outlet layout and render the runs
list pages via element in the specs.
* wip
---------
Co-authored-by: Oleh K <oleh@metabase.com>
* Treat a v7 Link with an empty `to` as a button, not a navigation
* Make a v7 replace to the current URL a no-op so URL-sync effects settle
* Provide router.listen on the v7 engine and keep the router shim stable
* Serialize a v3 location query into the search string on v7
* Fix lint and type issues in the v7 router shim
* Keep the v7 matched-route branch stable across renders
* Republish the v7 splat param under v3's `splat` name
* Enable the react-router v7 engine by default
* Commit the v7 location update synchronously instead of in a transition
* Mirror the v7 location into redux from the history subscription
* Format the v7 router provider
* Sort query keys when serializing a v3 location query
* Match v3 query encoding and resolve bare link paths against the root
* Keep v3 query encoding and leave absolute link urls alone
* Always push when a v7 link targets the current url
* conversation list endpoint
* add rtkquery endpoints
* generate conversation title
* better title generation logic, poll for title if not recieved before end of first message
* render conversation title in ui and show conversation list
* allow resuming historical conversations
* show exploration conversation history
* refresh current conversation ui for 409 case
* surface conversation titles in usage analytics
* add /metabot/conversation/:id route
* remove pending title data part, assume from lack of title data part we need to poll, fix loading a conversation that is mid-response
* title backfill job
* rename title event
* FE prevent polling multiple times for conversation title
* improve title generation prompt
* ci fixes
* automated review feedback
* e2e test fixes
* reduce diff size
* self-review
* Address PR review feedback
- api: pass profile-id through valid-usage-profile-id when ensuring a
conversation title, matching the backfill job
- title backfill: gate readiness on check-usage-limits! (instance-level)
so the job pauses and reschedules cleanly when a usage limit is hit
- title backfill: bound the title future .get with a 60s timeout so a
stuck generation can't block the backfill loop indefinitely
* Add Metabot option to the Transforms New menu (GDGT-1773)
Adds a "Metabot" entry as the first option in the Transforms "New" menu.
Clicking it opens the data-studio Metabot sidebar with a pre-seeded
"Create a transform that " prompt (not submitted), gated on Metabot access.
Also fixes the tiptap prompt input so a programmatically seeded prompt
places the caret at the end instead of the start (autofocus "end" plus a
caret-to-end after external setContent), which also improves the retry path.
* Cleanup
* Cleanup unnecessary code
* Test CreateTransformMenu Metabot item without mocking
Render the real CreateTransformMenu + Metabot via the shared metabot test
setup so the menu item, permission gating, and prompt seeding are exercised
end to end instead of through stubbed hooks.
Bump babashka postgresql pod to 0.1.6 so mage runs on Apple Silicon
The 0.1.0 pod manifest has no macos-aarch64 entry -- only macos-amd64.
Babashka downloads that x86_64 artifact and caches it under the aarch64 path
anyway, so on an Apple Silicon machine without Rosetta every mage command dies
before doing any work:
Cannot run program ".../mac_os_x/aarch64/pod-babashka-postgresql":
Exec failed, error: 86 (Bad CPU type in executable)
Clearing the pod cache does not help -- the re-download is the same x86_64
binary. 0.1.5 is the first release with a real macos-aarch64 artifact; this
bumps to the current 0.1.6, which resolves to a native arm64 executable.
0.1.6 keeps every platform 0.1.0 shipped (macos-amd64, linux-amd64,
windows-amd64) and adds macos-aarch64 and linux-aarch64, so no one loses
coverage. `pg/execute!` is the only pod fn used anywhere in the repo, and its
signature is unchanged.
Verified on arm64: the cached binary is now `Mach-O 64-bit executable arm64`,
`mage.bot.setup-worktree` loads, and `./bin/mage fix-kondo-ratchets` runs to
completion.
Claude-Session: https://claude.ai/code/session_01X5dVcVX59aLonqqsYsqU65
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Restore navigation blocking on the v7 engine via a blocking history
* Render the v7 Link on the v7 engine so links navigate in modals
* Scope v7 leave hooks to their route so within-route navigation is allowed
* Don't let a v7 Link with no destination navigate on click
* Type the app Link's `to` as optional to match the runtime contract
* Apply activeClassName on the v7 engine via NavLink
* Hide settings tab in TagEditorSidebar if there is no tags
* Don't crash query metadata fetch on un-normalizable queries
batch-fetch-query-metadata filtered empty queries before normalization,
but normalize-query returns {} when a query can't be normalized (e.g. a
native query with an incomplete field-filter/table template tag). That {}
then failed batch-fetch-query-metadata*'s strict ::query schema, 400ing the
whole request and tripping the query editor error boundary. Filter empties
after normalization too so an unnormalizable query just yields no metadata.
* Hide template-tag 'Try it' examples in transforms editor
The template-tag help sidebar's 'Try it' buttons load an example query
against the Sample Database. Transforms can't target the Sample Database,
so clicking 'Try it' switched the transform to an invalid database and left
the editor in a broken state. Add a canUseSampleDatabase QueryEditor option
(default on) that transforms set to false, which withholds the sample
database id so TagEditorHelp hides the 'Try it' buttons.
* Add regression tests for transform variables sidebar fixes
- batch-fetch-query-metadata skips queries that normalize to {} instead of
400ing the whole request (the P1 crash)
- TagEditorHelp hides the 'Try it' examples when no sample database is available
- TagEditorSidebar hides the Settings/Help tabs until the query has a variable
* Address PR review feedback
- Simplify batch-fetch-query-metadata with (keep (comp not-empty normalize-query))
- Replace the hidden-tabs settings behavior with an empty state in the Settings
tab (no more layout shift / auto-switch to Help); use the shared EmptyState
- Make sampleDatabaseId a required, DatabaseId-typed setup option
- Add a NativeQuerySidebar test asserting 'Try it' is hidden for transforms
(canUseSampleDatabase=false) and shown otherwise
- Re-add the getEditorOptions canUseSampleDatabase assertion
* split use-metabase-query.unit.spec.tsx into multiple files
* split test fixtures from setup.tsx
* move useMetabaseQuery tests into tests folder
* rename useMetabaseQuery hook test
* remove unused query test fixture fields
* rename mock dataset query helper
* rename bundle exports
* chore: tiny formatting changes
* test: rewrite stale query results test
* test: extract public api tests to type and runtime tests
* Convert the Table visualization from a class to a function component
* Stabilize the Table question so async metadata updates don't rebuild columns mid-hover
* Type useSyncedQuestion via VisualizationProps so the SDK d.ts doesn't leak internal types
* Author route paths in v7 syntax, shim to v3 in the facade
* Add use-v7-router flag and frontend engine reader
* Add flag-gated react-router v7 engine (provider, tree mapper, bridge)
* Re-point redux navigation and location to the v7 router
* Support both router engines in renderWithProviders
* Switch the app mount to the v7 router behind the flag
* Point setRouteLeaveHook no-op comment at DEV-2374
* Revoke schema-level grants when destroying Redshift workspace isolation
destroy-workspace-isolation! discovered schemas to revoke via
svv_relation_privileges, which only surfaces relation-level grants: a granted
input schema with no tables (or whose tables were dropped after provisioning)
kept its schema-level USAGE grant, and DROP USER then failed with 'user ...
cannot be dropped because the user has a privilege on some object'. Union in
svv_schema_privileges so schema-only grants are revoked too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rename workspace+det to workspace in the new destroy test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Run Redshift workspace teardown statements individually, not as a JDBC batch
A failure now surfaces as the plain server error instead of the
BatchUpdateException wrapper ('Batch entry N ... Call getNextException to see
other errors in the batch.'); the transaction still aborts as a whole on the
first error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Revert "Run Redshift workspace teardown statements individually, not as a JDBC batch"
This reverts commit 4bf2d241b5.
* Unwrap BatchUpdateException in Redshift workspace teardown
Keep the batch (there can be many granted schemas, two revokes each) but
rethrow .getNextException so failures read as the plain server error instead
of 'Batch entry N ... Call getNextException to see other errors in the batch.'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* remove comment
* Unwrap BatchUpdateException across all workspace-isolation batch sites
New driver.u/batch-exception util returns the per-statement exception hiding
under .getNextException (or the throwable unchanged). Applied to every
workspace init/grant/destroy executeBatch site (h2, mysql, postgres,
clickhouse, redshift) — init sites unwrap before scrub-exceptions — so
failures read as the plain server error instead of 'Batch entry N ... Call
getNextException to see other errors in the batch.'
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep the batch exception on init-workspace-isolation! failures
The init statements carry secrets and pgjdbc echoes the aborted statement in
the BatchUpdateException message — scrub-exceptions redacts it, and the
GHY-3660 test (init-workspace-isolation-never-exposes-password-test) relies on
that redacted CREATE USER echo as proof the run reached the statement.
Unwrapping to the inner server error discarded the echo. grant/destroy keep
the unwrap: their statements carry no secrets and the plain server error is
the better message there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Unwrap the batch envelope on init-workspace-isolation! too
All workspace batch sites now rethrow the underlying per-statement exception;
init scrubs the unwrapped exception so passwords stay out of the message. The
GHY-3660 test now proves the flow reached CREATE USER by asserting the
server's permission error instead of the redacted batch echo (which no longer
exists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Test batch-envelope removal for grant! and password scrubbing after unwrap
- driver-level: a failed grant-workspace-read-access! must not surface a
BatchUpdateException (h2 runs locally; pg/mysql/clickhouse in driver CI;
non-batching impls pass trivially)
- unit: scrub-exceptions composed with batch-exception redacts the password
in the unwrapped per-statement message
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Batch init/grant/destroy on every SQL workspace driver
Redshift grant, and all three Snowflake / SQL Server workspace methods, now
run their statements as JDBC batches like the other drivers — one round trip,
per-statement exceptions unwrapped via driver.u/batch-exception (init unwraps
before scrub-exceptions). Redshift grants commit before grant! returns, so a
subsequent describe-database from another connection still sees the
privileges; the destroy pre-loop for foreign-grantor default-priv rows stays
per-statement on purpose (each must survive the others failing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fold Redshift foreign-grantor revokes into the destroy batch
The per-statement autocommit pre-loop is gone: one transactional batch now
covers the FOR USER default-priv revokes, relation/schema revokes, and the
drops. A stale discovery row fails the whole destroy, which is fine — the
workspace layer records the failure and the deprovision retry re-discovers
fresh state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Escape workspace passwords in DDL like every other interpolated value
CREATE/ALTER USER statements interpolated the generated password raw into the
SQL string on postgres, redshift, h2, clickhouse, and snowflake (mysql and
sqlserver already escaped). Escape with sql.u/escape-sql :ansi (quote
doubling, valid on all five engines) and scrub both the raw and escaped forms
from failure messages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Unwrap batch exceptions from all JDBC connectors; fix sqlserver batch var scope
The grant-envelope test caught two real issues in driver CI:
- MariaDB's connector chains the per-statement exception via .getCause and
mssql-jdbc chains nothing at all, so batch-exception now falls back to the
cause and, failing that, rebuilds a plain SQLException with the same
message/SQLState/error code.
- mssql-jdbc executes a Statement batch in one T-SQL scope, so the two
sqlserver destroy statements both DECLAREing @sql collided; the variable
names are now unique.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Type-hint the rebuilt SQLException ctor to avoid reflection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Return the batch exception as is when nothing is chained
mssql-jdbc's BatchUpdateException message is already the plain server error —
only pgjdbc adds the 'Batch entry N ... getNextException' wording. So the
unwrap falls back to the original exception instead of synthesizing a
SQLException, and the driver test asserts the envelope wording stays out of
the message rather than checking the exception class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Interpolate the escaped password in Snowflake workspace user DDL
CREATE/ALTER USER spliced the raw password while every other workspace
driver uses the ANSI-escaped value; the escaped form was already
computed and scrubbed here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A STRUCT column's INFORMATION_SCHEMA data_type spells out its entire
nested schema, and the COLUMN_FIELD_PATHS LEFT JOIN repeats it on every
nested-leaf row -- O(n^2) bytes for a column with n leaves. Dynamic-key
STRUCTs (e.g. Cloud Functions log-sink tables whose JSON payloads use
user IDs as keys) reach thousands of leaves with ~270KB type strings;
one such table produced ~4,000 rows x 271KB = 1.09GB buffered in
sync-fields!'s partition-by, OOMing the instance mid-sync.
Only short type-string prefixes are ever consumed
(raw-type->database+base-type dispatches on prefixes up to 10 chars and
stores at most RANGE<TIMESTAMP> verbatim), so SUBSTR(..., 1, 200) in the
query -- applied server-side, before the wire -- is behaviorally
identical while collapsing response size, parse cost, and the buffered
partition (1.09GB -> ~2.8MB for the table above).
The test needs no dataset or network: it captures the compiled SQL via
*process-native* and asserts both data_type columns are truncated.
* remove the last test file from the legacy structure
* convert test table into format of newer tests
* remove test for undefined since this isn't a valid type for RowValue
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>
* migrate TokenField
* fix RemappedOption type for Autocomplete ComboboxStringItem
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* address RecipientPicker review: memoize maps, share lookup/label helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix single-value search: accept top match on Enter
Autocomplete only completes on Enter when an option is highlighted, but
search options load async so none is. Restore old TokenField behavior via
onKeyDown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix letters swallowing
* addressing the PR's comments
* added missing tests
* introduce Autocomplete + refactoring
* apply filter by clicking on enter
* fix test
* refactor autocomplete
* refactor autocomplete
* fix edge case + added unit tests
* fix type
* fix test
* fix test
* get rid of lint suppression + allow autoselect on open for multiautocomplete
* revert autoselect destructuring, restore eslint suppression (tests failing)
* explain exhaustive-deps suppression in MultiAutocomplete effect
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Remove withRouter from production components
* Delete the public withRouter export
* Type the route-leave hook for PlainRoute and drop the route casts
* Add element/Outlet + useRoute support to the router facade
* Convert root route tree to element/Outlet
* Convert admin route tree to element/Outlet
* Convert data-studio, models and metrics route trees to element/Outlet
* Convert transforms, account, monitor and public route trees to element/Outlet
* Convert enterprise route trees to element/Outlet and drop dead library segment routes
* Convert tenants admin route tree to element/Outlet
* Convert redirect, createEntityIdRedirect and modalRoute to element
* Ban component= on routes with eslint
* Drop component= from the facade Route type and convert remaining routes
* Drop the component= eslint rule, the facade type now enforces it
* Remove the unused withOutlet helper
* Assert the required tenantId route param instead of coercing to NaN
* Bridge element={null} to a nothing-rendering component, matching v7
* Memoize router context value so element routes re-render once per navigation
* Convert data_apps and impersonation routes merged from master to element
* initial proof-of-concept for data apps
POC 2
POC 3
POC 4
Revert "POC 4"
This reverts commit 1f62e574dc.
POC 5
POC 6
* Expose all SDK components to child realm
* Setup app publishing and serving
* Refactor distortion helpers
* Adjust skill
* Add useQuestionQuery SDK hook (#74900)
Co-authored-by: Timofei <timofei@metabase.com>
* Mock METABASE_EMBEDDING_SDK_BUNDLE for data app sdk hooks/components
* Adjust skill for TS project
* Update skill to clearly mention TS
* Add blocked APIs list to skill
* Prototype schema exports and typed hooks for custom data apps (#74978)
* add prototype for schema generation
* add formatting for generated schemas
* add skill file
* useQuestionQuery should take only ID based on @sanex3339 feedback
* add segments, measures, tables and useMetabaseQuery hook
* Add useMetabaseQuery to endowments
* Fix queryDataset
---------
Co-authored-by: Timofei <timofei@metabase.com>
* Add ability to update app bundle
* Simplify hook
* Add spinner instruction
* Mode Data Apps to OSS
* Instruct to not not use blocked event handlers
* Improve API, add styles isolation, add router components (#75418)
* Prepare skills for data app template + adjust router components (#75490)
* Fixes after rebase
* Move migration to v63
* Move migration to v63 #2
* Fix config.edn
* Fix BE tests
* Fix team name
* Fix duplicated config
* Adjust version text in skill
* Fix data app bundle uploading after legacy client removal
* Remove `actions` from config edn
* Fix failing test
* Fix failing test #2
* Add custom actions for data apps (#75627)
* Fix imports
* Fix type annotations
* Adjust migration
* Adjust permissions
* Fixed iframe security
* Data Apps publishing via remote repository POC (#75702)
* Update gitignore with data-app.html
* [PoC] Type safety and modularity improvements for custom data apps (#75381)
* allow separate libraries in schemas
* add metric type checking
* add collections parameter
* check that segments and measures belongs to table in useMetabaseQuery
* require less type information for measures
* require less type information for segments
* use metric dataset endpoint when querying measures in metrics
* add validation for breakout buckets
* fix filter operation type scoping
* expose filter type helper for nicer autocompletions
* update skill file
* add breakout schema helper for better type errors
* update breakout schema helper to accept binning and bucket
* move fields that are not used in compile and runtime to comments to shorten type errors
* enforce data truthfulness in skill
* enforce semantic layer richness in skill
* handle dimension key collisions mapped from many tables
* update skill description
* prevent hallucinated measures and breakouts
* fix field serialization
* add guidance on using filter matcher
* add skill guidance on long results and units
* accept query object in StaticQuestion and InteractiveQuestion
* extract use-metabase-query to multiple files
* update skill file to use sdk question by default
* update skill file to avoid hallucinated derived calculations
* add new functions into sandbox
* kondo and cljfmt lint fixes
* update CSP rules to allow style tags for data apps
* address review feedback on scoping and metric filters
* address code review feedback
* Add aggregations support for breakouts in query hooks (#75723)
* add aggregations support
* add additional aggregation helpers
* Add metrics support in useMetabaseQueryObject hook (#75728)
* add metric queries
* indicate metric support in skill file for useMetabaseQueryObject
* add new version for release
* fix query dataset tests
* update release workflow temporarily
* update skill description
* update skill to use more generic examples
* hotfix: allow esm usage in vite dev mode
* skill file update for table caveat
* update sandbox
* fix type errors in useMetabaseQuery
* add v0.63.2 to release workflow
* update skill file to use the 63-data-apps version
* Move data apps to the EE directory and under `data-apps` token feature (#75871)
* remove skill file alias
* Add error handling for data apps
* Add spinner while iframe is loading
* Add OSS/no token upsell banner placeholder for data apps (#75893)
* ensure a fixed height is set if container has height
* Allow including everything in data and metric library in schema endpoint (#75944)
* add filter for including library and data collections
* address review on skill file
* remove id field from rendered comment
* remove useQuestionQuery and useMetricQuery hook
* Improve env files lookup in skills (#75955)
* Allow `unsafe-eval` for data app iframe (#76004)
* Fix lint
* Fixes after rebase
* Fix duplicated test
* Improve Providers and typesafety (#75934)
* chore: reformat single expression functions to arrow
* Use entity id in questionCollections when generating typed schemas (#76230)
* allow using entity ids for question collection filter
* guide the agent towards using entity ids
* add failing test for invalid entity id
* Make custom viz work in data apps (#76237)
* Allow blob: images in data-app CSP (EMB-1923)
SDK custom visualizations load their icon assets via blob: URLs (to avoid
the cross-origin auth issue), but the /embed/data-app/ route allowed
'unsafe-eval' without the blob: scheme in img-src, so icons were blocked
under a production CSP. Add blob: to img-src scoped to the data-app iframe
route only; the main app img-src stays strict.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* EMB-1880: support for `providerProps` in data apps
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Reduce schema bloat in dimensions of generated metrics when many tables are involved (#76003)
* Add useQuestionQuery SDK hook (#74900)
Co-authored-by: Timofei <timofei@metabase.com>
* Prototype schema exports and typed hooks for custom data apps (#74978)
* add prototype for schema generation
* add formatting for generated schemas
* add skill file
* useQuestionQuery should take only ID based on @sanex3339 feedback
* add segments, measures, tables and useMetabaseQuery hook
* Add useMetabaseQuery to endowments
* Fix queryDataset
---------
Co-authored-by: Timofei <timofei@metabase.com>
* [PoC] Type safety and modularity improvements for custom data apps (#75381)
* allow separate libraries in schemas
* add metric type checking
* add collections parameter
* check that segments and measures belongs to table in useMetabaseQuery
* require less type information for measures
* require less type information for segments
* use metric dataset endpoint when querying measures in metrics
* add validation for breakout buckets
* fix filter operation type scoping
* expose filter type helper for nicer autocompletions
* update skill file
* add breakout schema helper for better type errors
* update breakout schema helper to accept binning and bucket
* move fields that are not used in compile and runtime to comments to shorten type errors
* enforce data truthfulness in skill
* enforce semantic layer richness in skill
* handle dimension key collisions mapped from many tables
* update skill description
* prevent hallucinated measures and breakouts
* fix field serialization
* add guidance on using filter matcher
* add skill guidance on long results and units
* accept query object in StaticQuestion and InteractiveQuestion
* extract use-metabase-query to multiple files
* update skill file to use sdk question by default
* update skill file to avoid hallucinated derived calculations
* add new functions into sandbox
* kondo and cljfmt lint fixes
* update CSP rules to allow style tags for data apps
* address review feedback on scoping and metric filters
* address code review feedback
* Add aggregations support for breakouts in query hooks (#75723)
* add aggregations support
* add additional aggregation helpers
* Add metrics support in useMetabaseQueryObject hook (#75728)
* add metric queries
* indicate metric support in skill file for useMetabaseQueryObject
* add new version for release
* fix query dataset tests
* remove useQuestionQuery and useMetricQuery hook
* Data Apps publishing via remote repository POC (#75702)
* avoid duplicating dimension fields
namespace dimensions by table
add dimension id to mapping
pick from table fields for only allowed dimensions
fix incorrect type guards
allow querying metric using question as source
remove createMetabaseQuery from public interface
add sourceFieldId to metric
* doc generation type fixes
---------
Co-authored-by: Timofei <timofei@metabase.com>
* Add allowed hosts handling for data apps (#76094)
* Update version
* Properly include data app changes in `pull changes` modal (#76294)
* Move app template into main repo, handle skill versioning flow (#76288)
* feat(data-apps): allow passing down a card with viz settings (#75993)
support card prop in sdk question component
* Rename data app env variables and read them only in dev mode (#76604)
* Add custom data apps layout with sliding toolbar (#76600)
* Remove unused helper
* Dev env Sandboxing, Human Readable Errors and Copy Helper (#76597)
* Revert wrong rebase changes
* Improve creating filtering interfaces in custom data apps (#76401)
* update skill files to guide filter usages
* update skill file to prevent filtering mistakes
* move filtering ui patterns into a separate file
* do not treat mb_replace_me as valid creds
* use combobox for filters
* pass title false to sdk question
* combobox trigger before typing
* Add missing exported types
* Apply visualizations and visualization settings from SDK package (#76659)
* ask the agent to pull possible visualizations
* ask the agent to use card prop
* teach the agent when to use viz settings
* Support query-only SDK cards
* Remove cross references between data apps skills (#76661)
Remove data app skill cross references
* Move Data App Dev Vite App setup into SDK package (#76695)
* Don't navigate on ad-hoc question chart clicks (#76751)
* Fix sandboxing crashes and update skill file mistakes (#76858)
Fix data app sandboxing and skill guidance
* Block form submissions in data apps via CSP (#76882)
* Fix SDK theming hover text colors in generated data apps (#77083)
update guidance on colors
* Remove SDK question query prop for data apps use cases (#77080)
* Remove SDK question query prop
* update tests to use card prop instead
* Adopt Lib.createTestQuery for table sources when querying in data apps (#75957)
* Add base metabase-lib query implementation
* switch to Lib.createTestQuery DSL
* Load query metadata at runtime for metabase-lib instead of hard-coding (#76874)
* load query metadata dynamically
* accept id-only source references
* Wait for SDK login before resolving query metadata
* refactor: use useAsyncFn to handle states
* pass source-name to createTestQuery
* Add metric aggregations for useMetabaseQuery via Lib.createTestQuery (#76947)
* add metric sources for useMetabaseQuery via Lib.createTestQuery
* use MetabaseQueryObject instead of opaque type marker
* Fix metric schema and join source validation
* add metric library filtering to skill files
* make metric an aggregation expression instead of join sources
* mark tableId as never in joined fields
* export public MetabaseQueryObject type
* use camelCase for source name and field id
* Add initial data app e2e setup (#77101)
* Add sorting and fields to useMetabaseQuery via Lib.createTestQuery (#76966)
* add sorting, limiting and fields to useMetabaseQuery
* refactor: cleanup sorting and aggregations
* Fix metric order by validation
* Support saved questions for useMetabaseQuery via Lib.createTestQuery (#77073)
* add saved questions as useMetabaseQuery input
* remove extra operators for saved cards
* Add unit fe/be tests for data apps (#76979)
* Fix lint
* Use modern api in query-dataset.ts
* Fix BG color
* Fix execute-action tests
* Update SDK-data-apps version
* Move migrations to v64 folder
* Adjust router components to use metabase/router
* Allow opening a data app for any signed user
* Rename data app skills (#77266)
* Update DataAppLayout.tsx `go back` button logic
* Harden data apps skills and action validation errors (#77261)
* harden data apps skills and actions error validation
* do not display models as questions in data apps
* clarify action result types
* Agents should report errors when models, actions or questions cannot be schema generated (#77260)
* return an error if models or saved questions are invalid
* return an error if actions are invalid
* update skill file guidance on model generation
* fix model boundary
* Add data apps admin UI + routing/sandboxing/isolation e2e (#77271)
* Rename data-apps paths to /apps (#77298)
* Include all action-included models in the typed schema when a mutation is specified (#77291)
* add includeModels=true flag to include all models
* omit model columns and models without actions
* remove extra metadata
* Adjust admin UI for Data Apps (#77420)
* Add more e2e/unit tests for Data Apps (#77524)
* Update bun.lock
* Fix errors after rebase
* Parallelize dts rollup (#77663)
* revert EMB-1840 measures support in metric dataset endpoint
* chore: add data app module (#77705)
* test ci
* apply code review suggestion
* Setup separate entries for client and node dev data app entries (#77687)
* Fix type casts lint errors (#77696)
* Adjust data apps setup + add proper analytics setup (#77604)
* Extract common and scope helpers + tests for typed schema module (#77403)
* refactor: extract common and query param helpers
* refactor: use kebab-case for query params
* refactor: cleanup schema mapping
ci: fix module config
* refactor: rename schema scope helpers to scope.clj
* refactor: extract scope_test.clj
* Extract typed schema rendering helpers to render.clj (#77703)
* extract schema render function
* move runtime compact test out
* remove javascript and json endpoints from schema generator
* refactor: cleanup javascript.clj and render.clj
* Extract typed schema generators for tables and questions (#77729)
* refactor: extract schema generator modules
* remove question-details from model-schemas
* fix: batch queries in table and question schema generator
* remove N+1 in typed schema field table lookup
* Extract typed schema generators for models and actions (#77739)
* extract model schema generator
* batch model action querying to avoid n+1 queries
* Extract typed schema generator for metrics (#77806)
* Extract typed schema generators for models and actions (#77739)
* extract model schema generator
* batch model action querying to avoid n+1 queries
* extract metric schema generator
* test: simplify fields in metric_test fixtures
* refactor: clean up metric schema generation
* remove n+1 from metric field dimension read
* disable default temporal dimension breakout
* surface errors if metric schema cannot be built
* revert metrics/api.clj to master to remove outdated comments
* Fix routes
* Take a data app's slug from its directory name (#77688)
* Merge data-apps specific migrations into one
* ci: fix failing backend test and cljfmt lint
* Add a beta badge in data apps admin page heading (#77877)
marks the data app as beta
* Revert "Merge data-apps specific migrations into one"
This reverts commit 383a5fa28d.
* Revert "Revert "Merge data-apps specific migrations into one""
This reverts commit 948d904b62.
* ci: actionlint fix
* Fix data apps route
* Use getSubpathSafeUrl in attachIframeUrlMirror
* Fix comment
* Do not send apps with sync error for token check
* Add data app listing sidebar section for non-admins (#77977)
* show data apps in sidebar for non-admins
* add app icon to data apps
* use available=true to filter no sync error and enabled apps
* fix navbar keys in data apps
* test: use non-admin user to assert non-admin response
* fix toucan query in data apps listing endpoint
* re-export entity detail helpers and result column to metabot core
* open data apps in new tab from the sidebar
* Prevent race conditions in responses of useMetabaseQuery hook (#78023)
prevent stale responses in useMetabaseQuery
* ci: remove tools.util from module declaration
* Merge migrations into a single one
* reset release-embedding-sdk.yml to master
---------
Co-authored-by: Phoomparin Mano <poom@metabase.com>
Co-authored-by: Phoomparin Mano <phoomparin@gmail.com>
Co-authored-by: Nicolò Pretto <info@npretto.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some cleanup of some false alarms I encountered in test environments while working on a different issue. Destination databases (those with `router_database_id` set) are reached only through their router: metadata, sync, permissions, etc. Only execution goes against the destination's connection. A `data_permissions` row pointing at a destination is never consulted -- it's already actively stripped.
There are test utilities that helpfully create in-test permissions for all databases created, so in a test environment it would look exactly like the invariant was being violated, but that was only because the test utilities deviated from production behavior. This brings the test behaviors into line with production (no permissions get written).
There should be no change in production behavior in this PR, but as a bonus, I figured that a safety guard filtering rows out on reads is not as good as filtering them out on reads *and* failing loudly on writes. I also added guards that a normal database can't be turned into a destination database, which was a behavior tests were relying on for easier setup (but again, the tests then deviated once more from production behavior).
Since 4.28 Liquibase ships an opt-out analytics feature that, on the first
migration run, fetches config from config-endpoint.liquibase.com and sends
anonymized usage events.
* Parse :absolute-datetime string literals in repr resolve
Metabot's representations repair pass wraps bare ISO `between` bounds as
`[:absolute-datetime {} "2024-01-01" :day]` to satisfy the lib `:between`
temporal schema, leaving the literal as a string. That form is schema-valid
(so it passes construct_notebook_query validation), but the JVM query
processor expects a real java.time value — optimize-temporal-filters throws
on a bare string, and the QP's own wrap-value-literals only parses temporal
strings that arrive as raw values, not ones already wrapped in
:absolute-datetime.
Parse those string literals into java.time values in resolve-query, where the
portable repr form becomes QP-ready pMBQL, alongside the existing
annotate-field-types pass.
* Address review: tighten docstrings, use update over assoc
- parseable-temporal-string?: contract-first two-line docstring
- parse-absolute-datetime-literals: move the "why" into inline comments at
the walk, keep the docstring to contract + idempotency
- use (update node 2 u.date/parse) instead of assoc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address review: year/year-month, fail-fast, export round-trip
- parse year/year-month :absolute-datetime literals too (u.date/parse maps
them to LocalDate), so they are QP-ready instead of crashing later
- fail fast on a structurally-ISO but invalid literal (e.g. "2024-13-45"):
re-throw as an :agent-error? ex-info so the LLM gets a clean message
- export: format java.time values back to ISO-8601 strings in
portable-repr-form so resolve -> export round-trips to the documented
string form
- tests: per-shape parsing (date/datetime/offset/year/year-month),
idempotency, invalid-literal, and export round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move :absolute-datetime string parsing to the QP layer
Revert the resolve-layer parsing and instead teach wrap-value-literals to
parse string-valued :absolute-datetime clauses, not just raw values. The
repair pass wraps bare `between` bounds as `[:absolute-datetime {} "2024-01-01"
:day]`; wrap-value-literals previously only parsed temporal strings that
arrived as raw values, so a pre-wrapped string slipped through and
optimize-temporal-filters threw on the bare string.
The QP is the right layer: wrap-value-literals runs at execution time, where
both the report timezone (bound) and the comparison field's type are
available — so the parse is type- and timezone-aware for free, and the stored
query keeps its portable ISO-string form (no export round-trip to invert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fail fast on malformed :absolute-datetime literals in resolve
Add a resolve-side validation pass that rejects temporal literals which pass
the schema's structural regex but are not real dates (e.g. "2024-13-45"), so
the agent gets an actionable error at construct time instead of a failure at
query execution.
Validation only — it does not coerce. The string -> java.time coercion stays
in the QP's wrap-value-literals, where the field type and report timezone are
available. Accepts every valid :absolute-datetime shape: date, datetime,
offset-datetime, year, and year-month.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Preserve unit and wrap mixed :between bounds for string :absolute-datetime
When re-parsing a string-valued :absolute-datetime in wrap-value-literals:
- Carry the clause's own temporal unit through instead of discarding it. The
comparison field's bucket still wins, but for an unbucketed field the literal's
unit is now the fallback (rather than :default), so a year/year-month literal
like [:absolute-datetime {} "2024" :year] keeps its bucket instead of silently
collapsing to a single instant.
- Wrap each :between bound independently so a mixed bound (string :absolute-datetime
on one side, :now / :relative-datetime / an already-parsed :absolute-datetime on
the other) still coerces the wrappable side instead of falling through unwrapped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Guard comparison literals and tidy :absolute-datetime validation
- wrap-value-literals: guard the field position in the :=/:!=/<…> arms so a
comparison with a wrappable literal on both sides can't bind a literal as the
field and leave it unparsed; it now falls through unchanged.
- resolve: validate :absolute-datetime literals via lib.walk/walk-clauses
(no tree rebuild, shares clause-of-type? detection with the QP) instead of
clojure.walk/postwalk.
- Move cross-subsystem rationale out of the string-valued-absolute-datetime
docstring into an inline comment.
- Add a test covering the two-literal comparison fall-through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Parse fieldless :absolute-datetime comparisons and bound bad-literal errors
Wrap a string-valued :absolute-datetime on both sides of a comparison
(no field to wrap against) so each literal is parsed with its own unit
instead of leaving a bare string for later middleware to choke on.
Truncate the untrusted literal before it lands in the agent-facing
exception message and ex-data, so a pathological value can't flood logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Treat :default field unit as unbucketed for :absolute-datetime literal fallback
A temporal field can carry :temporal-unit :default (add-default-temporal-unit
stamps it on :temporal/requires-default-unit drivers, and an LLM can author it
directly). The unit-fallback guard only skipped a nil unit, so a :default field
dropped the literal's own unit and collapsed e.g. [:absolute-datetime {} "2024"
:year] to a single :default-bucketed instant. Treat :default as unbucketed too.
* Validate :absolute-datetime literals with plain walk to keep resolve gate friendly
validate-absolute-datetime-literals walked the query with lib.walk/walk-clauses,
a mu/defn whose input schema validates the whole query against ::lib.schema/query.
Under test instrumentation that threw a raw "Invalid input" error on an otherwise-
malformed query (e.g. an :offset in :expressions), pre-empting the friendlier
query-not-runnable agent error the downstream canRun gate is meant to surface.
Use clojure.walk/postwalk and a bare [:absolute-datetime _ s _] shape check, matching
the sibling annotate-field-types pass, so only the literals are inspected.
* Use match/match-many for :absolute-datetime literal validation, add mixed-between raw-value test
Addresses PR review feedback: swap the manual walk/postwalk in
validate-absolute-datetime-literals for match/match-many, and cover a
:between with a raw (unwrapped) string bound plus a :relative-datetime
bound in wrap_value_literals_test.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Show honest "still working" state when a Git sync push stalls (GHY-4132)
A large push can stop reporting progress while the export is still running; the
backend then reports the task as :timed-out with ended_at nil. The push modal
treated that as neither success nor error and stayed frozen on "Exporting
content…" until the page was refreshed.
Render a distinct stalled state instead: drop the frozen progress bar and show
"Still working. No update for N minutes." (internationalized, singular/plural
aware). The counter derives from last_progress_report_at and stays fresh via the
existing 2s current-task poll — no separate ticker.
* Add throttled monotonic progress reporter for remote-sync tasks (GHY-4132)
* Ramp remote-sync export progress across plan + serialize phases (GHY-4132)
* Report remote-sync export commit checkpoint before push (GHY-4132)
* Report remote-sync push progress via JGit ProgressMonitor (GHY-4132)
* Fix throttle, dead 0.66 write, and push liveness in export progress (GHY-4132)
commit-staged! was passed a pre-forced report closure, so every push
ProgressMonitor tick bypassed the 10s throttle and hammered the DB. Force only
the one-shot commit checkpoint (0.8) explicitly in finish-commit!, and let
push ticks go through the throttled reporter.
The forced 0.66 serialize-boundary write ran after commit-staged! returned,
by which point the 0.8 checkpoint (and push ticks) had already advanced the
monotonic reporter past it, so it was silently dropped. Move it to the end of
each export's stage-fn, before the checkpoint/push run.
The push monitor only reported during the exactly-titled "Writing objects"
phase with a known positive total; a non-English JVM locale renames that
title and JGit may report an unknown (zero) total, so the push phase could go
silent for its whole duration and re-trip the stale-progress timeout. Report
on every update tick regardless of phase/total, falling back to the 0.8
push-start fraction — repeated equal values are monotonic no-ops but still
bump last_progress_report_at, throttled to at most once per 10s.
* Use dayjs diff for the stalled-progress minute counter (GHY-4132)
* Expose search's ranked set and page-hydration as separate steps
`search` already ran in two stages: rank the permission-filtered result set
(capped at max-filtered-results), then slice that set to the requested page
and hydrate only those rows. The comment on `search-results` spells out why
the slice comes first — hydration does per-row work, including the expensive
`can_write` check, so it should only touch rows that are actually returned.
Both stages were sealed inside `search`, so a caller holding several ranked
sets had no way to combine them and paginate the combination. Its only option
is to ask `search` for N hydrated pages and merge those, which hydrates N
pages to return one, and — because each page is sliced from its own ranking
before merging — cannot page the merged order correctly at all.
Split the two stages into `ranked-results` and `search-results` and export
both from the module API. `search` is now their composition and behaves
identically; no call site changes.
The motivating caller is metabot's multi-query search, which runs a search per
query and fuses the rankings with RRF. Fusing at the ranked-results seam lets
it hydrate only the page it returns, page the fused order coherently, and
report a total for the fused set.
* Remove comments from search core.clj
Removed comments explaining the purpose of `ranked-results` and `search-results`.
* kick ci
* Convert sandboxes and advanced_permissions plugin modules to TypeScript
* Address review comments: remove typecasting from sandboxes actions and unit spec
* Fix layout shift when switching tabs on the transform job page
The job Overview and Run history pages each rendered their own header:
Overview used an editable name input plus a `...` menu, while Run history
used a static title with no menu. The differing heights shifted the tab
row, so the clicked tab jumped when switching between the two.
Extract a shared JobHeader component (and a useJobHeaderState hook for the
readOnly/name-change logic) rendered identically by both pages, mirroring
the MetricHeader pattern. Run history now also shows the `...` menu and the
editable name, and Metabot is consistent across both tabs.
* Unify JobPage header logic with useJobHeaderState
JobPage duplicated the readOnly computation and name-change handler that
now live in useJobHeaderState. Source them from the hook instead; keep the
local useTransformPermissions call only for the page-level loading gate.
* Use strict undefined comparisons in job header code
Address review: job/jobId are never null, so compare with undefined
strictly and derive jobId from job.id in the JobHeader render.
Fixes GIT-10551: connecting an MCP client to a Metabase instance served
under a subpath (site-url like https://example.com/metabase behind a
prefix-stripping reverse proxy) failed at the consent step.
The OAuth flow already derives every advertised URL from site-url —
discovery metadata, authorization/token/registration endpoints, and the
WWW-Authenticate resource_metadata header are all subpath-correct. But
three browser-facing pieces assumed Metabase lives at the domain root:
1. The consent form posted to root-relative /oauth/authorize/decision,
so the browser sent the POST outside the subpath and the proxy
returned 404 (the reported bug). The form action is now the absolute
site-url-based URL, via the same absolute-url helper the page already
uses for the logo. The bundled @font-face URLs on the page get the
same treatment.
2. The CSRF cookie was scoped to Path=/oauth/authorize. Under a subpath
the consent page lives at /metabase/oauth/authorize, so the browser
would never send the cookie back and the decision POST would fail
CSRF validation even with the form action fixed. The cookie path now
carries the path component of site-url.
3. The logged-out flow: the backend bounces to the login page with a
basename-relative redirect target (the server sits behind the
prefix-stripping proxy and never sees the subpath). The frontend
guard resolved that target against the bare origin, losing the
subpath on the full-page redirect back to /oauth/authorize. The
guard now normalizes targets through the router basename in both
directions: paths without an explicit origin get the basename joined
(browser-real href), and pathnames get it stripped (router path, so
the backend-only-path prefix check still matches).
Why this is safe:
- Root deployments are bit-for-bit unchanged. site-url has no path
component there, so the cookie path is still /oauth/authorize, the
form action still targets /oauth/authorize/decision, and with an
empty basename the guard resolves every input shape (relative path,
bare path, absolute URL) to exactly the URLs it produced before.
- Under a subpath, the only outputs that change are ones that were
previously unroutable (404 through the proxy or a path the SPA
router would double-prefix).
- The server never sees the subpath, so no routes, parameters, or
validation logic change; every fix is to a browser-visible artifact
derived from site-url.
Tests pin both halves: existing root-deployment assertions are
untouched and still pass, and new tests cover the subpath form action,
cookie path, login redirect target, and the full consent -> decision
round-trip with a subpath site-url. Also verified live against a local
prefix-stripping Caddy proxy with a real MCP client flow (DCR, consent,
PKCE token exchange, tool calls).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Rewrite image-buffer pool on dirigiste (same pool as the GraalVM contexts)
Replace the hand-rolled ConcurrentLinkedDeque pool, stats counters, sweeper
daemon, and logging with the dirigiste Pool already used for static-viz
contexts, with the same semantics: 0-3 pooled int[] arrays of the fixed
oversized size, held exclusively per render, idle arrays dropped after ~10
minutes. acquire blocks until an array is free when the requested size fits;
larger requests get a fresh unpooled BufferedImage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Extract the dirigiste pool factory into metabase.util.pool
create-pool was duplicated verbatim between the static-viz graal renderer
(channel.render.js.common) and the sqlglot graal runner (sql-parsing.common),
and image-buffer had just picked up a third use. Move it to metabase.util.pool
and use it from all three; the pool behavior tests move from
sql-parsing.common-test to util.pool-test with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add prometheus counters for image-buffer pool usage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rename image-buffer metrics to metabase-notification namespace
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Migrate nav search and search result components from Emotion to Mantine
* Address review feedback: close button label, valid footer markup, footer role and hover transition
* Restore search dropdown footer ref by returning a ref callback from useListKeyboardNavigation
* Improve page/container margins when printing document
* do not call flushSync while printing
* Revert "do not call flushSync while printing"
This reverts commit 37c6fe2b50.
* Resize ECharts synchronously for print layout changes
* Refactor ECharts print resize tests
* Clean up
* remove compile-with-inline for clickhouse, no longer needed since the driver got fixed
* re-enable full tests
* fix: for max behavior on clickhouse
* fix: insert for clickhouse
* add temporal reg test
* Generate expression parser as TypeScript
Add --typeScript to lezer-generator so the parser emits typed output; convert lezer.js to lezer.ts.
* Migrate NotebookStepPreview to TypeScript
* Reuse QuestionResultLoaderChildrenProps for VisualizationPreview props
* Add element prop support to facade Route
* Compose route guards as element wrappers
* Memoize the element bridge by component type and render off the injected route
* Clarify that index routes accept component or element
* Test that the element bridge reuses shared components across sibling routes
* Migrate DataSelector SavedEntityPicker to TypeScript
* Align DataSelector picker prop types with actual usage
* Migrate DataSelector to TypeScript and adapt consumers
* Migrate DataSelector unit tests to TypeScript
* Remove unused DataSelector props
* Add TODO for DataSelector api/metabase-lib structural-type cleanup
* Remove unused hasBackButton/onBack from DataSelectorSchemaPicker
* Use setState updater form in setStateWithComputedState
* Guard currentUser instead of faking a zero user id
* Avoid passing a null database to SchemaAndTableDataSelector
* Convert SavedQuestionHeaderButton unit spec to TypeScript
* Justify or remove DataSelector type casts for no-unjustified-type-casts
Different endpoints dealing with destination databases handled checks differently, causing confusing error messages depending on code path. This consolidates and standardizes both the messages and the HTTP code.
* Remove table_id from measures and segments
* Clear stale Card table_id when the query loses its source table
populate-query-fields only wrote table_id when the derived value was
truthy, so converting an MBQL question to native SQL (or pointing it at
a native model) left table_id referencing the old table. Make it
unconditional, the same fix#68080 applied to source_card_id.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review: validate derived table on segment/measure updates
- PUT /api/segment/:id and PUT /api/measure/:id now derive the table from an
updated definition: table-less definitions are rejected (consistent with
POST), and if the definition implicitly moves the entity to another table,
the user must also pass the create permission check for the new table
(write-check alone only covered the old table).
- Drop the dead :table_id insert arg in POST /api/measure — before-insert
already derives it from the definition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Derive segment/measure perms from the definition, not table_id
Per review: instead of API-handler checks against a separately derived table
id, the mi/can-read?, mi/can-write?, and mi/can-create? implementations for
:model/Segment and :model/Measure now derive the source table from the
definition via lib/primary-source-table-id (same admin-or-analyst check as
before, rejecting definitions with no source table), and the API handlers no
longer deal with table ids at all — table_id only remains in what we return
to the client.
Segment definitions are now normalized to MBQL5 at the API boundary (like
measures already were), so the perms and model hooks always see full queries.
Updating a definition re-runs the create check with the new definition, since
the write-check only covers the existing one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix remote-sync segment permission tests for definition-derived perms
The three remote-sync segment tests used empty {} definitions and relied on
table_id/hydrated :table for the permission checks. Perms now derive the
source table from the definition, so give the segments a real MBQL5
definition on the venues table. Also coerce the can-manage helpers to
booleans so callers asserting false? keep working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Strip stray :aggregation from MBQL4 segment fragments at the API boundary
The model migration always dropped :aggregation from legacy segment
fragments (segments cannot have one). When definition normalization moved to
the API layer, that behavior was lost, so fragments with a stray aggregation
(which several Cypress specs post) started failing schema validation on
insert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reject invalid segment definitions instead of stripping :aggregation
Per review: don't silently fix up segment definitions with a stray
:aggregation — validate the normalized definition against the segment schema
at the API boundary and return a 400. Remove the aggregation key from the
Cypress specs that were posting it (it was always dropped by the old model
migration, so those specs never exercised it anyway).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify: keep table_id derivation at the API, revert the perms refactor
Restore the segment/measure permission methods, search filtering, and the
associated tests to their previous table_id-based behavior. The PR keeps to
its core change: the segment/measure APIs no longer accept table_id — it is
derived from the definition and used for the permission checks (including
re-checking the newly derived table when an update moves the entity).
Also update compare-to-the-rest-15655-test for the card change in this PR:
a wrong table_id can no longer be persisted (it is always derived from the
query), so the comparison dashboard for a nil-table card is built from the
field-distribution path now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Only clear a stale card table_id when the query itself changes
populate-query-fields' unconditional :table_id meant any card update
(rename, archive, collection move) would wipe table_id whenever the
derivation came back nil — e.g. when the query's source card had been
hard-deleted. Gate the nil-overwrite on :dataset_query actually being in
t2/changes; inserts and query edits still clear stale values.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Persistent queue: concurrent batches, per-queue error handler, config
Generic improvements to the mq persistent-queue subsystem, extracted from the
explorations work so they can land independently:
- max-concurrent-batches: a queue can process multiple batches in parallel,
bounded by a (optionally dynamic) limit (metabase.mq.queue.concurrency).
- on-error: per-queue handler invoked when a message exhausts its retries,
so callers can record terminal failures instead of silently dropping them.
- Reworked polling, quartz scheduling, and registry for the above.
- Expanded mq/README.md and tests (concurrency, on-error, quartz affinity).
* Fix Eastwood reflection warnings on Scheduler.checkExists in quartz-test
`Scheduler.checkExists` is overloaded (JobKey vs TriggerKey), so the un-hinted
`nudge-key` made the three call sites reflective. Import `JobKey` and hint the
local; CI's Eastwood step fails the build on reflection warnings.
* Code fixes
* Migrate remaining ModalContent Emotion styles to Mantine and fix dead EmbedModal header overrides
* Make the modal header back button keyboard-accessible with a descriptive label
* fix orphan semantic search index table cleanup
* Harden orphan index cleanup: schema-scope the scan, skip views, isolate drops
The orphan scan now only considers base tables (a view in the DROP TABLE
batch would abort every nightly cleanup) and, in dedicated-pgvector mode,
only current_schema() — where the unqualified DROP actually resolves;
previously it scanned all schemas and logged drops that never happened.
Stale/orphan tables are dropped one statement at a time with per-table
error handling so a single undroppable table (e.g. one with a dependent
view) cannot permanently block stale, tombstone, and repair cleanup.
Also anchors index-table-name-pattern (\A/\z) so it stays substring-proof
independent of re-matches, documents that legacy pre-BOT-337
index_table_* names must keep matching, and derives the force-reset test
case from model-table-name/model-table-suffix instead of a literal.
* Count actual index-table drops and reap legacy index_table_ stragglers
drop-index-table! now reports :dropped/:missing/:failed — dropping without
IF EXISTS so a vacuous drop (metadata row whose table is already gone)
does not count — and cleanup logs dropped-of-found. The name pattern
gains an index_table_<anything> alternative: the prefix belongs
exclusively to the pre-BOT-337 era, whose digit-less stragglers were
otherwise unreachable.
---------
Co-authored-by: Chris Truter <chris@metabase.com>
* Do not duplicate user context between system prompt and last user message
In #71045 we moved most of the user context out of internal.selmer system prompt and injected it into the last user
message. However, other system prompts still injected user context into the system prompt, so we wound up formatting
and including it twice. If the `:user_is_viewing` context happens to be large (many related tables / fields), then the
memory and token cost can be significant (#76493).
This commit removes everything from system prompts that was already being injected into the last user message, namely:
- `current_time`
- `first_day_of_week`
- `current_user_info`
- `viewing_context`
It also moves `recent_views` into the last user message (not previously included there).
In addition to saving input tokens and memory, this should also help with caching on providers like openai that do
automatic prefix caching, since we now always inject the current date/time near the end of the prompt.
This also means all system prompts can drop the `<<<METABOT_CACHE_BREAKPOINT>>>` since they no longer have any dynamic
content after the breakpoint.
* Add :slackbot and :document-generate-content to profiles-excluding-recent-views
Their system prompt never included recent_views, so preserve the behavior now that we inject recent_views in the last
user message.
A failed CREATE USER used to surface the generated workspace password
verbatim in the JDBC exception message, which provisioning then logged
(GHY-3660). Scrubbing landed in #74657; this adds a regression test that
reproduces the failure for real: init-workspace-isolation! runs over a
connection authenticated as a low-privilege workspace user (granted
database-level CREATE on postgres/redshift so execution reaches the
password-bearing CREATE USER), and every piece of text a logger could
print from the thrown exception - stack trace, Throwable->map, causes,
suppressed, ex-data, and SQLException nextException links - must not
contain the password. A postgres-only assertion requires the redaction
marker in the echoed statement so the test cannot pass vacuously.
JDK 25 changed the empty file path to resolve to the current directory
(File("").exists() is now true), so a long-standing MB_CONFIG_FILE_PATH=''
made the config-from-file loader try to parse the current directory as YAML
and fail at boot. Ignore blank values like metabase.config.core/config-str
does everywhere else, and don't count them as config-text-file being enabled
in anonymous stats.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* initial migration
* initial migration
* remove deadcode loaders
* one more site is migrated. remove dead code
* make data-testid="loading-indicator" to be by default for Loader component
* fix unit test
* Omit negative values from treemap instead of producing wrong percentages
Treemaps summed metric values as signed totals and divided by that signed
total, so rows of 30 and -50 rendered a surviving leaf as -150%. Negative
tiles also have no positive area, so ECharts dropped them with no warning.
Mirror the pie chart: omit negative values (with a warning) during
aggregation, or fall back to absolute values when every value is negative.
With all node values non-negative the downstream percentage math is correct.
Fixes GDGT-2760
* Tighten null check and document per-row negative filtering
* Bump mise JDK to temurin 25 to match CI
CI defaults to Java 25 (.github/actions/prepare-backend and the backend
workflow matrix), but mise.toml still pinned temurin 21.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update remaining Java 21 leftovers to 25
The e2e matrix builder and docs still referenced JDK 21, while CI,
Docker images, and release requirements for v60+ are on Java 25.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Pin MBQL transform source schema in transforms e2e spec
The many_schemas Postgres fixture creates an "Animals" table in every
schema (Schema A through Schema Z). createMbqlTransform resolved its
source table via getTableId by name only, so Array.find returned an
arbitrary schema's "Animals" depending on /api/table ordering. The
"convert an MBQL transform to a SQL transform" test asserts the compiled
SQL contains "Schema A", so a non-Schema-A match made it flake.
Add an optional sourceSchema to createMbqlTransform and pin the spec's
source to Schema A so the resolved table and its SQL are deterministic.
* Wait for MBQL->SQL transform save to settle before clicking Run tab
The Run tab only exists in read-only view mode; after clicking Save the
test must wait for the update PUT and the navigation off /edit, otherwise
it races the still-in-flight save and times out looking for the Run tab.
* only pin source schema for the default source table
---------
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
* Add index-route support and redirect() to the metabase/router facade
* Adapt ModalRoute and DataPermissionsPage to the function-based Route
* Replace IndexRoute/IndexRedirect/Redirect with index routes and redirect()
* Convert createEntityIdRedirect to a facade-hook wrapper component
* Update route test scaffolding for index routes and redirect()
* Build the Route shim via createRoutes instead of augmenting RouteUtils
* Point the guards spec at the relocated Route export
* Stabilize transforms permission navigation in e2e specs
* Freeze redirect() target at mount to fix chained index-redirect race
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* Don't GZIP sync-flush per object when serializing QP cache entries
Sync-flushing after every cached row meant one native Deflater call in a
JNI critical region per row; on JDK <= 21 (no G1 region pinning, JEP 423)
that stalls GC via the GCLocker under concurrent cache writes. Let the
deflater buffer normally instead, and have result-fn finish the stream
(draining the deflater and writing the GZIP trailer) before returning the
bytes. Old cache entries (unterminated gzip) still deserialize since the
reader already treats EOFException as end-of-stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Don't flush the JSON download writer per row
Each per-row flush cascaded into a sync-flush of the gzipped HTTP response:
one native Deflater call in a JNI critical region per exported row (GCLocker
stalls on JDK <= 21) plus one TCP packet per row (#34795, fixed for the :api
writer but not :json downloads) and a worse compression ratio. Let the
BufferedWriter push rows through as it fills, like the CSV writer does;
finish! still flushes the tail.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Make Snowflake CI runs use a randomly-generated, isolated DB names.
Something is still randomly calling `destroy-db!` on the test-data
dataset and wrecking things for CI runs:
```
(pprint (jdbc/query (no-db-connection-spec)
["SELECT query_text, end_time
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE query_text LIKE '%_c1bae%'
AND (query_text LIKE '%DROP%' OR query_text LIKE '%drop%')
ORDER BY end_time DESC limit 128"]))
[...]
"DROP DATABASE \"sha_rel_c1baee7db240aa419104c2d925a07a4d4faeeb24_test-data\";"
```
This particular query (without `IF EXISTS`) only exists on master in
the `destroy-db!` method. However, this method already has protection
against being run on the `test-data` dataset and will throw an
exception if this is tried! And the main call to `destroy-db!` is
gated behind a feature check for `:test/dynamic-dataset-loading`. So
there's nowhere this could be coming from on master.
Rather than trying to track it down, I think it's better to just treat
all snowflake DBs as isolated and disposable. Update `qualified-db-name`
to just prefix the DB name with a random prefix, and use the
`after-run` hook to drop it when done. This also allows us to get rid
of the janky and weird locking system, because no two jobs should ever
try to hit the same DB.
* Add per-version download link to Security Center advisories
Thread optional download_jar_url through backend schema/fetch and render
a Download link next to each fixed version in AdvisoryCard.
* fix aligning
* Read instance version via useSetting; simplify affected-version lookup
* rollback a redundant change
* addressing PRs comments
* use sequence to nil-coerce download_jar_urls
* addressing the PR's comments
* add download-click analytics
* fix test: add download_jar_urls to Advisory factory
* add e2e test
* initial migration
* fix link input size
* remove dead files
* Use Input.ClearButton for textinput clear icon
Replace hand-rolled Tooltip+ActionIcon+Icon clear pattern w/ Mantine
Input.ClearButton across 5 sites; c=text-secondary + rightSectionPointerEvents=all.
Comment why LinkViz needs pointer-events on both root+input.
* fix unit test
* Update loki refs for parameter search Input->TextInput
* Use Input.ClearButton in PermissionsEditorContent; rm blank line in SearchBar styled
* addressing the PR's comments
* validate client sent parent_message_id
* implement more restrictive retry logic
* move history to the backend
* migate state from conversation to message records
* client nows generates message ids to shrink case of client not knowing id of written message record on aborted requests
* pr feedback
* [Metabot] Implement conversation branches in usage analytics (#77542)
* implement branches in usage analtyics
* sol pass at making code terser / cleaner
* handle error messages correctly + allow branching after assistant message
* pr feedback
* fix test failures
* remove migration logicalFilePath
* Hide write-only share actions when entity is not writable (#72752)
The Question and Dashboard sharing menus, and the public-link popover,
offered write actions to any admin regardless of write access. On a
read-only remote-synced instance a card/dashboard in a synced collection
is not writable (can_write=false), so these actions 403'd or authored
state that doesn't stick — the "updating embed fails on read-only
instance" symptom in #72752.
Gate the write actions on can_write, matching how the normal edit
affordances already disappear when the entity can't be written:
- Embed (publish): hidden when not writable.
- Create a public link: hidden when not writable; an existing public link
stays visible so it can still be viewed/copied (those are reads).
- Remove public link: hidden when not writable.
* Disable embed Publish instead of hiding the option on read-only entities (#72752)
Keep the Embed sharing option visible for a read-only remote-synced
card/dashboard, but disable the Publish/Unpublish buttons in the embed
modal with a tooltip explaining the entity is synced and read-only.
This reverts the earlier hiding of the Embed menu item; the public-link
gating (hide create / remove, keep an existing link viewable) is
unchanged.
* Translate the read-only embed tooltip per resource type (#72752)
Interpolating resourceType left the noun untranslated and prevented the
rest of the sentence from agreeing with its gender.
* Add ai-tracing: eval-time trace capture for the Metabot agent
Introduce a new `ai-tracing` module that captures complete, per-run agent
traces during benchmark/eval runs and exports them to an external eval
backend (Confident AI) over OTLP — separate from production observability.
- Capture is gated by a per-run dynamic binding (`*capture*`), orthogonal to
MB_TRACING_*, so it never fires on organic traffic. Enabled via the
env-only MB_AI_EVAL_CAPTURE setting; the API path emits the trace as an
`eval_trace` data part and exports it.
- Captures the full agent.turn -> llm.call -> tool.* tree with prompts,
completions, tool args/results, durations, and concurrent-tool nesting
(conveyed across the agent's virtual threads).
- OTLP export uses a dedicated TracerProvider (SimpleSpanProcessor +
force-flush, never the production one), replaying the in-memory tree
post-hoc. The domain -> Confident `confident.*` semconv mapping lives in a
single chokepoint (`node->semconv-attrs`); vendor/schema swaps are one edit.
- Wires capture into run-agent-loop, loop-step, call-llm, and run-tool.
- Trace-level input/output (user question + final answer) set on the root.
- Export failures are logged loudly instead of swallowed.
- Tests cover the gate, nesting, enrichment, timing, concurrency, the
semconv mapping, and a metadata-size regression guard.
* Make ai-tracing export more idiomatic; refresh stale docs
- export.clj: replace node->semconv-attrs's nested cond-> of repeated
type checks + json/encode with :ai/keys destructuring and a flat
medley/assoc-some table; compose final-llm-output as transducers;
extract event->attributes; tidy parse-headers (for-comprehension),
build-provider! (doto), and ensure-provider! (fresh? helper).
- core.clj: drop the stale "seam not wired" comment block and the
outdated ns docstring (it referenced the abandoned gen_ai/OpenInference
schema and "single namespace" framing); describe the shipped export.
No behavior change; 14 tests / 56 assertions still pass.
* Replace ai-tracing OTLP export with per-session JSONL log files
Switch the eval-trace collection method from OpenTelemetry/OTLP export to a
dedicated file-based logger, and extend instrumentation to the MCP server and
agent-api in addition to the Metabot agent loop.
- Rip out all OTLP/Confident code (export.clj, the OTLP settings, the OTel
provider/semconv mapping, the eval_trace SSE part).
- New metabase.ai-tracing.log sink: each span streams one JSON line on a
dedicated logger, routed per session to its own <session-id>.jsonl by a
Log4j2 RoutingAppender keyed on mb-eval-session-id. Pure JSONL (%m%n), full
payloads, no size limits.
- Session model = OTel trace-id analog: *session-id* minted at a fresh
capture's root, or supplied by the caller, conveyed across virtual threads
by bound-fn*. Nodes carry :id/:parent-id for tree reconstruction.
- Generic with-eval-session entrypoint (gated by MB_AI_EVAL_CAPTURE, inherits
when nested) reused by run-agent-loop, MCP dispatch-request (keyed on the
Mcp-Session-Id so a conversation lands in one file), and agent-api routes.
- MB_AI_EVAL_CAPTURE is the single on/off switch; the logger sits at info and
writes nothing unless a capture is active.
- run-agent-loop emits a tiny eval_session SSE part so the HTTP harness can
locate the session file.
Verified end-to-end: real agent run writes a per-session JSONL file with the
correct span tree (parent chain, full structured + normal tool output);
sessions route to separate files. 14 tests / 51 assertions pass.
* ai-tracing: capture full request/response context and add trace read API
Enrich eval spans so a captured trace is a self-contained record of a run:
- agent-api: record HTTP request body, user id, and the response status/body
via a wrapping respond fn that fires inside the span.
- mcp: record JSON-RPC params, user id, scopes, and the materialized result.
- metabot agent: thread an optional eval-session-id through run-agent-loop so
the harness can name and read back the per-session trace file; record the
full request (messages/context/state), turn-level token usage, finish reason,
and final agent state (link registry / generated entities). Surface the loop
finish-reason out of loop-step instead of only logging it.
- log: guard span serialization so a non-encodable attribute can never break a
traced run.
Add metabase.ai-tracing.api: a superuser-only GET /api/eval-trace/:session-id
that returns the captured JSONL, mounted only when MB_AI_EVAL_CAPTURE is on.
Filenames are validated against path traversal. Wire the module into routes and
the kondo module config.
* ai-tracing: preserve OpenAPI spec on agent-api routes; harden trace sink
The eval-tracing wrapper turned agent-api `routes` into a plain fn, which no
longer implemented OpenAPISpec — full-API spec generation (openapi.json,
endpoint-dox, the openapi-all-routes test) threw when the walk reached
/api/agent. Wrap it in `handler-with-open-api-spec`, delegating the spec to the
underlying ns-handler.
Also: narrow the trace-sink catch to Exception so Errors/InterruptedException
propagate; stream the trace file instead of slurping it into heap; and correct
the "single nil-check" docstring claim.
* ai-tracing: validate eval-session-id at mint time to prevent path traversal
The trace read endpoint sanitized the session-id before touching disk, but the
write path fed the raw, caller-supplied eval_session_id straight into the
RoutingAppender's `<session-id>.jsonl` file name — a value like `../../foo` on
an eval instance escaped the trace dir (arbitrary .jsonl write).
Hoist the safety regex to a single `safe-session-id-re` in ai-tracing.core and
add `checked-session-id`, which mints a uuid when none is supplied and throws on
an unsafe supplied id. `with-eval-session` now validates every caller-supplied
id before it can reach the log-file path; ai-tracing.api references the shared
regex so read/write validation can't drift.
* ai-tracing: gate span-macro arg construction on capture-active?
The span macros expanded to an eval-span* call, so span-name and attrs
were built on every invocation and discarded on the nil-check when no
capture was live. Hoist the capture-active? guard into a `spanning`
helper macro (and an inlined variant in with-tool-call, whose name
derives from attrs) so organic traffic never builds the span name string
or attrs map — it pays only the *capture* nil-check plus one thunk
closure. with-tool-call now evaluates attrs exactly once, and only when
capturing.
* ai-tracing: test cross-module span nesting; make agent tests capture-agnostic
Add eval-tracing-nesting-test: drives the real run-agent-loop under
capture-reducible with only the LLM + search backend mocked, and asserts
the emitted span tree nests agent.turn -> llm.call -> tool.search. This
covers the wiring the ai-tracing docstrings promise — the turn/llm spans
opened in metabot.agent.core, the tool span in metabot.self.core, and
*parent* conveyed across bound-fn* into the virtual-thread tool executor
— which the ai-tracing unit tests exercised only at the primitive level.
Also pin eval-capture off for the namespace via a fixture. run-agent-loop
appends an eval_session data part to the stream whenever capture is active,
so every exact-match assertion on its output broke under MB_AI_EVAL_CAPTURE
(which dedicated eval instances set). The fixture keeps those assertions on
the production stream shape; the new test forces capture independently.
* ai-tracing: key MCP eval session on UUID correlator; complete finish-reason
Key the MCP eval-trace session on the session id's UUID prefix rather than
the full <uuid>.<base64> id: the prefix is stable across the conversation and
always filesystem/URL-safe, so a base64 payload that require-valid-session
accepts but safe-session-id-re rejects (e.g. `=` padding) can no longer make
checked-session-id throw ahead of the JSON-RPC try/catch and 500 the request.
Record :finish-reason on the empty-response and reduced (early-termination)
loop terminations too, so the turn span's :ai/finish-reason is populated on
every path, not just natural completion.
* ai-tracing: harden trace sink serialization and error capture
- emit!: fall back to a JSON-safe copy when an attribute value isn't
encodable, so one bad value no longer drops the span (and, for a root
span, orphans its children).
- span error events now carry the exception class and ex-data, not just
the message.
- rename JSONL :start-ns/:end-ns to :start-epoch-ns/:end-epoch-ns (they
hold wall-clock epoch nanos, not System/nanoTime).
- test the api trace-file path-traversal guard directly.
* ai-tracing: bound session ids, gate user-input build, snapshot finish-node
- Cap supplied eval-session-id at 200 chars on the API schemas and in
checked-session-id, so a supplied id can't produce a filename that fails
creation and silently drops the trace (charset was already enforced).
- Compute the turn span's :ai/user-input inside the capture-gated attrs map
instead of unconditionally on every run-agent-loop call.
- finish-node takes a single consistent snapshot of the node atom.
* ai-tracing: enforce session-id cap+charset at API boundaries
Route the 200-char cap through ait/max-session-id-length everywhere (read
endpoint, run-agent-loop opts, POST /api/metabot params) instead of
hardcoding the literal, and bound the previously-unbounded read-endpoint
:session-id path segment.
Validate the charset via safe-session-id-re in the Malli schemas so a bad
eval-session-id 400s at the boundary rather than throwing deep in
checked-session-id and surfacing as a generic agent error. Anchor
safe-session-id-re with \A/\z so it stays exact under both re-matches (mint/
read checks) and re-find (Malli :re) — unanchored, :re accepted any string
containing a safe run (e.g. ../../etc/passwd).
* ai-tracing: key MCP eval trace on a harness-supplied session id
Let an eval harness name and later fetch an MCP conversation's trace. The MCP
endpoint honors an `x-eval-session-id` header — `dispatch-request` keys
`with-eval-session` on it (validated through `checked-session-id`, the single
source of truth for the safe-id contract), falling back to the `Mcp-Session-Id`
correlator when absent. This is the MCP analogue of metabot's `eval_session_id`,
needed because the client owns the `Mcp-Session-Id` and never exposes it.
Also record the resolved query on the construct-query endpoints as `ai/query`
(inert unless capturing): the MCP `construct_query` tool returns only an opaque
handle, so this is the only place a harness can recover the agent's query off
the trace to grade it.
* ai-tracing: address review — guard :reduced append, cover the read API
- run-agent-loop: on a :reduced stop (client disconnect / cancellation) the
reducing fn has already terminated, so don't step it again to append the
eval-session pointer or debug part — that violates the transducer contract.
Fold both trailing appends into one capture-gated cond-> that no-ops on
:reduced (also fixes the latent debug-branch case). The harness reads the
JSONL file, not the stream, so a skipped pointer on disconnect costs nothing.
- agent-api: record :http/user-id in the respond closure, where +auth has bound
*current-user-id* — it is unbound when the span opens, so direct HTTP callers
previously recorded nil.
- ai-tracing.api: add endpoint integration tests — capture-gated 404,
superuser-gated 403, missing-trace 404, and existing-trace 200.
- log: mark node->entry ^:private; document that trace-file retention is the
operator's responsibility (append-only, no rollover, re-runs accumulate).
- mcp: extract the dispatch lambda (line length) and note the fresh-file-per-
request fallback when neither session id is present. Mark pure tests ^:parallel.
* ai-tracing: drop dead exports, document capture id, harden json-safe
- Remove the `sanitize-sql` re-export and `event!`: neither has a
non-test caller. Dropping `sanitize-sql` also removes ai-tracing's only
dependency on the `tracing` module (trimmed from `:uses`). The sites that
put a query in a span record the raw value on purpose (a scorer wants the
real SQL/MBQL), so the sanitizer was advertising an inconsistency the
design doesn't want.
- Document that `capturing`/`capture-reducible` is return-value-based: a
session id supplied to an inner `with-eval-session` is ignored because
capture is already active, so no `<id>.jsonl` is written on that path.
- `json-safe` now stringifies non-scalar map keys too, so the fallback can
never itself throw and orphan the root span it exists to save. Covered by
a vector-keyed-map regression test.
* ai-tracing: address review — gate pointers, bound json-safe, drop indirection
Correctness:
- capturing/capture-reducible no longer mint a session id, so the in-process
path is truly file-less (read :trace) instead of writing a stray <uuid>.jsonl
- gate the trailing eval_session pointer on a non-nil *session-id* so the
file-less path doesn't emit a pointer to a <nil>.jsonl
- add *retain-tree* so the in-memory span tree is only built when a caller
reads it back (capturing); the file-routed path no longer pins heap for the
whole run
- depth-bound json-safe so a deep/cyclic attr degrades to a string instead of a
StackOverflowError escaping emit!'s catch
- agent-api trace wrapper: only record a plain-data :http/response (skip a
streaming/opaque body rather than stringifying it); fix the stale comment
Cleanup:
- drop the eval-capture-enabled? indirection; entrypoints and tests consult the
ai-eval-capture setting directly
- with-eval-session: inline supplied-id, ^:once, generic 404 gate message
- extract mcp dispatch-method to de-nest the JSON-RPC case
- remove the dead ai-tracing.core/event! kondo entry
- keep eval JSONL spans out of the shared test log (log4j2-test.xml)
Tests: cover the agent-api trace wrapper, the file-less no-pointer/no-file
behaviour, and drive the log-sink assertions through with-eval-session.
* ai-tracing: fix route sort order and wire settings through init
Two structural test failures on the branch:
- Move the `/eval-trace` route into sorted position in the route-map
(keep-routes-sorted-test reads the file and asserts the keys are sorted).
- Add `metabase.ai-tracing.init` (requiring the settings ns), require it from
`metabase.core.init`, and add it to the module's `:api` — so
settings-namespaces-should-be-required-by-init-namespaces-test passes and the
`MB_AI_EVAL_CAPTURE` setting is registered deterministically at startup rather
than only when some instrumented namespace happens to load `ai-tracing.core`.
* ai-tracing: store span wall-clock timestamps as epoch millis, not nanos
Review: the epoch fields were nanos synthesized from currentTimeMillis (ms-
granular anyway), and precise sub-ms duration already lives in :dur-ms — so nanos
bought nothing. Rename :start-epoch-nanos/:end-epoch-nanos → :start-epoch-ms/
:end-epoch-ms (and the JSONL keys :start-epoch-ns/:end-epoch-ns → :start-epoch-ms/
:end-epoch-ms). End is still start + the monotonic duration (rounded to ms), so it
never reads a second, possibly backward-jumping wall clock.
visitDashboard waits on every dashcard query with cy.wait's default 5s
per-alias window; with 20+ cards the tail queries stagger behind the HTTP/1
concurrency limit and miss it (the dashcardQuery### "no request occurred"
flake). Add a dashcardTimeout option to visitDashboard and use it for the
large-dashboard visits in this test, so the downstream card assertions can
stay at their default timeouts. Also give the 20-request bulk move a longer
window, since its tail lags under CPU constraint.
The ci-conductor scripts are shared CI infrastructure: every supported
release branch runs them, and they keep evolving on master. A copy
committed to a branch goes stale, and syncing six branches by hand on
every change does not scale.
Add a prepare-ci-conductor action that overlays release/ci-conductor and
e2e/support/ci_conductor.ts from master, so a release branch runs the
current scripts while its workflow wiring stays branch-historical.
Anything headed for master uses its own sources, so a PR can still test
its own changes.
quarantine-gate and report-failures-to-ci-conductor delegate their bun
setup to it. prepare-cypress calls it too, because e2e/support/config.js
imports the reporter at Cypress bundle time and it has to be in place
before the run starts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Implement schema, lib helpers, viz-settings + query -> query
* Implement grouping-set compilation
* Implement query nesting to support breakouts with parameters
For example, when selecting nested JSON fields in postgres,
the generated SQL query contains prepared query parameter
placeholders and the engine cannot determine that all copies
are the same expression.
* Keep dominant treemap share honest instead of rounding to 100%
The treemap percent formatter used a fixed 2 decimal places, so a dominant
leaf's share rounded up to a misleading 100.00% (e.g. 100000 vs 1 rendered
as 100.00% instead of 99.999%).
Mirror the pie chart: derive the decimal count from every node share via
computeMaxDecimalsForValues, so a tiny sibling forces enough precision to
keep the dominant share honest. Floored at the existing two decimals so
ordinary treemaps are unchanged, and a genuine whole still shows a clean 100%.
Fixes GDGT-2761
* Pass tree to test setup via SetupOpts object
* Delete react-router-redux and redux-auth-wrapper
* Dedupe redux to a single v5 copy
Removing the @types packages dropped a transitive redux@3 pin that kept a
typeless redux at the top level and masked a redux@4-vs-RTK-v5 type conflict.
Force a single redux@5 so types stay consistent.
* Migrate remaining react-router-redux references
Drop the now-dead eslint import bans for the deleted package, remove the
obsolete redux-action cast in auth logout, and reword stale comments. Keep the
metabase/router provenance docs that name the package as their reference point.
* First pass for updating Lib
* Update metabase.lib.query.test-spec
* All Lib + Lib BE tests are passing
* Fix remaining tests in Lib + QP
* Update Lib JS template tag functions
* cljfmt
* Another round of test fixes 🔧
* Leave NativeQuerySnippet :template-tags alone for now; test fixes 🔧
* cljfmt
* kondo
* Test fixes 🔧
* FE test fix? 🔧
* Another round of test fixes 🔧
* Test fix 🔧
* Test fixes 🔧
* Test fix 🔧
* Test fixes 🔧
* PR feedback
* PR feedback
* 🔧
* Test fix 🔧
* Use qualified db names for locks in snowflake.
We're seeing some timeouts in Snowflake on CI. There are a few reasons
for this, but part of it is that we are locking a lot waiting for
access to the datasets. These locks are currently based on unqualified
database names, not hashed and branch-qualified, so they lock more
often than they need to. Using the qualified names spreads things out
a bit.
In order to do this, we need to change the `dataset-lock` method to
take the whole dataset (to hash it) not just the dataset's database-name.
* Fix upload test to not assume table set is stable.
The purpose of this test is to ensure that the created table is gone,
but the assertion it makes is far too wide. This breaks in snowflake
where we have other tests randomly creating tables in the shared cloud
DB across jobs.
* Rename snippets and application_permissions plugin modules to TS extensions
* Convert snippets and application_permissions plugin modules to TypeScript
* [pr-3] Move SDK error types to embedding-sdk-shared
* [pr-3] Move SDK auth-config types to embedding-sdk-shared
* remove un-useful usage of SdkCollectionId to remove import of sdk code in core app
we don't really need that type and they both end up being `number | string`
* [pr-3] Move PLUGIN_NOTIFICATIONS_SDK to the core app plugins
I don't particularly like this: this is one of those "plugins" used
both to activate enterprise code and to inject behavior from the SDK
into the core app. We don't have an alternative at the moment, so this
is the change with the smallest blast radius. The plugin was already
used in the core app, so this just moves it to the right place.
* [pr-3] Move onEditQuestion from the SDK dashboard context to DashboardContext
DashCardMenuItems (feature/dashboard) reached into the SDK bundle's
SdkDashboardContext just for the onEditQuestion override.
I moved the onEditQuestion to the DashboardContext instead, as
it's used in the core app dashboard.
* [pr-3] Remove CommonSdkStoryWrapper from SimpleDataPicker stories
It was not needed
* [pr-3] Move useLazySelector to the SDK package
The hook exists to read the lazy-loaded bundle's redux store from the
npm package, so the package is its natural home. Its only bundle-side
consumer, useInitDataInternal, already receives the redux store as a
parameter and now subscribes to it directly instead of going through
the window props-store singleton.
* [pr-3] Introduce SdkSharedStoreState
Some core app code currently needs to read _one_ piece of information
from the SDK redux state, I moved that piece of info into a new type
called `SdkSharedState` which I documented as being something we
should avoid adding to in the future.
* [pr-3] Promote embedding-sdk-bundle to the app tier
Promotes the sdk bundle to app tier, it needs a special rule for the
bridge
module that we'll hopefully clean up in the future
* [pr-4] Promote embedding-iframe-sdk to the app tier
EAJS becomes app tier, but we need to whitelist the theme preview from
importing it
* [pr-5] Move DashboardSharingEmbeddingModal to the dashboard module
The modal is dashboard UI living under metabase/embedding, giving the
embedding module an outgoing edge into dashboard (it imports
metabase/dashboard/selectors). Move it next to its domain so the next
commit can enforce the embedding module's outgoing boundaries.
* [pr-5] Move UserForm and group helpers to the common module
UserForm is used by the new "invite people to view" modal
(InviteToViewModal, which lives in metabase/embedding, shared tier),
so it moves out of feature/admin down to metabase/common together with
GroupsMultiSelect and the group utilities it depends on. Import paths
updated, no logic changes.
* [pr-5] Move DashCardMenuItem to the embedding-sdk plugins contract
It's imported by shared/embedding-sdk but it lived in feature/dashboard,
moved it down to remove violation that will be enforced on one of the next
commits.
* [pr-5] Enforce outgoing boundaries for shared/embedding and shared/embedding-sdk
* [pr-1] Define embedding modules
Defines the embedding modules without promoting them yet: modules whose
back-edges still need cleanup stay at their current tier (shared, outgoing
unenforced). Each module is then promoted to app in its own follow-up PR,
together with the fixes its back-edges require.
* [pr-1] Promote embedding-sdk-package to the app tier
The npm package is an entrypoint and should be able to import from
anything, and nothing should import from it.
I raised it up as app tier
* [pr-1] Promote embedding-sdk-ee to the app tier
This should probably be "embedding-features" in the future, but for now
I'm moving it to app to be above enterprise (which it imports from) and
to prohibit other things from importing from it
* [pr-2] Move LegacyStaticEmbeddingModalProps to the plugins contract
Module `embedding-iframe-sdk-setup` will be moved to app tier, so we move
`LegacyStaticEmbeddingModalProps` to the oss plugin file to avoid an import
going up to app
* [pr-2] Promote embedding-iframe-sdk-setup to the app tier
`embedding-iframe-sdk-setup` imports EAJS, which is app tier
so this also needs to be brought up to app tier
TBD how we want to structure this, but it kinda makes sense that some
parts of the core app are above EAJS as they use it (we also use it
to render theme previews in the admin settings for themes)
* Move root route onEnter/onChange/onLeave into components and effects
* Replace data-studio index onEnter redirect with a component
* Address review: settle LoadCurrentUser on rejection, drop test any-cast
* Point test Route import at the metabase/router facade
* Freeze has-user-setup on entry so the setup wizard is not interrupted
* Colocate route lifecycle components with the modules that own them
* Drop onEnter/onChange/onLeave from the facade's Route props
* Update to typescript 7.0 (beta)
* Switch to tsgo in scripts
* Remove NODE_OPTIONS from typecheck script
* Use typescript 7 instead of native-preview, but keep 6 around for lint
* Use typescipt 7.0.2
* Use real typescript 6.0.3 for eslint, run tsc 7 by explicit path
* Fix type errors surfaced by typescript 7
* refactor(sql-parsing): protocol + graal split, render.js-style context pool
Mirror the static-viz renderer structure (channel/render/js) for the sqlglot
parser:
- protocol.clj: strongly-typed SqlParser protocol for the sql_tools operations
- graal.clj (replaces pool.clj): GraalPy implementation on a 0..2 dirigiste
pool; contexts share one refcounted Engine and one bootstrap Source, created
with the first context and closed with the last, so an idle pool releases
all native memory (replacing the old TTL/min-size-1 leak mitigation);
timed-out contexts are interrupted and disposed instead of released
- common.clj: create-pool copied as-is from render.js.common (replaces the
PythonEval protocol); the future external-python impl will share it
- core.clj: public API unchanged; keeps JVM-side pre/post-processing
(VALUES stripping, field/error conversion) and delegates through the
protocol
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* tune(sql-parsing, static-viz): 0..3 pooled graal contexts, 10-minute idle reap
:idle-minutes is now an explicit create-pool option in both pool factories
(no default): graal pools (sqlglot + static-viz) keep idle workers for up to
10 minutes, the node renderer pool stays at 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(sql-parsing): native CPython parser, mirroring the static-viz node renderer
- python.clj: :python SqlParser running sqlglot in a pool of external CPython
child processes speaking newline-delimited JSON over stdin/stdout
(sql_tools_cli.py); in dev the child imports straight from
resources/python-sources, from the jar the sources are written to a fresh
temp zip per spawn (zipimport) and deleted with the process; single-process
pool (0..1, 1-minute idle reap), timeout kills a wedged process, one retry
when an acquired process turns out dead, call-level Python errors release
the healthy process back
- settings.clj + parser.clj: sql-parsing-mode setting (:graalvm default |
:python) and the mode-selection namespace, like static-viz-mode/renderer
- common.clj: python-sources locations + dev sqlglot install move here from
graal.clj (shared by both transports), plus make-parser building the
protocol reify over a transport call fn (marshalling written once)
- fix latent nil-schema bug in validate-query arg encoding: (json/encode
(or schema "{}")) double-encoded, making Python treat the string "{}"
as a strict-mode schema; encode an empty map instead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sql-parsing): transport-agnostic parse errors; 10-minute idle for process pools
Both parser transports now throw the same ex-info for Python-side failures
(common/call-failed-ex: :sql-parsing/error + extracted Python error type) —
graal wraps PolyglotException, the CPython transport wraps its ok:false
responses — and core exposes parse-error? so callers stop catching
PolyglotException (sql-tools.sqlglot updated; a python-mode instance
previously bypassed its parse-error fallbacks entirely).
Also raise the external-process pools (sql-parsing python, static-viz node)
to a 10-minute idle reap, matching the graal context pools.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(sql-parsing): un-nest testing blocks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* build(docker): install python3 for the :python sql-parsing mode
Both images already carry nodejs for the :node static-viz renderer; add
python3 (~45MB on alpine) so MB_SQL_PARSING_MODE=python works out of the box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* build(docker): default sql-parsing to python and static-viz to node in the entrypoint
The image ships python3 and node, so prefer the external-process backends
(lighter and faster than in-process GraalVM) unless MB_SQL_PARSING_MODE /
MB_STATIC_VIZ_MODE are set explicitly, following the MB_JETTY_HOST pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* tune(static-viz): keep the node process pool at a 1-minute idle reap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sql-parsing): wire settings into module init
metabase.core.init-test requires every .settings namespace to be reachable
from an init namespace; add metabase.sql-parsing.init and register it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Resolve the default search engine from capability precedence
The search-engine setting's :appdb default meant the engine precedence
list never chose semantic, no matter how many of its dependencies were
met. Engine selection now works in three layers:
- supported-engine? is a pure capability check (app-db type, premium
features, infrastructure) and no longer reads the search-engine
setting.
- The default engine is the configured override when servable,
otherwise the first supported engine in the precedence
(semantic > appdb > in-place). Unknown configured values warn once
and degrade instead of breaking all of search.
- Indexes are maintained for the default engine, the engines it
declares as dependencies (semantic mixes in appdb results), and any
engines opted in via the new additional-search-engines setting.
Reading the search-engine setting now resolves the engine that
actually serves search, so the admin UI needs no changes; the raw
override is available via configured-search-engine.
An explicitly passed per-request search_engine that cannot serve now
returns a 400 naming the cause (unknown / not supported / not enabled)
instead of silently falling back; legacy engine cookies keep the
fallback. The engine-active prometheus metric now reports active-set
membership rather than capability.
MB_SEMANTIC_SEARCH_ENABLED remains a kill switch, folded into the one
canonical semantic availability predicate (so repair/cleanup tasks now
respect it too); it will be removed once Cloud stops setting it.
Part of BOT-1832
* Tighten search engine docstrings and comments
* Validate cookie-carried search engines before use
A stale engine cookie naming an unmaintained index (e.g. appdb on an
in-place-default instance) now degrades to the default engine instead
of querying a missing index. The in-place exemption in engine-active?
uses = rather than isa?, so derived engines don't inherit a free pass.
Updates the prometheus search-engine-metrics-test to the new
engine-active semantics.
Part of BOT-1832
* Serve per-request engines through validated query-params; gate semantic writers on activity
Review follow-ups from roborev job 2600 and the workflow review:
- The engine middleware now reads and writes :query-params - the
:params injection had been dead since the api.macros migration, so
engine cookies had no effect on the served engine. Cookie values are
validated (stale/unknown degrade to the default), and param values
are normalized: blank means absent, the qualified form the API
returns in :engine is accepted, and the legacy fulltext name maps to
appdb at the boundary so activity checks use exact matches.
- engine-status in search.engine is the one classification of whether
an engine can serve (:ok/:unknown/:unsupported/:inactive); the API
layer maps it to 400s and no longer re-encodes engine invariants.
- Semantic fallback (EE core and the metabot search tool) prefers
engines with a maintained index over merely supported ones, so an
in-place default with additional-search-engines=semantic mixes with
the warm appdb index instead of in-place.
- The semantic indexer and index-repair tasks gate on the new
semantic-search-active? instead of availability, so a supported but
inactive semantic index no longer accrues hourly embedding spend.
- active-engines guards the pre-init nil default and only
capability-checks dependency entries.
Part of BOT-1832
* Canonicalize legacy engine names at every boundary; tolerate repeated engine params
search.engine/canonical-engine resolves legacy names (fulltext -> appdb)
for configured values, additional-search-engines, and request
params/cookies alike, so active-engine membership compares exactly.
A repeated search_engine query param parses as a vector; the middleware
now passes it through for schema validation to reject with a 400
instead of throwing.
Part of BOT-1832
* Review cleanups: single evaluation of engine resolution values
Bind configured-engine, the parsed param engine, and active-engines
once per expression instead of re-evaluating (Copilot); remove blank
lines inside the check-engine-serves! case form (CodeRabbit).
Part of BOT-1832
* Engine-active metric means can-serve; harden param parsing and dependency filtering
engine-active shares engine-status's definition: in-place is always
active while it exists, indexed engines while their index is
maintained. Fixes the prometheus test assertion that compared a metric
to itself.
param->engine strips the search.engine/ prefix rather than splitting on
slashes, so junk values 400 with the full value named instead of
validating their last segment, and degenerate values like // cannot
produce an empty or nil keyword.
active-engines filters dependency engines through known-engine? before
supported-engine?, matching the startup-ordering guard in
supported-engines.
Part of BOT-1832
* Reject malformed explicit engine values instead of treating them as absent
A non-blank search_engine that strips to a blank suffix (e.g.
search.engine/) parses to an unknown engine and 400s naming the raw
value; only genuinely blank values fall through to the cookie/default.
Part of BOT-1832
* Clarify the unknown-engine filter comment in supported-engines
* Fix review findings: runtime kill switch, csv hygiene, cookie unpinning, shared fallback
- Semantic Quartz jobs are scheduled when the instance is capable
(pgvector + feature) and check availability/activity per execution,
so the kill switch works at runtime again instead of gating job
scheduling at boot.
- semantic-search-active? is plain active-set membership; the HNSW
build gates on it like every other index writer.
- additional-search-engines values are trimmed, blanks dropped, and
qualified/legacy names canonicalized (prefix handling now lives in
canonical-engine, the one boundary fn).
- An explicit blank search_engine clears the engine cookie (the unpin
mechanism this branch had removed) instead of being ignored.
- fallback-engine lives in search.engine; the EE semantic core and the
metabot search tool share it.
- The runtime-activation backfill note moved from the
additional-search-engines description to a comment.
Part of BOT-1832
* Initialize the semantic index from repair when none is active
Semantic can become active at runtime (kill switch re-enabled, or added
to additional-search-engines) without init! ever having run; the hourly
repair task now initializes and backfills in that case instead of
no-oping until a manual /re-init.
Also renames warn-once to warn-once! per the side-effect convention and
adds end-to-end coverage for the pinned-cookie and blank-unpin paths.
Part of BOT-1832
* Cover the repair-initializes path; unpin test asserts the resolved default
The blank-unpin api test compared against a hard-coded appdb default,
which does not hold on MySQL/MariaDB app dbs. New repair test starts
from a blank pgvector db and asserts repair-index! creates an active
index and gates the documents.
Part of BOT-1832
* Trigger search index init when additional engines are set at runtime
Registers the (previously dangling) init-job-key as a durable,
trigger-less quartz job wrapping the search init, and the
additional-search-engines setter fires it when it exists. In tests the
scheduler is not running, so the setter stays side-effect free there;
env-var configuration is covered by the startup init.
Part of BOT-1832
* Rename the engine cookie and cover the pin/unpin round trip
metabase.SEARCH_ENGINE becomes metabase.SEARCH_ENGINE_OVERRIDE, so the
population of long-lived cookies written before engine validation
existed stays inert.
The round-trip test covers pinning via an explicit param, serving from
the cookie, and clearing it with a blank param.
Part of BOT-1832
* Order semantic capability gates cheapest first
The license check short-circuits before the pgvector URL check, whose
slot becomes a DB probe under BOT-1796.
Part of BOT-1832
* Only trigger search init when the setting change activates an engine
Semantic init re-gates every searchable document, so an unconditional
trigger made any write to additional-search-engines cost a full app-db
sweep. The setter now diffs active-engines across the set and triggers
only for newly activated engines.
Part of BOT-1832
* Defer the engine-activation trigger to after commit
The activation diff and quartz trigger run via do-after-commit in a
future (extracted to trigger-init-for-newly-active-engines!), observing
the final committed settings state - a surrounding set-many! may
include the setting that makes the engine supported - without I/O on
the transaction's thread.
The cookie round-trip test uses the in-process client so the handler
runs inside the fixture's engine and index bindings.
Part of BOT-1832
* Review nits: log async trigger failures, quote the cookie regex, fix a docstring
The post-commit init trigger logs exceptions instead of dropping them
in the future; the cookie test quotes the cookie name before building
its regex; build-hnsw-index-async! says active, matching its gate.
Part of BOT-1832
* Schedule semantic jobs on the boot-static gate; indexer initializes missing indexes
Job scheduling happens once at startup, so it now gates on the only
boot-static input: whether a pgvector DB is configured. The runtime
gates (license, kill switch, engine activity) are checked per
execution, so applying a license or re-enabling the kill switch at
runtime no longer strands semantic as an unindexed default until
restart.
The indexer's 20-second tick initializes the index when it is missing,
so every runtime activation path (license, kill switch, setter on a
stale cluster node) converges within seconds instead of waiting for the
hourly repair or a restart.
Also: the blank-param unpin deletes the engine cookie (max-age 0)
instead of writing an empty 20-year one; the activation trigger helper
lives in search.task.search-index with static requires; the setter test
uses poll-until for the async positive case and a direct synchronous
call for the negative.
Part of BOT-1832
* Runtime self-heal initializes all active engines and the HNSW index
The indexer's missing-index branch runs the generic search init, which
covers newly active dependencies, and applies the same HNSW-strategy
safety net as task registration.
Part of BOT-1832
* Ensure the HNSW index on the indexer's active path
An index-backed strategy configured while semantic was inactive misses
the setter's build event; the indexer tick now fires the async build
when the active index lacks its HNSW index. The pg_indexes existence
check is self-limiting: CONCURRENTLY registers the index when the build
starts.
Part of BOT-1832
* Fix engine tests on app dbs without appdb search support
Explicitly requesting an unservable engine now 400s instead of silently
falling back, so tests must not hard-code appdb: the data-studio search
tests iterate only supported engines, the cookie staleness test pins
in-place (servable everywhere), and the not-enabled case runs only
where appdb is supported.
Also realigns warn-once! call args (cljfmt).
Part of BOT-1832
* Keep semantic active under the search test fixtures
active-engines now derives from the default engine, so fixtures that
pin default-engine were silently deactivating semantic: semantic
queries in the metabot search tool fell through to the term engine.
Both fixtures pin active-engines to the set the old derivation gave
them - appdb plus semantic when supported, or semantic alone for
legacy search.
Part of BOT-1832
* Rename with-new-search-* test fixtures to with-appdb-search-*
The appdb engine predates semantic search; new no longer disambiguates.
Part of BOT-1832
* Update the last supported-implies-active consumers
The metabot semantic-search feature predicate gates tool schemas on
engine-status :ok rather than capability, so semantic_queries is only
advertised when a semantic index is actually maintained.
The metric collector runs only when semantic is active, and index
cleanup no-ops until the bookkeeping tables exist: available but never
initialized is now a steady state, and both jobs would otherwise warn
or throw about missing tables on every run. Cleanup still runs while
merely available so an opted-out instance's leftover indexes get
dropped.
Part of BOT-1832
---------
Co-authored-by: Tyler Plude <tsplude@gmail.com>
* Migrate query_builder/analytics to TypeScript
* Type Card.creationType and drop analytics cast
Address review: type Card.creationType as a union instead of a plain
string, narrow Question.creationType(), and make the new_question_saved
type field optional/nullable to match the Snowplow schema. Removes the
cast in the analytics tracker.
* Drop as const in load-question via Card annotation
* Re-own react-router-redux mechanisms behind the metabase/router facade
* Wire store, app and store types onto the re-owned router facade
* Re-point react-router-redux call sites onto metabase/router
* Update router tests and harness for the re-owned facade
* Ban react-router-redux imports outside metabase/router
* Model routing state's null location contract instead of casting
* Flatten CallHistoryMethodAction into per-method history action types
* Match history actions with ts-pattern in routerMiddleware
* Preserve explicit null location in createMockRoutingState
Adds `bun run email-dev`: a small Bun server + static page that renders
the Handlebars email templates in src/metabase/channel/email/ with
editable sample data, so designers and copywriters can iterate on email
copy and layout without running Metabase or sending real emails.
- live-reloads when a .hbs file changes on disk (1s polling)
- Fields/JSON panels to fill in template values; edits persist in
localStorage per browser
- Desktop/Narrow/Phone width toggles and Copy HTML
- sample-contexts.js mirrors the real send-time contexts (traced from
messages.clj and the notification handlers)
- mirrors the jknack custom helpers (equals, format-date, now, initials,
card-url, dashboard-url, trash-url) in handlebars.js
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Migrate Link, ListItem, and LoadingSpinner from Emotion to Mantine + CSS modules
* Fix TableList disabled-state assertions by exposing data-disabled instead of relying on Emotion-injected styles
* Name the referring entity in remote sync not-found errors
When an import references content that doesn't exist on this instance, the
error named only the missing entity's raw serdes id. It now also names the
entity holding the dangling reference, and backtick-quotes names and ids so
leading and trailing whitespace is visible.
`load-one!` takes the referrer as an explicit parameter rather than carrying
it on the load context: `load-deps!` reduces over sibling deps and each
recursive `load-one!` returns its own context, so a context key would leave
later siblings labelled with the deepest child's referrer.
Message construction lives in `missing-reference-message` so the format can
change without touching the throw sites.
* Attach the referrer by catch-and-rethrow instead of threading it
Replaces the extra `load-one!`/`load-deps!` parameter with a try/catch around
the dependency load. The nearest enclosing `load-one!` unwinds first, so it is
the frame that attaches itself as the referrer; outer frames see the key
already present and rethrow unchanged. The original exception is kept as the
cause, preserving the stack trace of the detection site.
Adds an end-to-end assertion in bare-import-test that a real import of a Card
referencing a missing Database carries the Card as `:referrer`, which the
hand-built ex-info unit tests could not cover.
* Key the database-not-found error off ex-data, and name the referring card
`source-error-message` detected this case by matching "database not found"
against the cause's message text, then interpolated that raw internal string
into the user-facing error:
Import failed: A referenced database does not exist on this instance.
table id present, but database not found: [clickhouse nil some_table]
`import-table-fk` now tags the throw with `:error ::database-not-found` and
`:db-name`, and the render walks the whole cause chain looking for that key
rather than checking only the immediate cause. `::load-failure` carries the
referring entity, so both missing-content paths now render through
`missing-reference-message`:
Import failed: Card `Some card` (`abc123`) references Database (`clickhouse`),
which does not exist on this instance. ...
Also fixes `:database-names` in that ex-data, which selected names from
:model/Table rather than :model/Database. It has no consumers.
The end-to-end test uses a result_metadata `:table_id`, since that is the path
that actually reaches `import-table-fk` with an undeclared database:
`result-metadata-deps` derives deps only from `:field_ref`, and a Card's own
top-level `table_id` is recomputed from `dataset_query` by
`populate-query-fields` on insert.
* Backtick-quote file paths in remote sync ingest-error messages
GHY-3992 asks for content names to be quoted so leading and trailing
whitespace is visible. The ::not-found path already did this; the
ingest-errors branch still joined raw paths, and the message already uses
": " between path and reason and "; " between entries, so a path containing
punctuation or trailing space was ambiguous to read.
No fallback is added for a nil :file. All three ingest-error producers
(ingest.clj file-hierarchy!, ingestable.clj read and parse failures) always
set it, and only YamlIngestion and IngestableSnapshot produce a non-empty
ingest-errors; the other Ingestables delegate.
Splits source-error-message-entity-not-found-test into three deftests, one
per error branch, which kondo requires now that each has grown assertions.
* Render load-failure errors with the entity name, reason, and stripped keys
::load-failure fell through to the generic "Failed to reload from git
repository: <ex-message>" fallback, showing a NanoID and burying the
stripped-keys note. It now reads:
Import failed: could not save Card `Orders by Month` (`abc123`).
NOT NULL constraint failed: report_card.display.
and, when the entity was part of a dependency loop, adds "It may have been
saved without: `dashcards`, `parameters`." — a partial row for it survived
the import, which the user previously had no way to learn.
Renames the ::load-failure ex-data key `:referrer` to `:entity`. It names the
entity that failed to load, which is a referrer only relative to the missing
database in the ::database-not-found case; that branch now reads `:entity` and
passes it as the referrer. ::not-found keeps `:referrer`, where it really is
the parent.
The new branch is ordered after the tenant-collection and database-not-found
branches, both of which match on causes that a ::load-failure wraps.
::circular is left on the fallback. Rendering its cycle needs an ordered
expansion chain — `expanding` is a set — and it only fires when the
key-stripping recovery has already failed.
* Address CodeRabbit review
- Remove a duplicated `testing` block in impl_test.clj. It was byte-identical
to the one above it, introduced when the deftest was split. Replaced with a
doseq covering trailing, leading, and no surrounding whitespace.
- Mark `missing-reference-message` and `load-failure-message` private; neither
is used outside impl.clj.
- Capture the exception once in bare-import-test's not-found case instead of
running the same failing import twice, and flatten the resulting nested let.
* Fix metabase.metabot.api/openrouter-model-group
Fallback to parsing the id if the display_name has no ":"-separated prefix.
* Add a default OpenRouter model so connect-without-model works
Without an entry in default-llm-metabot-model-by-provider, a PUT /api/metabot/settings that connects openrouter with
only an API key would save the key but silently fail to switch llm-metabot-provider.
* Use real dotted OpenRouter model IDs in defaults and docs
OpenRouter model IDs use dots in version numbers, e.g. anthropic/claude-haiku-4.5. The hyphenated
anthropic/claude-haiku-4-5 referenced by the default args and docstrings does not exist.
* FE: enable openrouter provider in metabot admin UI config
* Whitelist openrouter models
Filter openrouter list-models down to the same curated Anthropic and OpenAI model set the direct providers support,
instead of returning the full ~340-model catalog.
* Throw an exception if callers pass :ai-proxy true for openrouter
Similar to bedrock and openai, supporting this would require work on the ai-service side, and we already reject
non-anthropic providers in validate-metabase-managed-provider!
* Remove explicit credentials check in list-all-models
We want to allow callers to omit the creds and fallback to the configured api key from settings. core/resolve-auth
still handles the auth check and will throw if no credentials are provided or configured.
* Support prompt caching for openrouter
Report `:cacheCreationTokens` and `:cacheReadTokens` in the `:usage`.
For anthropic models, add the `:cache_control` breakpoint on the system message to enable caching (openai caching is
automatic).
* Apply code review suggestion in AIProviderSettingsSection.unit.spec.tsx
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* Add gpt-5.6 and drop gpt-5 from openrouter supported-models
See also #77450
* Apply code review suggestion in fe tests
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
---------
Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
* Move remote-sync branch switching into Settings with guard rails (GHY-4019)
Branch switching ran a full import that hard-deletes any synced-collection
content absent from the target branch, and it lived as a routine app-bar
dropdown item. A forced switch could permanently delete never-pushed content.
Backend:
- spec: detect collection-scoped deletion conflicts (Cards/metrics, Dashboards,
Documents, Timelines), not just the transform family; wire into get-conflicts
- import: expose force_deletion on POST /import so a forced version/dirty
override no longer silently forces deletions
- async-import dirty guard now returns the dirty objects (what would be lost)
- PUT /settings rejects a read-write branch switch (must use the guarded import);
read-only changes and first-time config still allowed
Frontend:
- remove the Switch branch affordance from the app-bar sync controls (branch
name stays as a read-only label; push/pull intact); drop the switch-branch
conflict variant
- add a guarded branch switcher to the read-write Settings panel: destructive
warning copy, hard block when there are unsaved changes (lists them, no force
path), destructive confirm when clean, stale-tab handling
* Refine Settings branch switcher: choice modal, admin gating, direct clean switch
Follow-up to the branch-switching rework, from review feedback:
- Offer a choice modal (push / new branch / discard) when switching with
unsaved changes, instead of a hard block — restores the switch-branch
conflict variant, now driven from the Settings panel BranchSwitcher.
- Drop the destructive confirmation on a clean switch: with everything pushed
the switch is reversible, so it runs directly.
- Show a red "N unsaved change(s)" label beside the dropdown when dirty.
- Gate the switcher on superuser: non-admins (e.g. settings managers) see the
section with an "admin required" message instead of a control they can't use;
the section no longer depends on the admin-only branch setting being readable.
- Match the section subtitle styling to the other settings sections.
- Strengthen the import superuser test to cover a branch switch.
* Update remote-sync E2E for Settings-panel branch switching
Branch switching moved from the app bar to the Settings panel, so the
branching specs and helpers drive it there now:
- Replace the app-bar switch helpers with Settings-panel ones
(visitRemoteSyncSettings, getSettingsBranchSwitcher,
createBranchViaSettings, switchBranchViaSettings).
- Rewrite the branching tests to create/switch branches on the Settings
page, navigating back to the app for content changes.
- Switching with unsaved changes opens the choice modal (wait for the
unsaved-changes indicator first); a clean switch goes directly.
- Snowplow triggered_from: app-bar -> admin-settings.
* cljfmt
* Address review: guard-bypass fix, anti-join, shared removal predicate
- api: block blanking the branch in read-write mode too, so it can't be used to
reset the branch-switch guard and then switch freely; regression test added.
- spec: compute collection deletion conflicts with a SQL anti-join + count/limit
instead of loading every candidate and an exists? per row, so it scales when a
switch would drop a large synced collection.
- Extract spec/removal-where-clauses as the single definition of what an import
reconcile removes for a model; remove-unsynced! and the deletion-conflict check
both build their WHERE from it (removes the duplicate build-entity-id-where-clause).
- Add a parity test asserting the conflict warning is exactly the unsynced subset
of what remove-unsynced! removes.
- Nits: be.visible for the dirty-warning indicator; wrap a long test string.
* Build the deletion-conflict WHERE with a thread-first instead of nested into/conj
* Give check-deletion-conflicts the same anti-join as the collection check
Was doing a t2/exists? per candidate (and materializing every candidate).
Now counts via one SQL anti-join, sharing removal-where-clauses and the new
model-id-column / unsynced-anti-join helpers with check-collection-deletion-conflicts.
Adds a direct spec-level test for it.
* Build collection deletion conflicts with cond-> instead of if/into
* Cover NativeQuerySnippet in the deletion-conflict warning
Generalize check-collection-deletion-conflicts -> check-content-deletion-conflicts:
warn before an import deletes any unsynced entity-id content, not just collection-
scoped models. This pulls in NativeQuerySnippet (global, no collection scope), which
otherwise fell through both conflict checks and could be silently deleted on a forced
import/branch switch. Collection stays excluded (structural; its contents are covered).
Adds a snippet test case.
* Address branch-switch review: drop force_deletion param, refetch dirty at switch, add tests
- Remove the unused force_deletion param from POST /import (schema, destructure,
and async-import! call); internal force-deletion? threading is unchanged.
- BranchSwitcher refetches dirty state at switch time so a stale settings-page
snapshot can't route a switch down the wrong (delete-without-warning) path.
- Add BranchSwitcher unit tests: non-admin message, dirty count label,
clean direct switch, and the choose-what-to-do modal on the dirty path.
* Left-align the branch value in the settings switcher for consistency with other selects
* Show a 'Using <env var>' note when the sync branch is pinned by an env var
* Fix branch-switcher e2e: scroll the switcher into view, drop spurious branch-switched event
The Sync branch section sits below the fold at the default viewport, and Cypress treats an element
clipped by a scrollable ancestor as hidden, so the switcher assertions failed (surfaced by
testing-library as a misleading "Unable to find an element"). Scroll to it before asserting.
BranchSwitcher also reported a branch switch when creating a branch, emitting a spurious
remote_sync_branch_switched alongside the branch picker's remote_sync_branch_created. Creating a
branch forks the current one without a reconcile, so only the creation is reported now.
Introduces a `QUARANTINE_ENGINE` variable that acts as a control knob. Either Trunk enforces a quarantine or CI conductor does. So far wired only in three places:
1. fe
2. be
3. e2e
* Preserve native model column types during serialization (GHY-4043)
export-result-metadata checked (model? card) before the native branch, so
native models were routed through lib/model-preserved-keys — a metadata-merge
soft-key set that omits :base_type/:effective_type. On a read-only Remote Sync
target the column types could not be re-derived without executing the SQL, so
computed columns (e.g. STRING_AGG(...) AS names) fell back to type/* and their
filter UI collapsed to Is empty / Not empty.
Reorder the cond so native cards — models included — go through the branch that
preserves structural type info; only MBQL models, whose types are re-derivable
from the query at import, use the soft-key set.
* Update native-model extract test for preserved column types
extract-result-metadata-native-model-test asserted the old behavior — that a
native model dropped :base_type on export. Update it to expect structural type
info to survive, matching the GHY-4043 fix. Drop the now-unused clojure.set
require.
* Ensure openai reports `:cacheReadTokens` in `:usage`
Add `openai-usage->aisdk-usage` to convert openai usage format to aisdk format and report `:cacheReadTokens`.
* Add undocumented :cache_write_tokens as well
`parse-grant` lowercased role names parsed out of `SHOW GRANTS FOR <user>`.
Those names are fed back into `SHOW GRANTS FOR <user> USING <roles>`, and
MySQL 8 treats backticked role identifiers as case-sensitive, so any role
with uppercase characters made the follow-up query fail with error 3530 and
took the whole metadata sync step down with it.
Lowercasing is still correct for the privilege types in the sibling
`:privileges` branch; it was never right for role identifiers.
Also add the missing space before `USING` in the follow-up query, which
only parsed because the user identifier ends in a backtick.
* Static-viz: dispose ECharts SSR instances + faster SVG dominant-baseline patch
Two isolated, output-preserving fixes to the static-viz render path:
- Dispose the ECharts/zrender SSR instance after renderToSVGString() in every
chart component. These instances were never released, leaking ~MBs per render
and OOMing a long-lived renderer process; memory is flat after.
- Replace patchDominantBaseline's full hast parse+serialize round-trip over the
whole SVG with a targeted string transform (like the sibling
transformSvgForOutline), cutting a large share of big-chart render time with
byte-identical output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Update BoxPlotChart.tsx
* Update ComboChart.tsx
* Update PieChart.tsx
* Update SankeyChart.tsx
* Update ScatterPlot.tsx
* Update TreemapChart.tsx
* Update WaterfallChart.tsx
* Drop hast deps entirely; svg test uses DOMParser
The SVG dominant-baseline patch no longer uses hast, and its test was the last
user of hast-util-from-html. Rewrite the test with the jsdom DOMParser and
remove hast-util-from-html and hast-util-to-html from the deps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix formatting in ComboChart.tsx
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* [Metabot] Migrate BE to v2 message format (#75672)
* migrate codebase to writing v2 format
* move to camelCase to match upstream schema changes
* improvements
* improvements
* correction
* metadata corrections
* handle string outputs correctly
* put add error code to message metadata in finish message
* self-review pass
* record step start to db
* correct issue in claude provider that sent too long of message ids
* record text state to open up future ability to write in progress work
* increase malli usage
* limit client streamed output for tool calls
* emit cache token usage correctly
* ensure started before error is emited
* update migration version
* [Metabot] FE read new message format (#76429)
* initial impl
* self review
* update e2e tests
* copilot feedback
* pr feedback
* fix lint
* feedback - test helper ergonomics
* misc. fixes to metabot chat improvements integration branch (#77321)
* fixes due to stale branches, correct data- prefix not getting recorded, remove unused code
* clean up debug visuals
* ci fixes
* fix couple more tests
* pr feedback
* test: add repro for SDK viz-settings column edits being dropped
Reproduces EMB-2057 — in modular embedding (embed.js), removing a
column via Visualization settings > Add or remove columns silently
no-ops. Fails against the current QuestionSettings.tsx onChange,
which drops the second question argument carrying the query edit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: forward query edits from viz settings in SDK QuestionSettings
QuestionSettings.tsx's onChange dropped the second `question`
argument that ChartSettings forwards for query-level edits (e.g.
add/remove columns), applying only the settings to the stale closure
question and never re-running the query. Column-removal edits landed
in the query object but the table kept rendering the old result.
Forward `nextQuestion` when present and re-run only in that case, so
pure viz-settings-only edits (no query change) keep their existing
no-run behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix code format
* move test to where it belongs
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Currently they are timing out with some regularity in CI. We've done
this same thing to bigquery and redshift already.
Not only will this get us pass/fail results more quickly; it also
reduces the scope of failures so retries don't have to redo as much
wasted work.
* docs: add page for treemap visualizations
* Optimized 1 images, saved 237.8 KB (44.8% smaller)
* fix broken link
* Edits from Jeff
* Edits from Robert
* Edits from Jeff 2
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
Stop running metabase-enterprise.transforms-inspector-test in cloud DBs.
These tests create new tables in the default schema, which is fine for
most drivers, but not for cloud DBs like snowflake and bigquery, because
concurrent CI jobs share the same jobs, which means this will cause test
failures in other unrelated tests which list tables, such as
metabase-enterprise.impersonation.driver-test/resilient-connection-options-test
We need to get these tests to stop creating tables in the default schema,
then we can drop the requirement for the :test/dynamic-dataset-loading feature.
* Replace the group picker in the user form with a multiselect
* Port the tenant group-picker e2e steps to the new multiselect
* Add group-manager controls and a section separator to the multiselect, with chevron and Backspace fixes
* Fix external-default group ordering in the multiselect dropdown, with manager-flag and divider tests
* Update frontend/src/metabase/admin/people/components/GroupsMultiSelect/GroupsMultiSelect.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update remove-button test assertions for the group-specific aria-label
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Serialize static-viz GraalJS context creation (GHY-4077)
Even with the shared-Source code cache (#77180), evaluating the bundle
in a fresh context churns through ~500 MB of allocation. That memory is
transient — allocated and discarded during the eval, with only ~10 MB
retained per extra live context — but it all still has to pass through
the heap. The dirigiste pool grows during render bursts, and nothing
stopped 2-3 context creations from running concurrently: ~1-1.5 GB of
allocation churn stacked on top of in-flight renders, which on a 2 GB
heap outruns the collector and drives GC thrash / OOM.
Serialize creation on a private monitor so at most one generate runs at
a time; waiting acquires just queue on the pool. Also jitter the
10-minute expiry timestamps (+/- 3 min) so pool members created in the
same burst don't all expire and regenerate in the same window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document static-viz pool memory model in namespace docstring
Capture what the OOM investigation measured so the pool's design
choices stay legible: engine + parsed sources ~220 MB retained (paid
once); fresh-context eval ~500 MB of collectable churn with only
~16 MB retained per live context (first-in-JVM cold parse ~1.2 GB);
renders ~1 MB/row transient. Also spell out why creation is serialized
on a monitor rather than time-throttled: bursts genuinely need
multiple contexts, so the pool still grows — just one creation at a
time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Make test stats upload more robust
* Fix trailing whitespace
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix rendering of tenant avatars
* Remove unused Avatar code, cleanup initials logic, more explicit types
* Fix type used in isUser
* Stricter types to avoid initial bug in the future
* Wire embedding tests to ci-conductor reporting
* Add ci-conductor quarantine gate to embedding Cypress jobs
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add withRouteProps facade shim for the router migration
* Convert DatabaseListContainer to a function component on facade hooks
* Put route-prop reference containers on withRouteProps
* Require route props in withRouteProps generic constraint
* Convert ScrollToTop from withRouter to useLocation hook form
* Fix merge fallout: rename RouteProps shim and repoint react-router imports
* Facade re-exports full react-router surface via a single leaf seam
* Re-point all react-router imports onto the metabase/router facade
* Ban react-router imports outside the metabase/router facade (eslint)
* Migrate InviteToViewModal Link import onto metabase/router facade
* Add serialization-dependencies multimethod + Card impl (GHY-4010)
Foundation for export-time dependency validation. serialization-dependencies
runs on a raw appdb entity and returns its references tagged :content (must be
in the export) or :data (database/table/field that must exist in the appdb).
Unlike the load-time `dependencies` multimethod, it operates pre-serialization
and includes tables/fields, which import otherwise creates on the fly.
The Card impl derives content deps from `descendants` (so the two can't drift)
and adds data-model deps from the query via the lib walkers. Not yet wired into
the export pipeline.
* Replace escape analysis with general dependency validation (GHY-4010)
extract-subtrees now runs a dry-run pass (collect-dependencies) that shares the
real export's extract queries but neither serializes nor writes. For every entity
that will be extracted it derives serialization-dependencies and checks them:
content references must be in the visited/extracted set (or be analytics cards),
and data-model references (database/table/field) must exist in the appdb. Any
unsatisfied reference aborts the export (unless continue-on-error), before any
archive is produced.
This generalizes part A's Card-only, collection-id escape analysis: it now also
catches non-Card content escapees and dangling data-model references (a deleted
table/field that would otherwise serialize to a malformed [nil nil nil] ref).
Adds a Dashboard serialization-dependencies impl. Tests not yet updated.
* Update serialization tests for dependency validation (GHY-4010)
The dependency-validation warnings replace the old "Failed to export
Dashboard/Cards ... outside requested collections" messages with a uniform
"<entity> references <model> <id> which is not included in the export /
is missing from the source database" form, so the escape tests now assert
on the new wording. Adds dependency-validation-missing-data-ref-test, which
covers the new data-model case: a card referencing a deleted field aborts the
export (and continue-on-error still exports the rest).
* Fold dependency collection over a monoid, with batched queries (GHY-4010)
Replaces the nested reduce in collect-dependencies with a {:visited :deps}
monoid: entity-deps maps one entity to its contribution, merge-deps is the
associative combine (0/1/2-arity, usable directly as a transduce reducing fn),
and collect-dependencies folds every entity into the whole via a single
transduce. content-id-batches yields [model id-batch] pairs so each extract
query's :in :id clause stays bounded (query-batch-size 1000), and the data-dep
existence check is batched the same way. Behavior is unchanged.
* Flatten dependency collection into four transducer stages (GHY-4010)
entity-deps now reads its model off the entity (extract-query returns modeled
rows) instead of taking it as an argument, so collect-dependencies becomes a
flat pipeline: filter content models, resize into id batches, mapcat each batch
through extract-query into an entity stream, then map entity-deps — no eduction
nested inside the mapcat. Behavior unchanged.
* Move dependency validation into its own namespace (GHY-4010)
Extract the export-time dependency-satisfaction check out of
serialization.v2.extract into serialization.v2.dependency-validation. extract
now just calls dependency-validation/validate-dependencies! and keeps ownership
of the analytics-card set, which it passes in. The moved code carries this
session's cleanups (map-kv-vals for the existence lookup, set-based :deps).
Tests move to a matching dependency-validation-test namespace; the escape
assertion embedded in selective-serialization-basic-test stays with extract as
an extraction-level integration check.
* Address review: drop :kind, transients, cover segment/measure (GHY-4010)
Alex's PR comments on the raw dependency validation:
- serialization-dependencies no longer tags deps with :kind; it returns plain
{:model :id} and the validator classifies each by model (serdes.models
content vs data-model), since serdes already knows which is which.
- collect-dependencies accumulates into transient sets — an export can walk a
lot of entities.
- Card serialization-dependencies now covers every reference in the query,
including :segment and :measure (both data-model, existence-checked), via the
lib walkers, closing the gap the field/table/card-only version had.
Refs embedded in parameter_mappings / visualization_settings / result_metadata
still aren't covered, and the raw serialization walker still duplicates the
serialized *-deps knowledge; both are tracked for the follow-up unification.
* Cover result_metadata and visualization_settings field refs (GHY-4010)
serialization-dependencies "Card" now gathers Field references from every
routine-backed source, not just the query:
- query: lib walkers (fields, tables, segments, measures, template-tag fields)
- result_metadata: each column's :id
- visualization_settings: the JSON-encoded column-setting keys, parsed with
mb.viz/db->norm (the same routine the dependency-analysis project uses)
New test covers a field referenced only in visualization_settings. Field refs
buried in parameter_mappings / parameters value-fields are bare [:field ...]
clauses the lib walkers reject; those remain for the dep-walker unification.
* Rename `dependencies` multimethod to `deserialization-dependencies`
Makes the load-time dep multimethod parallel with the export-time
`serialization-dependencies`. `dependencies` runs on the serialized/ingested
form for load ordering and omits tables/fields (import synthesizes them);
`serialization-dependencies` runs on the raw entity and includes them. The
name now says which is which.
Mechanical rename of the defmulti + default + 19 impls across 18 models + 3
callers (load, metabot, remote-sync) + tests + doc-links. No behavior change.
* Address CodeRabbit: batch analytics lookup, fix test targeting (GHY-4010)
- analytics-card-ids batches the card-id :in via a partition-all transducer, so
a large closure doesn't exceed database parameter limits (matches the batching
already used in dependency collection).
- escape-report-test's ":parameters" case targeted coll2 (re-testing the
source-table card); target coll3 so it actually exercises the parameter card.
- Correct the continue-on-error test wording: it suppresses the abort but does
not skip the affected card at export (the malformed card is skipped on import).
* Unify serialization/deserialization dependency walkers (GHY-4010)
Make the mbql-deps walker family format-aware so a single traversal handles
both raw (numeric ids, export/serialization) and serialized (portable refs,
load/deserialization) forms, gated by *serialization?* / raw-ref-id?. Card and
Dashboard now share one deps helper across both dependency multimethods.
Implement serialization-dependencies for the remaining content models
(Action, Collection, Document, NativeQuerySnippet, Timeline), computing deps
from the raw entity: Action fetches its query_action child row; Collection
derives its parent from the raw :location path.
Refine export-time validation: a content reference to a deleted entity is not a
failure (export can't emit a portable id for a missing row, so fk-elide drops
it), and structural Collection references are ignored (a selective export may
omit the containing collection tree).
Tests: mbql-deps format-parity test, direct per-model serialization-dependencies
coverage, and the full baseline load->extract round-trip over every model.
* Trim duplicated dependency docstrings and consolidate abort tests (GHY-4010)
Dedupe the "content refs must be in export / data-model refs must exist"
explanation that appeared in four docstrings, and collapse the three
near-identical "delete a data-model ref -> export aborts" tests into one
parameterized test over Field/Segment/viz-settings references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Share query-batch-size constant; use pos-int? for raw ref ids (GHY-4010)
Hoist the `:in`-clause batch size into a single serdes/query-batch-size
constant instead of a private def plus a bare 1000 literal in extract, and
tighten raw-ref-id? to pos-int? (entity ids are always positive).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Replace transient accumulator with a named merge-entity-deps monoid (GHY-4010)
The set-building is not the hot path (batched DB queries and the mbql walk
dominate), so drop the transients for a plain `merge-with into` monoid,
extracted as `merge-entity-deps` — the same idiom as u/traverse and
remote-sync's merge-incremental-export-plans.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fold partition-all into the existing-ids transducer (GHY-4010)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fold distinct into the existing-ids transducer too (GHY-4010)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Use peek and set-as-predicate in dependency validation (GHY-4010)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* SPIKE: thread allow-int-ids? param instead of *serialization?* dyn var (GHY-4010)
Replaces the *serialization?* dynamic var with an explicit allow-int-ids?
parameter threaded through the mbql walkers (mbql-deps/-map/-vector,
parameters-deps, visualization-settings-deps, viz-link-card-deps) and the
shared model helpers (card-deps, dashboard-deps, serdes-deps-dashcard,
result-metadata-deps). The name states the behavior: integer ids count as
references only when set (raw appdb entities), never on the serialized/load
form where a stray numeric must be ignored.
The parameter is mandatory everywhere — no default arities. Load-side callers
(measure/segment/transform/action deserialization impls) pass false; the three
serialization-dependencies impls pass true.
Spike to compare against the dyn-var approach; drop if we prefer that.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix cljfmt: align card-deps destructuring continuation lines (GHY-4010)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Don't compute related-tables or metrics in get-report-details
`get-report-details` drops `:related_tables` and `:metrics` via a `select-keys`, so don't ask `card-details` for them.
* Migrate remaining color tokens to new names (GDGT-2535)
Migrate the remaining legacy design-system color tokens to their new
names per the GDGT-2517 migration guide:
- feedback: error/danger -> feedback-negative, success ->
feedback-positive, warning/gold -> feedback-warning
- misc/core: info -> core-info, white -> core-white, shadow ->
shadow-default, overlay -> modal-overlay, focus -> input-focus,
metabase-brand -> core-metabase_brand
- admin: admin-navbar* -> navbar-admin*
- upsell/illustration: per guide
- remove unused bronze/copper/silver
Covers all reference forms: CSS `var(--mb-color-*)`, `color()`/helper
calls, Mantine `c`/`bg`/`color`/`iconColor` props (string and ternary),
object-literal `color:`/`iconColor:` keys, `ColorName` return values,
arrays, and `Record<_, ColorName>` value maps. Tightens `Undo.toastColor`
from `string` to `ColorName` so future token removals fail at compile
time. Maps the `Alert` component's semantic `color` prop to feedback
tokens internally so its call sites stay unchanged.
Also sweeps up brand/filter/summarize consumer stragglers left over from
the earlier (merged) migration. SDK/whitelabel input keys, the
derive-theme alias definitions, and static-viz's local palette are left
on their existing names by design.
* Migrate missed success-secondary props in StepperWithCards
The .module.css was migrated to feedback-positive-selected but the two
inline c="success-secondary" props (Done check icon + label) were missed.
* Update Loki Snapshots
* Fix toastColor type widening in metabot toast-prop helpers
Undo.toastColor tightened to ColorName in this branch; the two get*ToastProps
helpers returned object literals where toastColor widened to string (missing
as const, unlike their iconColor/dismissIconColor siblings).
* Update TransformListPage sync-color spec to migrated feedback token names
utils.ts color map was migrated (success/warning/danger -> feedback-*) but
the spec assertions still expected the old names.
* Update SDK theme + treemap specs for feedback/misc token mirrors
- get-embedding-theme: SDK mapping now fans text-white->core-white and
focus->input-focus, so the expected override gains those keys.
- treemap option: getColor("white") became getColor("core-white"); add
core-white to the test's MOCK_COLORS palette.
* Update Loki Snapshots
* Update toast color-attr assertions to migrated feedback token names
* Cleanup
* Migrate remaining color tokens in Button and Table stories
* Add explainer comment for colors naming convention
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* fix: Toaster crash on long-running questions from color-mix tokens
The Toaster's styled-components computed colors at render time with the
JS `color` library (via the deprecated lighten()/alpha() helpers). One
of the tokens they parsed, `background_page-tertiary-inverse`, is now
defined as a color-mix() value, which the `color` library can't parse —
so it threw "Unable to parse color from string" and crashed.
Replace those runtime helper calls with CSS color-mix on the variables
directly, so the mixing happens in CSS where color-mix()/var() resolve.
* Adjust dismiss color
* Add react-router v7 as a test-only aliased devDependency
* Add differential conformance harness for the router facade
* Add v7 conformance test for useLocation
* Add v7 conformance test for useParams
* Add v7 conformance test for useNavigate
* Add v7 conformance test for useSearchParams
* Add v7 conformance test for Navigate
* Add v7 conformance test for useSearchParams defaultInit
* Pin react-router v7 to 7.18.1 for conformance
* Add type-level conformance tests against react-router v7
* Guarantee cleanup between facade and v7 renders in runBoth
* Migrate ChartSettingsSidebar unit test to TypeScript
* Migrate DatasetMetadataStrengthIndicator unit test to TypeScript
* Migrate QuestionDataSource unit test to TypeScript
* Migrate ViewTitleHeader unit test to TypeScript
* Correct onSave return type to Promise<void> and relax Toaster className
* Migrate query builder View components to TypeScript
* Preserve collection type on move and tighten timeline callback types
* Remove dead onCloseQuestionInfo prop from structured sidebar
`enhance-context-with-schema` resolved each `:user_is_viewing` item's used tables and then ran them through
`table-utils/enhanced-database-tables`, which fetched and formatted the full visible-columns set for every used table, including all implicitly-joinable columns from related tables. That work was discarded anyway since columns were recalculated via `entity-details/get-table-details` when rendering the context. On large, highly-connected schemas this could load thousands of columns into the request metadata cache and exhausted the heap (metabase#76493).
`:used_tables` entries now carry only `{:id :type :name :database_schema :description}`. name/schema/description are kept for the `simple-entity` error fallback. `enhanced-database-tables` had no other callers, so it is removed.
Fixes UXW-4704.
Support emoji in PDF rendering - in card titles, text cards, and the dashboard title.
These are monochrome only, since monochrome emoji are regular font glyphs. Colour emoji requires unusual bitmap-backed font support, and the PDFBox engine doesn't support that. It could be done, but it would require rendering the emoji glyphs to PNGs, then embedding those in the PDF. It's not worth the effort or including a bulky bitmap emoji font for a cosmetic feature.
This includes the Noto Emoji font in the JAR, and adds the Open Font License notice file covering all the PDF fonts.
The "Transforms" group header in the push/pull changes list is backed by a
virtual collection with the sentinel id -1 (TRANSFORMS_ROOT_ID). Every -1 case
is special-cased in displayGroups.ts except the header breadcrumb, so the
sentinel flowed into collectionUrl({ id: -1 }) and produced a dead
/collection/-1-transforms link (a hyphen at the start of the last path segment).
Link the transforms-root segment to the transforms list (/data-studio/transforms)
instead. Covers both the virtual-root group and the transforms-namespace prefix
on sub-collections, since the guard sits at the single render site.
* Whitelist bedrock models
* Whitelist anthropic models
* Add claude-fable-5 to supported-models for bedrock and claude providers
* Include a display_name for all supported-models
* Filter to available-model? in bedrock's list-models
Snippet template-tag ids were regenerated on every serdes import: YAML
ingest keywordizes the tag-name keys, so the NativeQuerySnippet load hook
could not match them to preserve the existing :id and minted a fresh UUID
instead. In a remote-sync import/export loop this produced a spurious id
diff for every snippet with variables on every cycle.
Normalize :template_tags on import in make-spec so the tag-name keys are
strings before the model hooks run; export is unchanged. Add a round-trip
regression test asserting template-tag ids survive export/import.
* [GHY-3974] Add failing tests for Usage Analytics upgrade failure modes
Two invariant tests covering the audit-DB upgrade breakage, asserted
through ensure-audit-db-installed! so they're independent of the fix
mechanism and leave the generic sync path untouched:
- Mode A (audit-db-self-heals-duplicate-rows-from-stale-schema-test):
an interrupted host-adjust + schema sync duplicates a v_* view row and
retires the original, breaking customer content. ensure-audit-db-installed!
must reconcile back to one active row per view and keep dependent content
on an active table.
- Mode B (checksum-not-advanced-until-host-adjust-completes-test):
last-analytics-checksum must not advance if adjust-audit-db-to-host! is
interrupted, so the next boot re-runs the load and the host-adjust.
Both fail on master.
* [GHY-3974] Fix Usage Analytics upgrade failure modes
Mode B (checksum committed before host-adjust): in maybe-load-analytics-content!
advance last-analytics-checksum only after adjust-audit-db-to-host! completes,
so an interrupted host-adjust leaves the checksum behind and the next boot
re-runs the load + adjust. Also force a reload, regardless of the checksum, when
audit tables are stuck at the postgres "source" schema on a non-postgres host
(audit-db-in-source-state?) so already-corrupted instances self-heal.
Mode A (schema-renamed view duplicated + retired by sync): add
reconcile-audit-db-duplicates!, run on every ensure-audit-db-installed! so
already-corrupted instances (whose checksum already matches, skipping the load)
still recover. For each duplicate same-name group in the audit DB it keeps the
row dependent content points at, repoints stray report_card.table_id refs to it,
normalizes it to the host-canonical name/schema, and deletes the orphans. Schema
is derived from the host engine, not the surviving row, so a duplicate left at
the postgres source schema (Mode B masking Mode A) is corrected too. Scoped
entirely to the audit DB; the generic sync path is untouched.
Both audit-test regression tests pass on h2 and postgres.
* [GHY-3974] Serialize audit DB load/adjust/sync across nodes (1b)
with-duplicate-ops-prevented is per-process only, so on a rolling upgrade
two pods could run the audit load/adjust/sync concurrently and produce the
half-applied schema states behind both failure modes. Wrap the
load+adjust+sync+reconcile in an app-DB cluster lock (metabase_cluster_lock)
so only one node runs it at a time. If another node already holds the lock it
is doing the same work, so skip rather than fail the boot.
The sync must run inside the lock to be serialized, so maybe-sync-audit-db!
now runs synchronously instead of in a background future: on a transactional
appdb a sync on another thread would escape the lock's transaction and
deadlock against its uncommitted metabase_table writes.
Full audit-app namespace passes on h2 (in-process RW lock) and postgres
(transaction + SELECT FOR UPDATE), 52 assertions each.
* [GHY-3974] Filter audit view lookups in SQL, not in memory
audit-view-table and the Mode A heal assertion were selecting every active
audit table and filtering by lower(name) in Clojure. Push the predicate into
the query with a lower(name) where clause.
* Better comments
* [GHY-3974] Don't force a reload for an inactive public orphan (MySQL fix)
audit-db-in-source-state? treated any `public`-schema audit table on a
non-postgres host as the interrupted-host-adjust state and forced a reload.
But a Mode A duplicate leaves an *inactive* `public` orphan, and on MySQL
(canonical schema NULL) forcing a reload made adjust-audit-db-to-source!
move the active NULL row to `public`, colliding with the orphan on the
(db_id, schema, name) unique index (Duplicate entry '…-public-v_content').
Only active rows signal the genuine stuck source state; inactive public rows
are retired orphans handled by reconcile-audit-db-duplicates!. Restrict the
check to active rows. Surfaced by the MySQL/MariaDB app-db CI run of
audit-db-self-heals-duplicate-rows-from-stale-schema-test.
* [GHY-3974] Update modules config: audit-app no longer uses config
Removing the metabase.config.core require from the audit-app module (the
unused config dep dropped when the sync future was removed) leaves config
out of enterprise/audit-app :uses. Fixes modules-config-up-to-date-test.
* [GHY-3974] Only swallow lock-contention failures for our own audit lock
A different cluster lock's acquisition failure raised from inside the locked
body (e.g. the permissions lock taken when serdes inserts a Database) also
carries :lock-names; it must propagate rather than be swallowed as audit
contention and silently abandon the load.
* [GHY-3974] Bail faster on audit lock contention
Drop the lock-acquisition timeout to 5s and retries to 2, so a node that loses
the boot-time audit lock skips in ~17s instead of ~150s. The work is idempotent
and the lock holder is doing it, so a losing node need not win; keeping a couple
retries still lets a node pick up the work if the holder crashes mid-load.
* [GHY-3974] Serialize audit DB install/adjust under the cluster lock too
The install-time adjust-audit-db-to-host! + schema sync (the ::installed and
::updated branches of maybe-install-audit-db!) previously ran outside the
cluster lock, so two nodes detecting an engine mismatch could adjust and sync
concurrently against a half-adjusted schema. Move install inside the lock so the
whole install+adjust+load+sync+reconcile is serialized across nodes; a node that
acquires the lock after the installer commits sees the DB already exists and
no-ops instead of colliding on the audit DB primary key.
* [GHY-3974] Harden reconcile-audit-db-duplicates! and Mode B test
- Sort rows by id before grouping so survivor selection (including the
referenced? tiebreak) is deterministic regardless of appdb row order.
- Clear is_defective_duplicate on the survivor so it re-enters idx_unique_table
(a flagged row has a NULL unique_table_helper and is excluded from the index).
- Comment why orphans are deleted before the survivor is re-canonicalized.
- Mode B test: move cleanup into a finally and reinstall the audit DB so an
unexpected throw can't leave sibling tests without an audit DB.
* [GHY-3974] Make reconcile lossless: move content off orphans before deleting
The previous reconcile did (t2/update! :model/Card {:table_id orphan} {:table_id survivor})
then deleted the orphan. That update is a no-op — Card's before-update populate-query-fields
re-derives table_id from the unchanged dataset_query — and report_card.table_id is ON DELETE
CASCADE, so deleting the orphan destroyed any customer card whose query pointed at it. This
happens in real Mode A: bundled audit cards reference the retired row (making it the survivor)
while a customer question built during the corrupted window references the freshly-synced active
row (the deleted orphan).
Now repoint-cards-to-survivor! rewrites each referencing card's dataset_query (source-table)
and result_metadata, remapping field ids onto the survivor's same-named fields, before the
orphan is deleted. Handles both pMBQL and legacy field refs.
Adds audit-db-reconcile-preserves-content-on-the-deleted-duplicate-test, which asserts the card
survives and its field refs resolve to active fields on the survivor (fails on the old code).
* [GHY-3974] Use lib field-ref helpers instead of hand-indexing refs
repoint-refs now reads/writes the field id via lib/field-ref-id and
lib/with-field-ref-id rather than indexing the ref vector by position. Expose
with-field-ref-id from metabase.lib.core (the setter alongside the already-public
field-ref-id getter) so it's reachable through the lib module's public API.
* [GHY-3974] Use lib clause predicate / field-ref-id for remaining MBQL access
Replace the hand-rolled field-ref? predicate with lib/clause-of-type?, and the
positional field-id extraction in the test with lib/field-ref-id.
* better docstring
* [GHY-3974] Order reconcile rows in the query instead of in memory
Address review: let the DB sort (:order-by [[:id :asc]]) rather than sort-by; group-by
preserves the order within each group, keeping the survivor tiebreak deterministic.
* [GHY-3974] Move query id-remap into lib; handle result-metadata thoroughly
Review (@metamben): the ref manipulation should live in lib and be general-purpose,
and queries vs result metadata shouldn't share one generic walker.
- Add metabase.lib.walk.util/replace-field-ids and replace-table-ids (general, built on
lib.walk): field refs everywhere via walk-clauses (filters/aggregations/breakouts/joins/
nested stages), :source-field/:fk-field-id opts, native :template-tags :dimension refs, and
:source-table per stage/join. Exposed via metabase.lib.core.
- audit-app repoint-cards-to-survivor! now rewrites dataset_query via the lib helpers, and
handles result_metadata separately (non-generic): :id, :fk_target_field_id, and :field_ref
covering both legacy ([:field id opts]) and pMBQL ([:field opts id]) shapes.
- Test asserts legacy result_metadata :field_ref is remapped onto the survivor.
Covers review threads: field-ref manipulation in lib (#383), FK/source-field refs (#418),
legacy result-metadata refs (#392).
* [GHY-3974] Batch the reconcile referenced? check into one query
Address review: instead of a t2/exists? per row in a duplicate group, fetch the set of
card-referenced table_ids for the whole group in a single query and test membership.
* [GHY-3974] Review cleanups: cond-> in replace-field-ids; update-existing in result-metadata remap
- lib replace-field-ids: use cond-> for the template-tags/:dimension updates instead of if/else.
- audit remap-result-metadata: use m/update-existing with an identity-for-unmapped fn instead of
a cond-> with duplicate lookups; drop the redundant pos-int? guard in remap-result-metadata-ref
(field-id-remap is always a map, so it already returns nil for names/nil).
Fixes UXW-3770.
The default limit of 50 levels of nesting is too small for deeply nested
MBQL expressions, such as a `replace(replace(replace(...)))` tower.
This PR raises the limit to 10k, and includes a test that 500 levels
parses fine. 10k works at my REPL but I don't want to commit a test like
that in case other JVMs hit a `StackOverflowError`. Rejecting the import
is a better UX than crashing Metabase!
Fixes UXW-4108.
When no `database`, `dashboard` or `question` parameter is supplied, the
endpoint does not invalidate anything. It previously returned a 404 in
that case, indicating that nothing was found to invalidate.
But since no attempt to invalidate anything is made without at least one
of those parameters, the 404 was misleading. Instead, this malformed
request should return a 400, which it now does.
No impact to the Metabase FE, which only ever calls this endpoint with
the parameters supplied.
* Add an Invite someone to view this action to dashboard and question share menus (#76209)
* Add an Invite someone to view this action to dashboard and question Share menus
* Update admin create-user e2e to reveal the temporary password via the new toggle
* Add a shareable item link to the invite modal's no-email states
* Remove the stale password-toggle aria-label and reword the temp-password invite copy
* Land invited users on the shared dashboard or question after signup (#76505)
* Land invited users on the shared dashboard or question after signup
* Scope the invite email to the shared dashboard or question
* Code cleanup
* Polish the invite email's join button spacing, label, and padding
* Remove stale TODO about synchronous invite email
* Add tests for invite someone to view (unit + e2e) (#76589)
* Add unit and e2e tests for the invite-to-view feature
* Fix the invite-to-view e2e signup button and no-access selectors
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* Add user_invited and invite_to_view_opened analytics events (#76646)
* Add user_invited and invite_to_view_opened analytics events
* Reword the external-invite tracking comment in NewUserModal
* Type isEmailAlreadyInUse's error with a guard instead of a cast
* Return an error_code for the duplicate-email 400 and key isEmailAlreadyInUse off it
* Move invite-events analytics into common/analytics and assert events fire once
---------
Co-authored-by: Nathan Voxland <nathan@metabase.com>
* fix(custom-viz): allow map tile server host in img-src CSP
When Custom Visualizations are enabled, csp-img-enabled is forced on,
switching img-src from a permissive wildcard to a restricted allowlist.
Map tiles (OpenStreetMap by default, or a custom map-tile-server-url)
were never added to that allowlist, so map visualizations broke.
Automatically add the configured tile server's origin to img-src,
mirroring how custom font files are added to font-src. The {s} subdomain
placeholder becomes a * wildcard and the {z}/{x}/{y} path is dropped.
* docs: note map tile server is always allowed under restricted image domains
* Missing update to setting description
* Update comment
* Fix whitespace
* Rename router useRouter to kebab-case use-router
* Add router facade shared types and path/search utils
* Add useNavigate facade hook
* Add useLocation facade hook
* Add useParams facade hook
* Add useSearchParams facade hook
* Add Navigate facade component
* Add Outlet facade shim
* Add Link facade re-export
* Reference react-router v7 docs from each facade helper
* Lock facade tests to react-router v7 behaviour
* Import useRouter from the metabase/router barrel
* Sort metabase/router barrel exports
* Test useNavigate splits query from a string destination with state
* Test setSearchParams clears the query when called with no argument
* Use router/<name> pattern for facade test describe blocks
* Link react-router v7 API reference from facade types
* Use react-router's official descriptions for facade type comments
* Use useMount instead of a bare useEffect in Navigate
* Expand inline JSDoc in router utils to multiline
* Implement useNavigate over v3 history instead of redux dispatch
* Navigate via react-router's router API instead of history directly
* Re-navigate on prop change in Navigate to match v7
* Default useLocation state to null and note v3 helper limitations
* Pass Navigate state by reference and clone search-param updater arg
* Expose v3 splat param under v7's * key in useParams
* Drop the existing hash in setSearchParams to match v7
* Churn useNavigate identity per navigation to match v7
* Support defaultInit in useSearchParams to match v7
* Match v7's URLSearchParamsInit pair-tuple type
* Match v7's useParams generic signature
The work done in #76355 and #76504 to fix some OOMs while generating
subscriptions modified the core rendering pipeline (static viz charts to
PNGs) concurrently with the work happening on PDFs.
This adapts the PDF generation to reuse both in-memory and on-disk query
results, and to use the whole-dashboard budget of results in memory before
spilling to disk. Cleans up all temporary files when finished.
Take existing tables into account when determining already-loaded bigquery.
We no longer have bigquery tables that constantly think that they need
to be recreated, but we have fallen a little too far in the other
direction; datasets that get their DB created but fail midway thru
will still be treated as loaded.
In snowflake we have a way of checking this down to the individual row
counts and counting as not-already-loaded if there's a mismatch. I
don't see a way to do this in bigquery, but we can at least check that
all the tables exist. If we get a failed partial load that gets all
the tables created but fails the rows that will still require manual
intervention.
Also stop using (config/current-major-version) when qualifying dataset
names; turns out this is not at all reliable in CI. This unfortunately
means that we don't actually have cross-major-version isolation; the
best we can do is isolate master/release from all other branches,
which is still an improvement over what we have now.
`get-day-of-week` accepts a positional week-mode, and the ::week-mode
schema admits :iso, :us, and :instance (shared with get-week). But only
the default (no-mode) and :iso forms have a lowering in lib.filter.desugar
— :us and :instance desugar to a nil temporal-extract unit and 500 at SQL
compile time (observed: an LLM emitting "instance" for "day of week on
weekends"). Drop the unsupported mode, leaving the start-of-week-aware
default. :iso and the bare form are untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prompt-snippet updates, independent of any code change:
- discovery: search to choose a source, navigate to drill in (don't skip
the curated layer just because a raw table name is recognizable)
- data-sources: a source the user explicitly names is authoritative
- grounding: when several columns plausibly match, read descriptions and
sample values rather than picking by name resemblance
- construct_notebook_query: a "translating the request" section (constraint
vs breakout, apply every constraint, absolute vs relative dates)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(custom-viz): make dev hot-reload resilient to transient failures
When the dev server is mid-rebuild its bundle endpoint can transiently
fail, and rapid edits / overlapping reloads could leave the viz showing a
stale bundle or a spurious "unavailable" toast.
- Backend: fetch-dev-bundle returns nil on upstream failure (mirroring
fetch-dev-manifest) so the endpoint serves its existing retryable 503
instead of a hard 500.
- Frontend: retry the dev bundle fetch on transient 5xx (fail fast on
4xx), and tag each load with a monotonic sequence so a slower/older
overlapping load can't overwrite a newer applied bundle or fire a stale
toast (latest-wins).
* fix(custom-viz): render the freshest bundle after overlapping dev reloads
Overlapping dev reloads (rapid edits) could apply the newest bundle to the
visualizations registry but leave the chart rendering an older one: the
visualization only re-reads the registry when it remounts on the loading
toggle, and a plain boolean cleared after the first load resolved.
- Refcount in-flight loads so `loading` stays true until the last settles,
so the single remount reads the freshest registered bundle.
- Stop retrying a load once a newer one has started (it would be discarded
by the latest-wins guard anyway, and would keep `loading` held).
* fix(e2e): restore custom-viz dev plugin source between retries
The development-mode custom-viz test mutates the scaffolded plugin
source in place ("Above threshold" -> "Way above!") to verify hot
reload, but never reverts it. Since `before` does not re-run on a
Cypress retry, a retried attempt started from the mutated file and the
opening "Above threshold" assertion timed out against stale state.
Capture the pristine source in `before` (after the clean re-scaffold)
and rewrite it in `afterEach` so each attempt starts from the default.
* fix(e2e): use frozen lockfile for custom-viz SDK bun install
Skip lockfile resolution during the dev-mode `before` hook so the
install step is deterministic and faster, reducing the chance of the
120s setup timeout.
* fix(e2e): start custom-viz dev server per test, not per suite
The dev-mode test stops the dev server as its final assertion (revert to
default), but the server was started once in `before`. Since `before`
does not re-run on a Cypress retry or during burn-in, every run after the
first hit a dead server and failed at "verify the dev plugin is
registered".
Move the server start to `beforeEach` and the stop to `afterEach` (the
stop task is idempotent), keeping the heavy build/scaffold/install in
`before`.
* chore(custom-viz): trim verbose comments per review
* refactor(custom-viz): reuse retry util for dev bundle fetch
* fix(custom-viz): use > not >= in superseded check
The == case is impossible (loadAppliedSeq is only set to a load's own seq
on success, which returns before the catch; seqs are unique), so >= and >
are equivalent here. > matches the sibling loadStartedSeq check and reads
as "a strictly newer load exists".
* fix(custom-viz): only the latest-started load registers its bundle
The apply-guard bailed only when a newer load had already applied; bail
when a newer load has merely started too (applying implies starting, so
this subsumes the old check). Reuses isLatest(), consistent with the
retry loop and the superseded check.
* refactor(custom-viz): name load-sequence maps by plugin id
* test(custom-viz): scaffold dev plugin per attempt instead of capturing source
Move the plugin scaffold (init + npm i) into beforeEach so each attempt
starts from a pristine source, dropping the pristineSrc capture/restore.
The expensive SDK build stays one-time in before.
* fix(embedding-sdk): gate query builder controls on create-queries permission
Filter/Summarize/Breakout dropdowns and the notebook editor toggle rendered
regardless of query permissions, showing non-working controls for users
without create-queries access. Mirrors main app's FilterHeaderButton/
QuestionSummarizeWidget/QuestionNotebookButton gating (isEditable, !isNative,
!isArchived) via a new shouldRenderQueryBuilderEditingControl.
Fixes EMB-1963
* test(embedding-sdk): unit test shouldRenderQueryBuilderEditingControl
Covers editable/native/archived/no-question cases, plus the missing
create-queries case by building a query against metadata with the source
table excluded (mirrors what the backend does when that permission is
missing) rather than mocking metabase-lib.
* fix(embedding-sdk): hide decorative icon from ToolbarButton accessible name
Icon set role="img" aria-label="<name> icon" unconditionally, so a button's
accessible name became "filter icon Filter" instead of just "Filter",
breaking cy.button()/getByRole name lookups.
* test(embedding-sdk): e2e test for query builder permission gating
Confirms Filter/Summarize/notebook editor toggle are hidden for a user
without create-queries permission on the underlying table, and shown for
one with it, using existing readonly/readonlynosql fixture users whose
default group permissions already match each scenario.
Fixes EMB-1963
* docs(embedding-sdk): tighten comments, scope test fixtures per-test
Bullet the three omitted main-app gating conditions instead of run-on
prose, shorten the missing-permission test comment, and move each
question fixture into the test that uses it instead of module-level
constants shared across unrelated tests.
* fix(embedding-sdk): guard BreakoutDropdown on missing question
Matches the !question || !shouldRenderQueryBuilderEditingControl(question)
pattern used by FilterDropdown and EditorButton.
* Let fetchDashboard accept a prefetched dashboard
(cherry picked from commit 63425d8754777a96f023c3be6028c5a45b4607d5)
* Use save response to refresh dashboard instead of refetching
(cherry picked from commit 8f5aff044beb8290f9c63f7ebb041ffbe8c2b392)
* Test save reuses response without refetching the dashboard
(cherry picked from commit a90b2885f7ac19ed472bed1018a88fa29fee60be)
* Update e2e save helper for dropped post-save dashboard refetch
(cherry picked from commit 41bba584ae41640903c06fcd76022a1569d6f087)
* Update e2e dashboard reload expectations for save-response reuse
(cherry picked from commit 97fabf4f8df7d0c1345ea33a6136a1c3ba76933f)
* Wait on dashboard UI instead of phantom reload when returning from drill
(cherry picked from commit 19181e1345af6af1fe46f323c3b0362a5286e1b4)
* Add waitForDashcardsToLoad e2e helper and use it in #32444 test
(cherry picked from commit fa3a0e989f2166f50af10a0294fcda9bfef968d9)
* Settle saveDashboard on dashcards loaded instead of fixed sleep
(cherry picked from commit b7a2e157b6db12a2f4f8ab10b7c19f68437b94ed)
* Drop redundant saveDashboard waitMs args now that it settles deterministically
(cherry picked from commit e8827b06955f581dde739491d3aef2bee5f90be9)
* Drop redundant post-save sleep now covered by saveDashboard settle
(cherry picked from commit c3ae1c65997165fffc0fa1c394d1d2765b4fb2b8)
* Drop tombstone comments per review
* Make waitForDashcardsToLoad tolerant of dashboards without a grid
* Anchor dashcard settle on dashboard body to avoid early return
* Revert broad saveDashboard auto-settle; keep save-reuse and opt-in waitForDashcardsToLoad
* Settle saveDashboard on dashcards loaded + grid layout stable; drop waitMs sleeps
* Wait for filter widgets to settle after save (parameter panel re-render)
* Make editDashboard resilient to a dropped Edit click after save
* Make editDashboard retry work inside cy.within (use cy.document not cy.get body)
* auto stress test
* better options
* fix(e2e): restore 30s timeout on findByText for impersonated permission test
The timeout was removed in a8cc638d2f when updating the error message text.
The impersonated write path takes 15-20s, far exceeding the default 4s timeout,
causing consistent test failures (quarantined for 33 days).
---------
Co-authored-by: Ryan Laurie <iethree@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
* Fix flaky pivot-table dashboard filter spec: wait for dashcard load before opening question
The "does not use extra filtering stage for pivot tables" spec clicked the pivot
dashcard's legend title immediately after exiting edit mode. The title's
navigation handler is only wired once the card series has reloaded, so an early
click was a no-op and the /api/dataset/pivot request never fired, timing out
cy.wait("@datasetPivot"). Wait for the dashcard to finish loading (visible title
+ no loading indicator) before clicking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Give pivot-table dashboard filter spec a throttle-tolerant pivot wait
Clicking the pivot dashcard title navigates to an ad-hoc QB that must boot
the route, load table metadata, and only then issue /api/dataset/pivot. Under
network throttling that chain can exceed cy.wait's default 5s timeout, so the
wait occasionally fails even though the request does fire moments later. Wait
on the @datasetPivot alias with a throttle-tolerant timeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WIP: support for merge strategy via delete + insert
* PoC FE
* fix
* overridable method
* support for py
* refactor
* FE
* changelog
* FE fixes
* validate UK
* chore
* update FE
* tests
* remove compile-merge multi
* FE tests
* remove feature
* move component to its own file
* chore: test composite keys
* fix: override for sqlserver
* fix(embedding-sdk): open SQL editor when Metabot navigates to native hash URL
When Metabot sends navigate_to with /question#<native_hash>, SdkAdHocQuestion
was returning questionId=null (no numeric slug), so isNewQuestion=false and the
SQL editor never opened. Now detects dataset_query.type==='native' and returns
'new-native', which makes SdkQuestionProvider set isNewQuestion=true and
useNewQuestionEditorSync open the editor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dwi9BjxKNSJsztkkVrsU8d
* test(sdk): use real embedding Metabot stream for SQL editor test
Replaces hand-rolled mock payload with a navigate_to event captured
verbatim from an actual embedding Metabot response, so the test
fixture matches real wire format (db id adjusted to match env).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wNQmcGH7PCXMD1PqFLiBS
* refactor(sdk): call resolveQuestionId inline instead of in useMemo
It's a cheap derivation, not worth complicating the memo for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wNQmcGH7PCXMD1PqFLiBS
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* GDGT-2534 Migrate icon tokens (disabled/secondary swap + text aliases)
Per design, the icon tokens are corrected: `icon-disabled` and
`icon-secondary` swap meaning, and all four icon tokens become runtime
aliases of their text counterparts.
- Swap consumers: `icon-disabled` <-> `icon-secondary` call sites.
- Redefine in both themes:
- icon-primary -> var(--mb-color-text-primary) (value unchanged)
- icon-secondary -> var(--mb-color-text-secondary)
- icon-disabled -> var(--mb-color-text-disabled)
- icon-brand -> var(--mb-color-text-brand) (brand[40] -> brand[50])
Token names are unchanged, so there's nothing to remove at cleanup. Icon
tokens aren't SDK public theme keys and aren't used in static-viz, so the
var() aliases are safe everywhere.
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* refactor: add basename module for app-wide basename state
* refactor: move basename off ApiClient into the basename module
* refactor: make setBasename tolerate nullish input
* refactor: drop redundant basename guard in sdk init
* test: capture original basename per-test in setupBasename
* fix: strip basename only as a prefix in getDatasetDownloadUrl
* Measure SDK/app bundle sizes from both uberjars, apples-to-apples
* Always report brotli bundle size, estimating when no .br is shipped
* Bundle-size stats: upload per-bundle deltas, skip unchanged commits
* Bundle-size stats: always record releases, gate/report on brotli delta
* Scope app to app-main and SDK totals to reachable code via the chunk graph
* Fall back to whole app/dist when stats lack reachableAssets
* Stats logger: record master only, drop release tags from the live series
* Measure SDK async chunks as loadable; split legacy initial/total
* Point bundle-size logger at table 79702
* Skip SDK bundle-size gate when base ref predates reachable-chunk stats
* Split bundle-size scripts into pure cores + I/O wrappers, add unit tests
* Bundle-size: extract legacy embedding-sdk.js fallback; fix stale as-served comments
* Retry stats upload with backoff; keep bundle-size run green if it still fails
* Add default-openai-llm-metabot-model gpt-4.1-mini
* Filter openai list-models by supported-models
OpenAI's /vi/models endpoint returns ~140 models, many of which are not relevant (text embeddings, audio/video models)
or else deprecated. Filter this down to a whitelisted set of known supported models.
* Add grouping for openai models in provider-models-response
* Throw an exception if callers pass `:ai-proxy true` for openai
Similar to bedrock and azure, supporting this would require some work on the ai-service side, and we already reject
non-anthropic in validate-metabase-managed-provider!
* FE: enable openai provider in metabot admin UI config
* Drop gpt-4.1-mini from supported-models and change default to gpt-5.4
* Replace gpt-4.1 with gpt-5 in supported-models
* Bump metabase.metabot.example-question-generator/max-tokens to 4096 for gpt-5
GPT-5 produces a lot of reasoning tokens and sometimes exceeded the old limit.
* Remove reference to gpt-4.1-mini in docstring example
* Remove references to gpt-4.1 and gpt-4.1-mini in FE unit tests
* Skip empty remote-sync commits after a no-op export (GHY-3934)
A dirty RemoteSyncObject row whose serialized content already matches the
repo stages a net-zero tree change, so finishing the commit produced an
empty commit that still got pushed. GHY-3933's content-hash gating catches
the no-op-update-event case, but not the others: a full-export fallback
that re-serializes byte-identical content, a rename/delete whose old path
isn't known yet, a dedup name collision, or path/hybrid models.
Add an `empty-commit?` query to the CommitBuilder protocol (git layer
answers whether the staged tree equals the parent's; it does not decide
policy). full-export!/incremental-export! branch on it: when empty, abort
the commit, leave the version unadvanced, and report push-skipped — while
still reconciling RemoteSyncObject state so the stale dirty flags that
triggered the no-op are cleared.
* Docstring
* Unify remote-sync export commit lifecycle and skip empty merges
Extract commit-staged! — the open/stage/empty-commit?/finish-or-abort
lifecycle — and route full, incremental, and merge exports through it.
merge-and-store! moves from source.clj into impl.clj (next to the other
export paths) so it shares commit-staged! instead of duplicating the
lifecycle via replace-all-files!; source.clj keeps the merge computation
(compute-merge, preview-merge).
commit-staged! returns the :remote-sync/empty-commit sentinel when the
staged tree matches the parent, so the merge path no longer pushes an
empty commit either. An empty merge folds in any remote changes and
advances to the remote tip, reporting "pulled" (or "push-skipped" when
nothing changed on either side) rather than a misleading "merged, pushed 0".
* Inline merge-and-store! into export-merged!
The intermediate {:status ...} map that export-merged! re-dispatched via
case added nothing: inline the merge computation and commit so the merge
path reads like full-export!/incremental-export! — compute, commit-staged!,
reconcile, outcome.
Move the test seam down from merge-and-store! to source/compute-merge: the
export! tests stub the pure merge function and exercise the real commit
staging, and the three dedicated merge-and-store! unit tests are dropped as
redundant with merge-test (algorithm) and the commit-staged!/export coverage.
* Better docstrings
* Test empty-commit? against a real git repo
Exercise empty-commit? through the real JGit commit builder (not a mock):
identical re-stage and delete-of-absent-path read empty, real edits and new
files don't, aborted no-op checks leave the branch untouched, and the
memoized tree still yields a correct commit when finish-commit! follows.
Serdes stamped every non-Setting entity (and the Card column) with the
instance build version on extract. Since the value differs between
instances, it produced spurious uncommitted changes during remote sync.
Compatibility is now governed by the versioned representation spec
(:card_schema + upgrade-card-schema-to), so the per-entity version stamp
no longer carries useful information. Stop serializing it:
- extract: remove the metabase_version stamp
- Card serdes spec: move :metabase_version from :copy to :skip
- load: remove the now-dead version-mismatch warning machinery
Legacy baseline fixtures still carry the field, exercising that older
exports import cleanly now that it's skipped.
Replace e2e's local ConductorTest with the shared NormalizedTest wire shape
(plus a transient screenshotPath) and import toNumber from the module, so the
report path and the quarantine path can't drift on a test's identity. The
transport stays forked: e2e posts mid-run from Cypress's after:spec hook on
node-fetch, with screenshots and a dry-run mode the module's post-run transport
doesn't cover.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): strip controlled parameters from drill-through target dashboard
When InteractiveDashboard renders a drill-through target via
SdkInternalNavigationProvider, the full dashboardProps spread included
the host's controlled `parameters` and `onParametersChange` props.
getEffectiveParameterValues makes the controlled prop win over
initialParameters, so click-behavior filter values were silently
discarded and the target dashboard opened with empty filters.
Introduce getDrillThroughDashboardProps to explicitly strip controller
props before forwarding to the drill-through target. Display props
(withDownloads, withTitle, etc.) pass through unchanged. The target
is seeded from click-behavior initialParameters as intended.
Fixes EMB-1946.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwn7CNjPBZAg4KLVMaV7HW
* fix(sdk): also strip initialParameters from drill-through target props
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwn7CNjPBZAg4KLVMaV7HW
* test(sdk): clean up EMB-1946 e2e test
Rename Dashboard A/B to Origin/Target for readability, fix parameter id,
remove cy.intercept/cy.wait in favour of visible UI assertions only, and
rename the describe block to cover all three stripped props.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwn7CNjPBZAg4KLVMaV7HW
* test(sdk): remove describe.only from EMB-1946 test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwn7CNjPBZAg4KLVMaV7HW
* fix(sdk): address review feedback on getDrillThroughDashboardProps
- shorten comment to focus on why (root-scoped props override drill-through target)
- tighten return type to Omit<> reflecting the stripped keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwn7CNjPBZAg4KLVMaV7HW
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Limit metabot FK related-tables to bound context size
When bulding metabot's context we expand one related-table entry per FK column and fetch and format the full column
set for each related table. On a large, highly-connected schema this fetches a large number of columns into the
metadata cache, exhausting the heap (metabase#76493).
We now limit the number of related-tables we attempt to fetch to metabase.metabot.tools.entity-details/max-related-tables
* Report truncated related tables to the LLM
When the related-table list in the metabot context gets truncated due to exceeding the limit, leave a note in the
context that truncation occurred. For tables that were dropped, include them in a lighter weight list with only
id/name/related-by (also capped), so the LLM knows more related tables exist and can look any of them up individually.
`entity-details/related-tables` now returns the detailed list plus a total count and a list of truncated items; the
result surfaces related_tables_truncated / related_tables_total / related_table_refs (+ related_table_refs_truncated),
and llm-shape renders a <related-tables-truncated> note with the truncated items.
* Rename max-related-table-{,truncated-}refs and add table descriptions
* Don't :include-implicitly-joinable? when looking up FKs for metabot related-tables
Otherwise we fetch and cache the full column set for every FK-target table when looking up realted-tables for
metabot's context. Instead, just do a bulk metadata lookup on the FK target columns only so we don't blow up the
metadata cache for tables with a huge number of columns.
* Cleanup docstrings and formatting in related-tables->xml
* Exclude tables in :related_tables from :related_table_refs
The refs list doesn't need to duplicate the tables from :related_tables. This way, the LLM potentially gets more
info about truncated tables, since we also cap the :related_table_refs list.
* Drop :related_tables_truncated and :related_table_refs_truncated
They can be inferred from :related_tables_total and the size of two lists.
* Use render-llm-template for related-tables->xml and related-table-ref->xml
* Fold related-tables-result into related-tables
* Reduce max-related-tables from 20 to 10
* Make metabase.metabot.tools.entity-details/related-tables private
* Fix docstring typo
* Ensure that :related_tables_total = :realted_tables + :related_table_refs
Don't exclude entries from :related_table_refs just because the table id appears in :realted_tables. We specifically
want to allow duplicate table-ids as long as the (table-id, fk) pairs are distinct to preserve the total = related +
refs invariant.
* Cleanup realted-tables
* Return field id from fk-related-table-groups
To avoid having to call lib.metadata/field in the caller for each fk just to extract the :name.
* Include the field id in related-tables :related_by to distinguish fk fields with the same :name
* Simplify handling for related-tables without fields
- rename max-related-tables to max-related-tables-with-fields
- rename max-related-table-truncated-refs to max-related-tables
- rename :related_table_refs to :related_tables_without_fields
- return up to max-related-tables-with-fields (10) in :related_tables
- return up to 40 tables in :related_tables_without_fields
- 40 = 50 - 10 = max-related-tables - max-related-tables-with-fields
- always use table-details to format related-tables, just pass :with-fields? false once max-related-tables-with-fields
is exceeded
* Don't say roster
* Only include :related_tables_without_fields if the caller requested fields
If the caller did not pass `with-fields? true`, then there is no distinction between :related_tables and
:related_tables_without_fields, so don't include it.
* Fix docstring
Adjust dataset hashing for snowflake and bigquery.
We are frequently seeing some rogue process dropping the test-data
database which is shared across all tests, causing a good deal of
instability. We have not been able to track down what code is doing
this. Changing the way we calculate database names based on dataset
will help us find the culprit and also help isolate branches for
stability.
When we are on master or a release branch, splice the current major
version into the qualified database name. When we aren't, use "sha__"
as a prefix instead of "sha_", which will keep backport branches from
affecting dev branches going forward and help us identify whether it's
backports or not that are causing the database drops we're seeing.
Stop checking the tracking table to determine whether the dataset is
loaded. The dataset will be tracked regardless, so this should not
factor into the question of whether it needs to be created.
Unrelated change: update get-or-create/create-database! to print when
it encounters a failure and deletes the database it was trying to
create. This should never happen for snowflake/bigquery because they
are flagged as not supporting dynamic dataset loading, but it's still
better to have this printed visibly instead of the error logs which
are hidden.
* Fail serialization export when cards escape requested collections (#75176)
When a dashboard or card in the requested collections referenced a card
saved outside them, escape analysis logged a warning but the extract
stream was silently emptied. The CLI still printed "Export complete!" and
exited 0, and the API returned HTTP 200 with an archive containing only
export.log, so automated pipelines could not detect the empty export.
The default export now aborts loudly: the eager extract phase throws, so
the CLI exits non-zero and the API returns a 4xx before streaming begins
(the status can't change once the tar.gz starts). continue-on-error is
unchanged - it still proceeds, skipping the escaped entities, and the
escape warnings still land in export.log.
* Unify escape reporting and abort into handle-escapees!
The escape warning report and the abort throw were two adjacent branches
over the same data. Fold them into one handle-escapees! function: it logs
the per-entity report and, unless continue-on-error? is set, throws to
abort. Behavior and the thrown message/status are unchanged.
* Better docstrings
* Track remote-sync dirty state by serialized content hash (GHY-3933)
The remote-sync "yellow dot" was driven purely by update events: any
:event/<model>-update flipped the RemoteSyncObject row to "update",
regardless of whether the entity's serialized form actually changed. So
editing a non-serialized field (e.g. a transform's schedule) or any no-op
re-save marked the entity dirty, and the subsequent push produced a commit
with no files.
Record a content_hash (SHA-256 of the entity's serialized YAML, taken from
the live DB entity) on each RemoteSyncObject at every sync point — full
export, incremental export, and import. On an update event, the consumer
recomputes the hash and keeps the row "synced" when it matches the recorded
baseline; a null baseline is treated as dirty so new content still exports
once. A reverted edit that returns to the synced content clears the stale
dirty flag.
Works for all tracked model types (entity-id, hybrid, and path identities).
* Add export/import integration tests for content-hash dirty detection
Beyond the consumer-level per-type tests (which seed the baseline hash
directly), add end-to-end tests that exercise the baseline-recording paths:
- export integration: run a real export! to a mock source, then fire a no-op
update and assert it stays synced — covers the incremental export
synced-loop hash recording for Card, Dashboard, Document, Timeline,
Collection, TransformTag, and PythonLibrary.
- import integration: run a real import! from a mock source (the "right after
a pull" scenario), then fire a no-op update — covers record-content-hashes!
on the import path for Transform and TransformTag.
* Better comments in migration
* Batch record-content-hashes! to one extraction and update per model
Instead of re-extracting and updating each RemoteSyncObject one entity at a
time, group rows by model type and: run a single extract-query (keeping each
instance's local id so it correlates to its row), serialize each in-process,
and write all hashes with a single CASE update. This works for every identity
flavor — including the extract-query overrides (Collection, NativeQuerySnippet),
hybrids, and path models (Table/Field) that have no entity_id — since
correlation is by the instance's primary key, not entity_id.
Adds a unit test asserting the batched result matches per-row row->content-hash
across entity-id, override, hybrid, and path models.
* Chunk record-content-hashes! into batches of 500
Bound the IN-list and CASE expression sizes: partition each model type's rows
into chunks of content-hash-batch-size (500) and run one extract-query + one
CASE update per chunk, so very large synced sets don't produce a single
oversized statement. Adds a test (with a small batch size) covering rows that
span multiple chunks.
* Use t2/query CASE update for cross-driver portability
Switch the batched content_hash write from t2/update! (with a honeysql value)
to the t2/query {:update :set :where} form used by
queries.models.query/update-rolling-average-execution-times! — the established
cross-driver (H2/Postgres/MySQL) pattern for a single atomic CASE update.
* Use t2/update! for the batched content_hash CASE update
RemoteSyncObject has no transforms or update hooks, so t2/update! passes the
honeysql CASE expression straight through to the same dialect-aware compilation
as t2/query — equally portable across H2/Postgres/MySQL — while reading more
idiomatically with its conditions/changes maps.
* Make record-content-hashes! no-arg, selecting all rows itself
Both callers passed the same select-all of RemoteSyncObject rows, so move that
query into the function. It is always invoked after every row is synced.
* Run record-content-hashes! under serdes/with-cache
Extraction resolves foreign keys (collections, databases, users, …) while
serializing each entity. Wrapping the pass in serdes/with-cache memoizes those
resolvers so shared references are looked up once instead of once per entity.
The full-export caller was already inside a cache; the import caller was not.
* Record export/import metadata in one serialize + one batched write
Collapses the post-export/import RemoteSyncObject bookkeeping. Previously a full
export serialized the whole synced set twice (store! to write files, then
record-content-hashes! to re-serialize for hashes) and wrote each row up to
three times (blanket status, per-row file_path, batched content_hash); import
was similar.
Now:
- source/store-and-record! drives serialization from extract-query so it keeps
each entity's primary key, writing files and returning {:model_type :model_id
:path :content_hash} — so file_path and content_hash come from the single
serialization store! already does, for every model type (including path
models with no entity_id).
- record-exported-metadata! writes file_path and content_hash for all models in
one batched CASE update per chunk (not per model, not per row), matched by
primary key.
- import-content-metadata re-serializes the loaded entities once (import has no
store!), overriding file_path with the actual repo path for entity-id models;
used by both full and incremental import.
Extraction (extract-one) runs inside the extract-query reduction so the JDBC
ResultSet stays open. Replaces record-exported-paths! and record-content-hashes!.
* Batch the incremental export RSO updates
The incremental export fast-path wrote each synced row with its own t2/update!.
Replace with one batched CASE update per chunk (status/status_changed_at
constant, file_path/content_hash per-row CASE), keyed by the RSO :id the synced
plan already carries — no extra select. Fewer round-trips on the fast path.
* Fold import metadata into the insert (one batched insert per row)
Import previously wrote each RemoteSyncObject row twice: sync-objects! inserted
status/name, then record-exported-metadata! updated file_path/content_hash.
Compute the metadata before the insert and merge it into the insert maps
(merge-content-metadata), so the whole import reconcile is one delete-all + one
batched insert (full import) / one delete + one insert (incremental). Drops the
follow-up update pass and leaves record-exported-metadata! export-only.
* Make full export one RSO write per row
record-exported-metadata! now reconciles the whole table in one write per row:
one select of all rows, then chunked updates that set status for every row in
the chunk and file_path/content_hash for the exported ones (CASE with ELSE
keep-existing). full-export! drops the separate blanket status update. Each row
is written once; removed/delete rows just get their status reset.
* Chunk the import pipeline and export IN clauses
Bound the work that could exceed DB param limits / memory on large syncs:
- insert-with-metadata! processes the import in chunks of content-hash-batch-size
(extract + hash + enrich + insert per chunk, under one shared serdes cache),
so insert/IN params and the maps held at once stay bounded — replaces the
whole-table merge + unbounded insert. Used by full and incremental import.
- store-and-record! chunks each model's extract-query IN (restores bounding the
refactor had dropped from record-content-hashes!).
Removes the now-unused sync-objects!.
* Flatten store-and-record! into one transduction pipeline
Replace the nested mapcat>mapcat>map with a single (into [] (comp ...) targets):
expand each model into bounded [model-type id-chunk] work items, then stream
each chunk's extract-query through a serialize helper via an eduction — so
extract-one still runs while the ResultSet is open, with no intermediate
per-chunk sequence realized.
* Flatten import-content-metadata to a single transduction
Mirror store-and-record!: pull the per-instance work into a serialize helper and
stream each model's extract-query through it via an eduction instead of an inner
(into [] (keep ...)), so the whole function is one transduction over the model
groups with no intermediate per-model sequence realized. extract-one still runs
inside the extract-query reduction (ResultSet open).
* Fix events_test references to the removed sync-objects!
The export/import refactor removed sync-objects!, but events_test still called it
(2-arg form) to set up a synced baseline before firing move-out events, failing
clj-kondo (unresolved var) and the enterprise test load. Replace with a local
mark-synced! helper that does the same via spec/sync-all-entities!. Also refresh
two stale comments referencing removed functions.
* Use empty? instead of not
* kick ci
* Remove no-op (filter identity) from export root-targets
query-export-roots is consumed by mapcat (so nil returns contribute nothing) and
the [model-type id] tuples it produces are never nil, so the filter could never
remove anything. (PR review #76098)
* export-targets returns {} instead of nil
Removes a nil-as-empty signal: export-targets always returns a map, so callers
test emptiness (full-export! already uses empty?; extract-entities-for-export
uses not-empty) rather than nil. (PR review #76098)
* refactor
* Refactor!
* commment
* Stream full-export serialization; fix disabled-files deref
- store-and-record! feeds write-files! a lazy tee'd eduction so the full
serialized content is never held at once; only the lightweight per-entity
entries accumulate (GHY-3933 / PR review #11).
- Fix export!: pass @disabled-files (was the raw delay) to export-incremental!,
which reduces it via into.
- Fix resolve-status: return "synced" (was "synched").
* Stream the incremental export in two passes (bound memory)
The incremental plan used to serialize every dirty entity and hold all their YAML
in :upserts. Split it so the dirty set's content is never all in memory:
- Pass 1 (incremental-plan / incremental-updates-for-row / dep-writes): validate
the batch is syncable and compute each write's target path — using the new
source/entity->path, which renders no YAML. Produces a content-free plan of
{:writes [{:id :model_type :model_id :file_path}] :delete-paths :removed-ids}.
- Pass 2 (export-incremental!): stream-re-serialize each write's content (via
source/entity->content), using the pass-1 path, and feed it lazily to
apply-changes!; tee the per-row synced metadata. One entity's content is live
at a time.
stream-extract is a single comp'd eduction over the model groups (not a nested
eduction over the reduce-only extract-query) so it survives reduce- or
seq-consumption. entity->file-spec is now path+content composed.
* Batch the incremental pass-1 extraction (one query per model, not per row)
incremental-updates-for-row used to extract its entity itself, so incremental-plan
did one DB read per dirty row. Move extraction up into incremental-plan: batch-
extract the create/update rows on entity-id models via stream-extract (one query
per model/chunk), computing each row's path + existing-repo-file info once, then
run incremental-updates-for-row as a pure decision over the precomputed info.
removed/delete and non-entity-id rows need no extraction; a create/update row
whose entity is gone gets no info and is unsyncable; an extraction error sends the
whole batch to a full export (which self-heals). Paths are still computed with the
shared storage context (rows before deps) so dedup stays consistent.
* Simplify stream-extract: one key->row lookup, no tuple threading
Compute the [model-type model-id]->row lookup once instead of threading a per-model
id->row through the work-item tuple. Same two-mapcat structure (which keeps the
reduce-only extract-query on the reduce path); just less to read.
* Full export drops tracking rows for entity-id entities that left the set
Reconcile the removed-row handling between the two export paths. The incremental
path deletes the RemoteSyncObject row for a removed/delete entity; full export
left it as a synced row pointing at a file the wholesale re-serialize had already
dropped. Full export now deletes those rows too — but only for entity-id entities
that genuinely left (removed/delete status AND not in this export). Path/hybrid
removed rows (which can't go incremental) and removed rows whose entity is still
exported are still kept and reconciled to synced, as before.
* remove unnecessary function
* Rework export onto a streaming git commit-builder + chunk model
Reshape the export path around two ideas the reviewer asked for: an incremental
git commit API, and a uniform chunk-based extract pipeline shared by full and
incremental export.
Git protocol: add a stateful CommitBuilder (open-commit / stage-upsert! /
stage-delete! / finish-commit! / abort-commit!). The caller stages one file at a
time and the blob is inserted immediately, so the writer never receives or holds a
whole collection — streaming is structural, not a fragile single-pass contract.
write-files!/apply-changes! are kept, re-expressed on top of the builder. All
SourceSnapshot implementers (git, mocks, read-only views) implement it.
Export core: a chunk is [model-name rows<=500]; ->chunks (pure) groups+partitions
and extract-chunk runs one query per chunk. full-export! and incremental-export!
share stage-and-commit!, which doseq-stages each chunk's entities and accumulates
the synced metadata locally. After the push, set-version! + the RSO writes
(mark-synced! / record-exported-metadata! / removed deletes) run in one
transaction so the version can't advance past stale RSO state. Incremental still
validates first (incremental-plan) and aborts to a full export when unsyncable.
Supersedes the store-and-record! helper and the reduce-once write hardening (now
moot — no collection is passed to git).
* Tighten docstrings on the reworked export functions
One-line intent + args/return; move the how (batched CASE, select-all, streaming,
dedup, validation rules) into body comments where it matters. Fix the open-commit
docstring that still referenced the old :replace-dirs option.
* Batch enable-snippet-tracking! into one insert
Was one t2/insert! per snippets-namespace collection and per snippet (N+M
inserts); build the rows and do a single batched insert instead.
* Fix snippets export test for the removed-row reconciliation
A full export now drops the tracking row for an entity-id entity that left the
synced set (archived snippet, 'delete' status, not in export-targets) instead of
marking it synced — matching the incremental path. Update the assertion to expect
the row to be deleted.
* BEtter names
* Consistent capped chunking + shared path-free? predicate
- ->sized-chunks (group by model + cap at content-hash-batch-size) is now used by
stage-and-commit!, incremental-plan, and dep-writes, so every extract-chunk gets
a bounded chunk (the ≤500 cap was missing from the first two).
- dep-writes validates one chunk at a time and short-circuits on the first
colliding chunk, instead of realizing all decisions then scanning.
- path-free? is a pure predicate over the {:eid :file-exists? :file-eid} info,
shared by dep-chunk-writes (via repo-file-info) and incremental-updates-for-row's
create/rename branches — one collision check instead of three copies.
* docs and names
* Calculate the incremental plan one chunk at a time
incremental-export-plan now folds the create/update rows over ->sized-chunks
(chunk->updates per chunk), short-circuiting on the first unsyncable chunk like
dep-writes and stage-and-commit! do — instead of building an id->info map for all
rows and then deciding in a separate pass. Rows that need no entity (removed/delete,
non-entity-id, bad status) are decided up front. A gone entity is detected by the
found-vs-requested count; an extraction error falls back to a full export. Reuses
repo-file-info + path-free?.
* lots of doc improvements, some refactoring
* More renames
* Row is now map
* Chunk-as-map, deps return plans, and a pure plan combiner
- A chunk is {:model_type :rows}; export-closure/untracked deps speak
{:model_type :model_id} maps throughout.
- dep validation returns plan fragments ({:writes [...]}) merged like every other
fragment; row/chunk/dependency all produce plans combined by one function.
- Split merge-incremental-export-plans into a pure combiner (safe for direct
calls, e.g. the deps merge) and a -reducer wrapper that adds the reduced
short-circuit at fold sites — fixing a bug where the direct deps merge returned
a Reduced and broke the fall-back path.
- Move the chunk-plan try/catch into chunk->plan where the throwing happens.
* REfactor
* Nice!
* Go!
* Guard no-source export at the API call; add exportable-entity-chunks
- async-export! refuses up front (HTTP 400) when remote sync isn't configured,
instead of a nil-snapshot check inside the async task (which never fired) or a
cryptic clone error. export! no longer guards its snapshot.
- spec/exportable-entity-chunks: exportable-entities in chunk form.
* Decompose stage-writes into stage-write/chunk-stage-writes
Split per-entity staging (stage-write) and per-chunk staging (chunk-stage-writes)
out of stage-writes; synced metadata flows out as return values.
* Go for it!
* Self-contained exportable-chunks; tighten docstrings
- exportable-chunks reads its own export set and full-export!/delete-departed-entities!
flow over chunks; delete-departed derives the exported set from the chunk rows.
- Tighten prose-heavy docstrings to intent + Returns shape (content-metadata trio,
untracked-content-deps, incremental-export-plan, dep plan, row->content-hash).
* Refactor!
* Go for it!
* Retire the legacy write API (write-files!/apply-changes!, store!)
The CommitBuilder (open-commit → replace-all!/stage-upsert!/stage-delete! →
finish-commit!) is now the sole SourceSnapshot write path:
- merge-and-store! (the export-merged! path) writes via the commit builder
(replace-all-files!) instead of write-files!.
- Removed the dead store! and its tests/mocks.
- Removed write-files! and apply-changes! entirely — protocol methods, git
impls + helpers (and the now-unused with-staged), and the read-only stubs.
- Test fixtures (git_test, test_helpers_test) seed via local commit-builder
helpers, so they still exercise the same wholesale/incremental write paths.
* Untrack content_hash_bench.clj (committed by mistake)
* cljfmt: re-align test helper call sites
* Review fixes: drop unused fn, log skipped serialization, document commit lifecycle
- Remove the unused spec/exportable-entity-chunks.
- Log a warning when an entity fails to serialize for its content hash (was a
silent catch in import-content-metadata).
- Document the CommitBuilder resource/lifecycle contract (caller must finish/abort).
* Align RSO txn ordering (delete then update); use u/index-by in merge-content-metadata
- incremental-export! now deletes departed rows before updating RSO metadata,
matching full-export!'s order (the ops are disjoint within the txn, so this is
a consistency change, not a behavior change).
- merge-content-metadata uses u/index-by instead of an into/map/juxt build.
* Inline expand-nested-collections
* Bgrabow/ghy 3933 suggestions (#76640)
PR feedback
---------
Co-authored-by: Ben Grabow <ben.grabow@metabase.com>
* Move OnBeforeRequest* types into the api client middleware module
The `OnBeforeRequestHandler` / `OnBeforeRequestHandlerConfig` types lived in
`metabase/plugins/oss/api` but describe the request-manipulation middleware,
which lives under `metabase/api/client`. Relocate the type definitions to
`metabase/api/client/middleware` and re-export them from the `metabase/api/client`
barrel, so the middleware contract is fully contained in its own module. Pure
move plus import updates; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Make the request middleware a single static plugin-slot list
The request-manipulation pipeline was split across two mechanisms: a hardcoded
list of `PLUGIN_*` slots in `middleware.ts`, plus a dynamic
`api.beforeRequestHandlers` array that three features pushed onto at runtime
(the embed-preview rewrite, the React-SDK embed referrer, and the iframe embed
referrer). That made the full set of handlers — and their order — impossible to
see in one place.
Promote the three dynamic handlers to plugin slots and drop the dynamic array,
so `middleware.ts` lists the entire ordered pipeline statically:
- `PLUGIN_API.onBeforeRequestHandlers.rewriteEmbedPreviewUrl`
- `PLUGIN_EMBEDDING_SDK.onBeforeRequestHandlers.reactSdkEmbedReferrer`
- `PLUGIN_EMBEDDING_IFRAME_SDK.onBeforeRequestHandlers.embedReferrer` (new
`onBeforeRequestHandlers` section on that plugin)
The owning features now install their handler into the slot instead of pushing.
`ApiClient` loses its `beforeRequestHandlers` field; `apiRequestManipulationMiddleware`
reads the static list and delegates to an exported `runBeforeRequestHandlers`
(kept pure so the merge/ordering semantics stay unit-testable). Slot resets on
plugin `reinitialize` now cover these handlers too, and the dead
`overrideRequestsForStaticEmbeds` slot is removed.
Because the slot positions are fixed, the public/static preview-rewrite no
longer depends on render-vs-effect registration timing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move the embedded-app header to a plugin handler
`client.ts` imported `isEmbeddingSdk` from `metabase/embedding-sdk/config` to
decide whether to send the `X-Metabase-Embedded` header — the last SDK import in
the api HTTP client. Move that logic into a `PLUGIN_API.onBeforeRequestHandlers`
slot (`setEmbeddedHeader`) whose default implementation does the
`isWithinIframe() && !isEmbeddingSdk()` check, and add it to the static pipeline
in `middleware.ts`. The plugin layer may import SDK config; `metabase/api/client`
no longer does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Route param-remapping endpoints through the embed middleware
`api/card.ts` and `api/dashboard.ts` built the parameter-value remapping URL via
`PLUGIN_API.getRemapped*Url` — the last reason these endpoint files imported
`metabase/plugins`. Switch them to the same `:tag` template + params convention
the sibling `values`/`search` endpoints already use, so the existing embed
override middleware (which already has remapping transforms) does the rewrite.
Embed requests pass `entityIdentifier` instead of the real id, mirroring
`parameters/actions` — non-embed fills `:cardId`/`:dashId`, embed swaps to
`:entityIdentifier` and the guest handler injects the token. `substituteUrlTags`
encodes tag values, matching the getter's prior `encodeURIComponent`.
NOTE: verify the embedding e2e suite (guest/public/static parameter remapping)
before merge — this changes outward-facing embed request URLs and is not covered
by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Remove the now-unused remapping-URL plugin getters
With the card/dashboard endpoints routed through the embed override middleware,
nothing reads `PLUGIN_API.getRemappedCardParameterValueUrl` /
`getRemappedDashboardParameterValueUrl` anymore. Drop the getters from the
plugin default and delete `setupRemappingUrls` (and its calls in the guest /
public-static embed setup), which only existed to override them.
`metabase/api/card.ts` and `metabase/api/dashboard.ts` no longer import
`metabase/plugins`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Move embedding auth/client request headers off the api client
The api client carried embedding-only request state (apiKey, sessionToken,
requestClient) and emitted the embedding request headers (X-Api-Key /
X-Metabase-Session / X-Metabase-Client / -Version / -Embedded-Preview). Move
these off the generic client into embedding request-header plugin handlers built
as closure factories: setApiKeyHeader / setSessionTokenHeader installed onto the
setEmbeddingRequestAuthHeaders slot as the active auth strategy, and
setRequestClientHeaders installed onto its own slot per embed mode. The embedding
setup sites install the handlers they need, each closing over its value, so no
embedding state or header logic remains in metabase/api and there is no shared
mutable module state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Send the SSO session token on the initial embed requests
In the SSO/SAML/JWT path the session-token header was only installed by the
request-pipeline refresh handler, which runs after the auth-header step, so the
user/settings requests fired during init went out without X-Metabase-Session
(the bootstrap path already installed it eagerly). Install the strategy eagerly
right after the initial session refresh, sharing one refresh-and-install helper
with the pipeline handler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Simplify RequestClientInfo type
* Split the embed-preview header off the request-client handler
X-Metabase-Embedded-Preview signals preview mode (IFRAMED_IN_SELF), which is
orthogonal to which client is embedding. It was riding inside
setRequestClientHeaders, where the IFRAMED_IN_SELF branch was unreachable from
the static and full-app install sites (they skip installing the handler in
preview). Move it to its own setEmbedPreviewHeader slot, installed only by the
public and SDK flows so static/full-app previews keep not emitting it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Set the SSO session header from the refresh handler itself
getOrRefreshSessionHandler re-installed setEmbeddingRequestAuthHeaders with the
fresh token, but that was too late for the triggering request: the handler list
is snapshotted per request, and setEmbeddingRequestAuthHeaders runs earlier in
the pipeline, so the request that detected expiry still went out with the stale
token. Have the handler return the session header itself instead — it runs after
the static auth-header slot, so the refreshed token applies to the same request
(mirroring getOrRefreshGuestSessionHandler).
Drop the three dynamic setSessionTokenHeader installs (SSO + bootstrap) in favour
of the returning handler; keep setSessionTokenHeader for MCP's static token via a
shared sessionTokenHeaders helper. Widen the getOrRefreshSessionHandler slot type
to allow returning a header config.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Type request handlers with OnBeforeRequestHandler
Replace the repeated inline `async (): Promise<Partial<OnBeforeRequestHandlerConfig> | void>`
annotations (and the local EmbeddingRequestHandler alias) with the existing
OnBeforeRequestHandler type. The plugin no-op defaults collapse to a shared
`noop` handler, and the embedding-request-auth factories and the two embed-
referrer handlers gain their parameter and return types from the annotation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Skip null querystring params, not just undefined
appendQueryParameters only skipped undefined, so a param explicitly set to
null was serialized as the literal string "null" (e.g. ?cursor=null). Skip
nullish values so an absent param drops out of the querystring entirely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Drop the cardId/entityIdentifier switch into the embed override
The param-value/remapping endpoints chose between cardId/dashId and
entityIdentifier at six call sites to keep the unused id out of the request.
Pass both unconditionally instead: the embed override now drops the real
cardId/dashId from the params bag when it rewrites the path to
:entityIdentifier, so the real id never leaks to an embed endpoint, and a null
entityIdentifier falls out of the normal-app querystring via the nullish skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Settle the JWT popover test's auth flow to stop a cross-test leak
The 'usage problem popover' test rendered the auth flow but never awaited it,
so its user/current + session/properties requests were still in flight when the
next test (the SSO subpath test) ran, tripping the strict unmocked-routes
afterEach. Await the user request after the synchronous assertion so the flow
settles within the test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Omit null entityIdentifier from parameter-value requests
The parameter-value dropdowns key their cache on stableStringify(request).
Always including entityIdentifier (null when not embedding) changed that key
from {dashId, paramId} to {dashId, entityIdentifier: null, paramId} — stable-
Stringify keeps null — so every cache lookup missed and the dropdown rendered
empty (caught by the PublicOrEmbeddedDashboardView Loki stories). Only include
entityIdentifier when it exists; cardId/dashId stay unconditional (the embed
override still strips them). Add actions tests pinning the cache-key shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Use camelCase ids in the remapped-parameter request types
The getRemapped{Card,Dashboard}ParameterValue requests used snake_case
(card_id/dashboard_id/parameter_id) while the url tags and the embed override
use camelCase (:cardId/:dashId/:paramId), so each query function had to remap
the keys. Align the request types (and their callers) to camelCase like the
sibling values/search endpoints, so the query is a plain passthrough. Rewriting
the url template to snake_case instead would have pushed mixed casing into the
shared override (its embed-url outputs and the cardId/dashId strip), so the
request types are the right place to converge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Migrate bubble Radio usages off the deprecated component
Replace the two `bubble`-variant usages of the deprecated
`metabase/common/components/Radio`:
- EntityViewSwitch (Groups/Databases view switch) -> Mantine `Tabs`
`variant="pills"`. Drops the accent7 color scheme for the standard brand.
- FormFieldEditor "Field type" -> a horizontal Mantine `Radio.Group`. It's a
single-select choice, so a radio group fits better than tabs.
Centralize the pills active style in the shared `metabase/ui` Tabs so
`variant="pills"` renders our soft "selected" pill out of the box.
Update the EntityViewSwitch e2e selectors (label/radio -> tab). The Field type
tests are unchanged: Mantine `Radio.Group` keeps the radio/radiogroup roles.
Part of GDGT-2588 (PR 3 of 3).
* Replace data studio hand-rolled tabs with Mantine pill tabs
PaneHeaderTabs (the data-studio entity-header navigation) was a hand-rolled
Group of Button-as-Link pills. Render it with the shared Mantine `Tabs`
`variant="pills"` instead, deriving the active tab from the existing
`isSelected` logic. Tabs keep real `<Link>` anchors (cmd/middle-click, href)
via a small cast, since Mantine's `Tabs.Tab` isn't polymorphic.
Drops the unused `withBackground` prop and refines the shared pills styling.
* Remove the deprecated common/components/Radio component
All usages have been migrated to metabase/ui (Radio, Tabs, SegmentedControl)
across the normal/underlined/bubble variants, so the deprecated component is
now unused. Delete it along with its stories and spec.
* Document pills variant in Tabs stories and docs
Add Pills (and Pills + icons) stories plus a variant control, and document the
pills variant and the listBorder prop in Tabs.mdx.
* Fix tests for the Tabs/Radio migration
- TransformHeader and JobRunListPage specs: PaneHeaderTabs are now role="tab",
not "link".
- FormFieldEditor: give the "Field type" Radio.Group a real label (via the
Subtitle) so the radiogroup has an accessible name again — Mantine doesn't
name it from a sibling label + aria-label. Fixes the actions e2e specs that
query the radiogroup by name, and add a positive assertion for it.
* Simplify comment
* feat: convert SDK analytics events to use trackSimpleEvent
Replace the custom Snowplow "sdk" tracker approach with trackSimpleEvent
so SDK analytics go through both Snowplow (simple_event schema) and
Metaplow automatically — no custom schema registration or Metaplow
migration needed.
Two events via trackSimpleEvent:
- embedding_sdk_initialized: fires once per JS load from ComponentProvider
- embedding_sdk_component_rendered: fires per component mount via
triggered_from (component name) and event_detail (JSON with sdk_version,
auth_method, locale_used, and component-specific properties)
Removes: snowplow.ts (custom tracker), sdkTrackerReady Redux state
(no longer needed — trackSimpleEvent needs no async init). Dedup for
component events switches to instance-based (useRef + nextInstanceId)
from entity-based, matching the reference branch design.
setSdkTrackingContext() is called synchronously during ComponentProvider
render so child component effects can read auth_method on the first commit
without needing a Redux gate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: pass live store getState to Snowplow instance-context plugin
getSdkStore() calls configureStore() each invocation — no memoization —
so the plugin's lazy getSdkStore().getState() was always reading from a
fresh empty store, leaving analytics-uuid, version, and token_features
undefined on every event. Fix: pass store.getState from useSdkStore()
(the actual ComponentProvider store) to initSdkTracker, which threads it
into the plugin closure so it reads the live state at event-send time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VagN42CYBMhd1MHSDhbxTJ
* test: guard tracker-before-event ordering with an integration test
Uses real tracker.ts, snowplow.ts, component-events.ts (only
@snowplow/browser-tracker mocked) to assert that newTracker is called
before the first trackSelfDescribingEvent across a parent+child tree.
Falsifiability verified: reverting initSdkTracker to useEffect causes
the assertion to fail (invocationCallOrder 9 > 6), confirming the test
is exercising real render/effect ordering, not just mocked call counts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VagN42CYBMhd1MHSDhbxTJ
* fix: address review findings for simple_event schema migration
- Cache sdkMetaplowEnabled at init instead of storing the full store ref
- Unexport trackSdkEvent; only trackSdkSimpleEvent is public
- Move SdkComponentName back to component-events.ts (SDK-only type)
- Remove triggered_from from SDK event types; component goes in event_detail
- Nest event_detail as { component, properties, global } envelope
- Restore field order auth_method/sdk_version/locale_used in global block
- Add sdkTrackerReady Redux flag; gates per-mount events until tracker is up
- Initialize SDK Snowplow tracker synchronously to prevent first-event drop
- Read metaplow gate from Redux store, not Settings singleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HWtepijYZu4qkYB3XAgwT
* fix: tighten trackSdkSimpleEvent to EmbeddingSdkEvent union type
Narrows the parameter from the open SimpleEventSchema to the closed
EmbeddingSdkEvent union so only known SDK events are accepted at
call sites. Updates two Metaplow gate tests to supply the now-required
event_detail field and runs Prettier on format-checked files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* refactor: remove implementation-detail tests and unnecessary diffs
- Delete reducer.unit.spec.ts (Redux plumbing; behavior covered by events
and tracker specs)
- Delete ComponentProvider test 1 (call arity check); update tests 2 & 3
to use toHaveBeenCalledWith instead of magic index access
- Extract parseEventDetail/findEventByComponent helpers in e2e spec to
eliminate repeated try-catch JSON.parse blocks
- Restore setSdkTrackerReady position in reducer.ts to append after
setInitialDashboardTabId (matches master, removes unnecessary diff)
- Restore JSDoc on sdkTrackerReady in types.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* refactor: simplify event_detail assertions in events spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* refactor: move SDK event types and schema URI out of metabase-types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* fix: pass metaplow-url and analytics-uuid explicitly to trackMetaplowEvent
Convert trackMetaplowEvent to object-style signature with metaplowUrl and
analyticsUuid params that default to Settings.get. The SDK caches both
values from the Redux store at initSdkTracker time and passes them
explicitly, bypassing the Settings singleton which is absent in a
customer's page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* refactor: convert all metaplow.ts functions to object-style params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jux5AAEEsYVNUNSUEK8zBF
* refactor: revert core app metaplow to positional params
Settings.setAll already runs in SDK auth flow, so trackMetaplowEvent and
trackMetaplowPageView can read metaplow-url and analytics-uuid via Settings.get
directly. No need to change the core app function signatures for SDK's sake.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQTnAAmpqsWDQqNymVmnck
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes UXW-4614.
There are effectively three "sizes" of query results for rendering
dashboard subscriptions. The PDF renderer had so far only supported the
smallest of them ("fits in memory").
The QP always *streams* query results, but the subscriptions sink
collects them in memory up to a threshold, then spills them to a temp
file and streams the rest there.
If the temp files get too large, we don't risk an OOM by reading them and
trying to render the dashcard. The files are discarded and the card's
results are marked `:too-large? true`.
This PR makes the PDF renderer correctly read from temp files, and
properly surfaces a "results too large" message just like the FE if the
results are too big to render in one chart.
Fixes UXW-4040.
The timezone being dropped caused it to default to UTC, causing the
bug. With the `results_timezone` preserved the correct timezone is used
to render the x-axis and tooltip labels, achieving correct results.
Migrate the "{user} accepted their Metabase invite" admin notification
email from the legacy _header/_footer layout to the newer card-style
template (_dashsub_alert_header) already used by the new-device login
and transform-failure emails. Updates the sender to build the nested
context map the new template expects.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Make Snowflake data loaded checks look at row count.
We have some unidentified process that destroys the test-data dataset
and causes failures such as this:
https://github.com/metabase/metabase/actions/runs/28206004397/job/83561786972#step:5:381
Sometimes the database exists but the tables have double or triple the
expected number of rows.
First change: refuse to destroy the test-data database. This should
just never happen. If this ends up throwing, it should allow us to
identify where the deletions are coming from. (Unless the culprit is
someone calling DROP DATABASE directly.) Of course, we really should
be blocking *all* deletions of static datasets, but this is by far the
most commonly-used, and we don't have a good predicate for identifying
static datasets anyway.
Second change: don't consider a dataset loaded if it's been
double-loaded or triple-loaded. Previously we would just look at
whether the dataset's database existed and ignore the tables inside
it, but now we'll be able to see when it's been double-loaded and
treat this the same as if it had not been created at all.
Third change: when creating a database, ensure it gets dropped first
before creating. Without this, the previous change would just cause
the datasets to get loaded increasingly many times. This actually
reverts a change made previously in #74464, but we have a lot more
data now about what's happening, and making create-db into a no-op
when it already exists has turned out to be counterproductive because
of the double-loading issue.
* Remove parallel from database-local-settings-test
This fails in CI sometimes with the following error:
clojure.lang.ExceptionInfo: Error rolling back after previous error: (conn=362) Deadlock found when trying to get lock; try restarting transaction
This indicates it is probably not safe to do in parallel with other tests.
* GDGT-2533 Migrate text and syntax tokens
Sweep all consumer references from the legacy text/syntax tokens to their
new names (added in the foundation PR):
- text-tertiary -> text-disabled (and -inverse) — value shifts from solid
orion to orion-alpha (intended recolor)
- text-hover -> text-brand-hover (value unchanged)
- syntax-parameters(-active) -> text-syntax-parameter(-active)
- saturated-{blue,green,yellow,red,purple} -> core-{blue,green,yellow,red,
purple}-saturated (value unchanged)
`text-tertiary` and `text-hover` are also SDK public theme keys, so the
SDK_TO_MAIN_APP_COLORS_MAPPING right-hand sides gain the new internal names
as second targets and the public keys are left unchanged.
Per design review, the five saturated colors are renamed to
`core-{color}-saturated` palette primitives rather than `text-syntax-{role}`;
this PR adds those token definitions, removes the superseded text-syntax-*
ones, and keeps the legacy saturated-* definitions emitting until cleanup.
`text-secondary-opaque` is left untouched (open design question).
* Formatting
* Update Loki Snapshots
* Keep public SDK theme key text-tertiary in embedding examples/tests
text-tertiary is an embedder-facing SDK theme key (MetabaseColors), not an
internal token. The blanket sweep wrongly renamed it to the internal
text-disabled in three embedding spots that live outside the type-check
scope (e2e specs + the CLI scaffold snippet). Revert those to the public
text-tertiary; the SDK maps it internally via SDK_TO_MAIN_APP_COLORS_MAPPING.
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Migrate underlined Radio usages to Mantine Tabs
Replace the `underlined`-variant usages of the deprecated
`metabase/common/components/Radio` with Mantine `Tabs` from `metabase/ui`:
AccountHeader, the admin PermissionsTabs, and the BaseChartSettings section
picker. Mantine's default tab styling already matches the old underlined look
(neutral list divider + brand active underline).
Add a thin `metabase/ui` Tabs wrapper (replacing the bare Mantine re-export)
with a typed `listBorder?: boolean` prop. When `false` it suppresses the tab
list's own divider (default variant only), so a full-width divider can instead
be drawn on a parent container — avoiding full-width hacks and absolute
positioning. The active tab's underline is unaffected.
Adopt that pattern everywhere the list divider is customized, and migrate the
tabs that previously hid the divider with one-off CSS to use it, deleting their
CSS modules:
- PeopleListingApp, TenantsListingApp, SourceReplacementModal (were
`::before { display: none }`)
- AccountHeader and BaseChartSettings now draw the divider on a parent
Also tidy the permissions header layout (parent-drawn divider, toolbar back in
normal flow) and convert a leftover utility-class `div` to a `Box`.
Update BaseChartSettings tests from radio `toBeChecked()` assertions to tab
`aria-selected`.
Part of GDGT-2588 (PR 2 of 3).
* Update tests for radio -> tab structure
Fix tests that queried the migrated tab bars as radios:
- section picker (Scalar, viz-charts e2e): role="radio" -> "tab",
toBeChecked() -> aria-selected
- permissions tabs (CollectionPermissionsPage, admin-permissions e2e,
command-palette e2e, transforms e2e): role="radio"/"radiogroup" and
label-based selectors -> role="tab"
* Fix data-permissions e2e for radio -> tab structure
The "data permissions > warns about leaving with unsaved changes" test
selected the Collections permissions tab via a label selector; the tab is
now a role="tab" button.
* Reflect listBorder in Tabs stories; show focus outline
- Add the `listBorder` prop to the Tabs Storybook stories (boolean control +
a dedicated "Without list border" story).
- Show a keyboard focus outline on tabs via `:focus-visible` instead of
suppressing it, for accessibility.
Consolidate dashboard sidebar-switch repros, drop flaky 27356 block
Fold the parameterized -> non-parameterized sidebar-switch coverage into
the better-asserted 26230 sticky-positioning test by making its switch
target a param-less bookmarked dashboard, and remove the redundant,
weakly-asserting flaky block.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* Add transient creationType field to UnsavedCard type
* Type STRUCTURED_QUERY_TEMPLATE and allow null source-table
* Declare dev-only __lib_query/__lib_metadata/Lib window globals
* Accept null/undefined collection in collection type guards
* Remove @ts-nocheck from metabase-lib Question
Open the navbar inside assertTrashSelectedInNavigationSidebar
Routes like a dashboard or question collapse the navbar, so the assertion
needs it open first. Two of the four call sites opened it manually while the
other two relied on the route not collapsing it. Fold the open into the
assertion helper so every call site is covered uniformly, and drop the now
redundant standalone calls. openNavigationSidebar is idempotent and self-heals
against a pending collapse, so calling it unconditionally is safe.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Navigating to a dashboard/question collapses the navbar, and that collapse
can land a beat after the page content renders. The previous helper took a
single non-retrying snapshot of the sidebar's open state, so it could read a
lingering-open navbar, skip the toggle click, then watch it collapse out from
under the be.visible assertion. Re-check after acting and re-open if it
collapsed, bounded to a few attempts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add ExplicitSize test for measuring a child that renders content after mount
PivotTable returns null on its first render, so ExplicitSize has nothing
to measure at mount. The regression was that once it rendered content,
its size was never recalculated without a resize event, leaving the
child stuck at null dimensions. Cover that contract directly at the unit
layer rather than through a flaky e2e viz-switch repro.
* Delete e2e repro for 51926
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test forced email-configured?=true with a response-modifying intercept
on GET /api/session/properties; a reload aborting an in-flight refetch made
the callback throw "Socket closed before finished writing response". Set
email-smtp-host server-side instead so email-configured? is genuinely true.
* Add monitor dir in metabase-enterprise
* GDGT-2676: Migrate Dependency Diagnostics under Monitor space
* GDGT-2677: Migrate Admin -> Tools under Monitor space
* GDGT-2676: Fix paths
* GDGT-2676: Create separate high-level directories for migrated features
---------
Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
After publishing, the active-path effect in useExpandedState auto-expands
the database. The test then clicked the (already-expanded, active) database
row, which only toggles expansion and collapses it again — so the schema
fetch never re-fired and the table rows disappeared, causing the getSchema
wait timeout and the missing [data-type="table"] failures.
Re-open Data Studio for a clean, collapsed tree before expanding the
database, matching the reliable pattern the sibling tests already use.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Make workspaces deletable from any database state (GHY-3954)
delete-workspace! only deprovisioned :provisioned rows, but the model-level
guard rejects any non-:unprovisioned row. A WorkspaceDatabase stuck in
:provisioning or :deprovisioning (crash mid-flight, or a failed warehouse
teardown) made the workspace permanently undeletable with a 409.
delete-workspace! now drives every active row through a best-effort,
delete-only teardown (force-teardown-for-delete!) that works from any state:
it reconstructs the iso schema/user names deterministically from the row id,
drops them idempotently, always clears app-DB TableRemapping rows so query
routing is never left dangling, and forces the row to :unprovisioned. When the
warehouse is unreachable the workspace is still deleted; the 200 response then
carries :orphaned_resources and a :message naming the inert schema/user left
behind and why.
Recovering a stuck row without deleting (startup reconciliation / admin
force-unstick) remains GHY-3550.
* Surface orphaned warehouse resources in logs and the delete UI
Log a per-row warning (with the exception) when warehouse teardown fails
during delete, plus a consolidated warning carrying the full human-readable
orphan summary.
On the frontend, type the delete response (DeleteWorkspaceResponse) and, when
the workspace was deleted but warehouse cleanup partly failed, show a warning
toast naming the leftover schema/user objects and why they couldn't be removed
— the response is otherwise silently discarded since delete returns 200.
* Gate workspace delete on pending databases with an ignore-pending flag
A database still :provisioning/:deprovisioning may have warehouse work in
flight, so delete-workspace! now refuses by default (409, :pending_databases
lists them) rather than tearing such rows down. The new ignore-pending? arg
(ignore-pending query param, default false) leaves those databases' warehouse
resources untouched and removes only their app-DB rows. :provisioned rows are
still torn down, and the orphaned-resources reporting for unreachable warehouses
is unchanged.
On the frontend, the delete modal fetches the workspace's databases, and when
any are pending it swaps in a warning that lists them by name and confirms with
ignore-pending=true.
* Mock get-workspace in WorkspaceItem delete-modal test
The delete modal now fetches the hydrated workspace when opened, so the
'opens the delete modal' test hit an unmocked GET /api/ee/workspace-manager/:id.
Check dynamic dataset in do-with-dataset-definition/with-transform-cleanup!
Monitoring failures for bigquery/snowflake on master, we're seeing
these are still trying to do creation/deletion of datasets and
occasionally failing on bigquery and snowflake.
These are specific to the tests for transforms and actions.
Long term we want to make it so this can work reliably. Short term we
just need the tests to fail less when blocking releases and allow us
to unquarantine sooner.
* Consolidate usage-auditing row-chart drill-through e2e flake fixes
clickRowChartBarForLabel coordinate-clicked the chart container at a blind
labelRect.right + 30 offset, which intermittently landed on empty SVG so the
drill-through never fired (cy.wait("@conversations") timed out) or a near-miss
drilled a neighboring group. Two things conspired: the page only waits for the
audit metadata so the chart's dataset query + render could still be in flight,
and row charts measure their size asynchronously (ExplicitSize, throttled) and
first render with zero-width bars — assertChartRendered passes in both states.
Anchor on assertChartRendered, then click the measured (non-zero-width) bar
whose vertical band contains the label's midpoint, asserting exactly one match.
Cypress re-resolves the element's center at click time and retries for
actionability, so the click can't miss.
selectUserFilter anchored on `url includes "user="`, a no-op after a
drill-through since the URL already carries the drilled user, so it didn't gate
on the filter actually switching. Anchor on the select's value instead,
mirroring selectDateFilter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Use graphics-symbol role query for row-chart bar drill
Swap the ad-hoc `[aria-roledescription="bar"]` attribute selector in
clickRowChartBarForLabel for the a11y query cy.findAllByRole("graphics-symbol"),
matching the conventions' preference for role queries over CSS attribute
selectors in spec files. Functionally equivalent — both resolve the same ECharts
row-chart bars within the scoped chart card.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Give the chart-render assertion a budget for the ECharts mount
The usage-stats page waits only for the audit metadata before rendering; each of
its six charts then fires its own adhoc /api/dataset query and mounts ECharts
independently. getChartCard resolves as soon as the chart title paints (a
Skeleton placeholder), but the [data-testid="chart-container"] only mounts once
that card's data query returns. With all six mounting at once under CI CPU
contention, the container mount routinely overran Cypress's default 4s budget,
surfacing as "Unable to find an element by [data-testid=chart-container]".
Give the chart-container readiness assertion a 12s budget (CHART_RENDER_TIMEOUT)
so it waits out the ECharts mount instead of racing it. This hardens every chart
assertion in the spec, including the assertChartRendered anchor the drill-through
helpers rely on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix model filter dropdown lost on serdes sync (GHY-3946)
A dashboard/model filter mapped to an MBQL model column rendered as a
text-search box instead of a value dropdown after the content was synced
via serdes (git sync).
Root cause: a model's result_metadata is serialized with overrides only
(display_name, semantic_type, ...); the structural :id that links each
column to its underlying Field is dropped on export and never restored on
import, because populate-result-metadata stores passed-in metadata
verbatim during deserialization. Without that link the column can't
resolve has_field_values, so the widget falls back to text search.
Fix: add a generic serdes load-finalize! lifecycle hook that runs after
every entity is loaded (when the referenced Tables/Fields are present),
and implement it for Card to re-derive an MBQL model's result_metadata
field links from its query while preserving the serialized overrides.
This keeps the serialized form unchanged (no :id in the YAML) and avoids
the load-ordering fragility of re-inferring at insert time.
* Drive model metadata restore through model-preserved-keys
Rework the Card load-finalize! to mirror the export: recompute the fresh
columns from the query, then overlay the serialized overrides using the
same lib/model-preserved-keys set (with this model's native flag) that the
export uses to decide what to keep. This replaces the opaque
infer-metadata-with-model-overrides call and removes the separate native
guard — native-ness now flows through model-preserved-keys, so native
models (whose infer-metadata yields nil) keep their already-complete
serialized metadata untouched.
* Remove comments on native models in card.clj
Removed comments about native models and infer-metadata.
* Switch to serializing model result_metadata :id as a portable ref
Carry each model column's :id through serdes as a portable field
reference (via *export-field-fk*), instead of recomputing it on load.
serdes already knows how to remap it on import (import-result-metadata),
and its FK resolution handles load ordering. This drops the load-finalize!
hook (and its serialization/load plumbing) in favor of a one-key change to
the model result_metadata keep-set.
* Re-derive model result_metadata :id on deserialization
Instead of serializing the model column :id (reverted), restore it on
import: when deserializing a model, populate-result-metadata re-derives
the result_metadata from the query (recovering each column's :id link to
its underlying Field) and blends the serialized overrides on top. This
assumes the referenced Fields are already present on the destination; if
they aren't resolvable, it falls back to the passed-in metadata.
Add a mandatory closing section to the e2e-test-review skill that classifies
each it() block as load-bearing e2e, narrowable, or a unit/component-test
candidate, grounded in which layers the test actually exercises. Wire it into
the review process and final-check steps so every report includes it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'should create collections and save transforms to them' e2e test
created a nested transform collection and then clicked the parent row's
name to expand it. The row only toggles when row.getCanExpand() is true,
i.e. once the post-create collection-tree refetch has landed and
re-rendered with the new child. Clicking the name before that re-render
was a no-op, leaving 'Q4 Reports' hidden until the 4s findByText timed
out — a race that surfaced on faster (unthrottled) runs.
Waiting on the GET /api/collection/tree refetch wasn't enough: the
re-render can lag the network response. Instead, wait for the row's
Expand control, which the TreeTable only renders once the row is
actually expandable, and click that. Its presence is the deterministic
signal the child has landed, and it only ever expands (never toggles a
collapse), removing both the readiness race and the toggle ambiguity.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Replace deprecated Radio with metabase/ui Radio (normal variant)
Migrate the four `normal`-variant usages of the deprecated
`metabase/common/components/Radio` to Mantine's `Radio.Group` + `Radio`
from `metabase/ui`:
- query_builder LimitPopover
- ActionCreator FieldSettingsPopover (input type)
- ValuesSourceTypeModal (parameter value source)
- enterprise EditSandboxingModal
Mantine's `Radio.Group` only handles string values, so the two non-string
cases are adapted at the radio boundary:
- EditSandboxingModal: boolean options -> "column"/"saved-question"
- ValuesSourceTypeModal: the null source type ("From connected fields")
-> a sentinel string via serialize/deserialize helpers, keeping
`ValuesSourceType` (incl. null) as the domain type everywhere else
Part of GDGT-2588 (PR 1 of 3).
* Adjust visual alignment
* Preserve search relevance order for command palette actions (#76055)
The command palette re-sorted basic action results by a static
BASIC_ACTION_ORDER array, discarding the relevance order kbar's
useMatches already computes via Fuse.js. Since basic actions only
render when there is a search term, this static sort never served as a
useful default ordering and only buried the best match (e.g. typing
"New c" pushed "New collection" below "New question").
Drop the static re-sort so kbar's relevance order is preserved, and
remove the now-unused BASIC_ACTION_ORDER constant and processSection
sortOrder parameter.
* Update e2e test
* Make command palette nav test independent of action relevance order
* Try fixing e2e
* Fix test
* Tie-break command palette actions by default order via kbar priority
Rather than dropping the default action order entirely, encode it as a
small kbar `priority` so it only orders actions whose relevance scores
are equal. Typing "New" still surfaces "New question" first, while a
stronger match like "New c" surfaces "New collection" (metabase#76055).
Adds unit coverage for both cases and restores the concrete navigation
assertions in the palette specs.
* Rank palette actions by relevance with registration-order tie-break
Type a query and the command palette now orders basic actions by kbar's
search relevance, falling back to registration order only to break ties
between equally-relevant matches (via a tiny per-action priority). This
fixes 'New c' burying 'New collection' while keeping 'New question' first
for the bare 'New' query.
Also drop the redundant 'new embed' and 'new SQL' keywords: each merely
duplicated words already in the action's name, but distorted Fuse scoring
for short prefixes (ranking 'New embed'/'New SQL query' above the plain
create actions for 'New'). They remain findable by name.
* Restore PageDown-based palette nav assertions with updated order
* Widen isDimension/isMetric and formatField parameter types
* Fold Base into Field and remove @ts-nocheck
* Type the Field constructor input and keep runtime-only fields on the class
* Use the API field directly in MappedFieldPicker; document Field hydration
* Move Field instance fields onto the class; drop vestigial JSDoc type comments
* Remove _comesFromEndpoint reference in clojurescript
* Take a NormalizedField in the Field constructor (removes constructor casts and lies)
* Move comment
* Drop dead SOFT_RELOAD_CARD branch from questions reducer
* Hydrate questions slice via RTK endpoints, remove INJECT reducer
* Hydrate metadata store from entity mutation endpoints
* Revert "Hydrate metadata store from entity mutation endpoints"
This reverts commit fc20ae580a.
* Fix flaky data-studio bulk "syncing multiple tables" e2e test
The "syncing multiple tables" test restores the postgres-writable snapshot
in its body but, unlike every sibling test in this spec, never re-signs in
afterward. Visiting Data Studio could then land in an unauthenticated state
where the TablePicker never issues the GET .../schema/public request, so
`cy.wait("@getSchema")` timed out after 5s with "No request ever occurred".
Add `cy.signInAsAdmin()` after the restore — the same deterministic
re-authentication the reliable sibling tests already perform — so the
database expands and the schema request fires before the wait.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix flaky "syncing multiple tables" e2e test: read table IDs deterministically
The test derived the expected table IDs from the intercepted `@getSchema`
UI response. Under stress that response body is occasionally a truthy
non-array (e.g. an error map), so `tables.find` threw
`TypeError: tables.find is not a function`. Read the table list from a
direct `cy.request` instead, which deterministically returns the array,
and keep `cy.wait("@getSchema")` purely as a UI-readiness wait.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix flaky resize-column-width e2e test with real-events drag
The read-only column-resize test hand-rolled a synthetic mousedown/
mousemove/mouseup sequence plus a magic cy.wait(10). The TanStack resize
handle re-renders while the table measures itself, so the synthetic
events intermittently landed on a stale node and the drag was lost (or
only partly applied), leaving the column width unchanged.
Drive the drag with cypress-real-events (CDP-level input) and re-query
the resize handle for every event so each one targets the live node,
matching the proven pattern in notebook-native-preview-sidebar. Drop the
numeric wait in favor of the existing retrying assertion.
* Harden flaky read-only column resize e2e with bounded drag retry
The synthetic TanStack column-resize drag in saved.cy.spec.js was
occasionally lost or only partially applied because the resize handle
re-renders while the data grid measures/auto-sizes itself, so the
width assertion would time out (e.g. expected 72 to be at least 82).
A prior cypress-real-events rewrite did not resize the 4px, hover-only
handle at all. Revert to the proven synthetic drag and re-run it until
the column has actually widened (bounded, gte-checked, idempotent), so
a dropped drag becomes a non-event instead of a failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Stabilize pivot dashcard drill flake in dashboard-filters-misc
The 'does not use extra filtering stage for pivot tables' test drilled into
the pivot dashcard via its legend caption immediately after a raw
cy.button("Save").click(), without waiting for the dashboard to leave edit
mode. The drill only navigates in view mode, so when the click landed during
the save/edit-to-view transition it did nothing and /api/dataset/pivot never
fired, timing out cy.wait("@datasetPivot").
Use H.saveDashboard(), which waits for the save PUT, dashboard reload, and the
edit bar to disappear (view mode) before proceeding, so the drill reliably
triggers the pivot request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Exit dashboard edit mode without awaiting a save PUT in pivot filter test
The 'does not use extra filtering stage for pivot tables' test only inspects
parameter mapping options (verifyDashcardMappingOptions opens the picker and
clicks away); it never creates a mapping, so the dashboard stays clean and
clicking Save issues no PUT /api/dashboard/*. The prior H.saveDashboard() call
waited unconditionally on the saveDashboardCards request, which only fired on
the runs where the dashboard happened to be dirty, timing out otherwise.
Pass awaitRequest: false so saveDashboard clicks save and waits for the edit bar
to disappear (view mode) without awaiting a PUT that may never occur. This still
guarantees the following legend-caption drill runs in view mode, where it
reliably triggers the pivot dataset query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The usage-auditing seed timestamps the "today" conversations at
`today - {15min, 30min, 2h}` where `today` is the current instant in
UTC. When the e2e suite runs shortly after UTC midnight, those events
roll into the previous UTC day, while the reported `:date` (the UTC
calendar day) advances to the new day. The "Conversations by day"
chart's last dot is then yesterday, so drilling navigates to
`date=<yesterday>` and the assertion `search` includes `date=<today>`
times out (e.g. `'?date=2026-05-25' to include 'date=2026-05-26'`).
Anchor `today` at noon UTC so every seeded "today" event stays firmly
inside the current UTC day regardless of run time. Relative offsets and
all per-hour relationships between the seeded conversations are
preserved.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix click-behavior tab-fallback flake: await target dashboard load before save
The "fall back to the first tab" test saved the dashboard immediately after
opening the click behavior sidebar, gated only by a negative assertion that
the tab selector is absent. That assertion passes trivially before the target
dashboard's fetch resolves, so the save raced ahead of the `migrateDeletedTab`
effect that falls the invalid tabId back to the first tab — the only thing
that dirties the dashboard. With no change registered, the PUT never fired and
`cy.wait("@saveDashboard-saveDashboardCards")` timed out.
Anchor on a positive signal (the click-mappings heading, rendered once the
target dashboard has loaded) before asserting the tab selector is absent, so
the migration has run and the dashboard is dirty by the time we save.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "allows publishing and unpublishing multiple tables" test clicked the database row to expand it and then waited on the schema fetch. The picker tree is still mounting when the databases request resolves, so the click on the row landed before its expand handler was wired and was dropped — the GET .../schema/public?include_hidden=true request that populates the tables never fired, timing out cy.wait("@getSchema") with "No request ever occurred" (and downstream, the [data-type=table] rows never rendered).
Click the database's dedicated expand toggle once it has rendered in its collapsed state (aria-expanded=false), then assert it reaches the expanded state. The pre-click assertion anchors on a positive readiness signal so the toggle is fully interactive before we click it, making the schema fetch fire deterministically.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix flaky dashboard filter card-reload e2e test
The "should not reload dashboard cards not connected to a filter" test
re-aliases the dashcard query intercept after saving the dashboard, then
asserts exactly one filter-triggered card query fires. Saving exits edit
mode and reloads both dashcards, but the test waited for only one of those
two post-save queries. The unwaited second query, slow under network
throttling, arrived after the intercept was re-registered on the same URL
and got counted as a filter-triggered query, so the assertion saw 2 instead
of 1.
Wait for both post-save card queries (total of four) to settle before
re-aliasing, so only the genuine filter-triggered query is counted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Settle dashboard reload by loading state, not query count
The flaky dashboard filter card-reload test re-aliased the card-query
intercept right after saving, then asserted only one filter-triggered
query fired. A post-save reload query could still be in flight and get
captured by the new intercept, inflating the count.
The previous attempt waited for a fixed number (4) of post-save queries,
but how many fire is not deterministic — cached card results may be
reused, so the total varied (3 vs 4) and the wait failed.
Wait instead for both dashcards' loading indicators to clear before
re-aliasing, which drains the post-save reload regardless of how many
queries it issued.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dependency graph is recomputed asynchronously on entity create/update,
so waitForBackfillComplete (global one-time flag) doesn't guarantee freshly
seeded entities have surfaced in the unreferenced list. Visiting too early
renders an incomplete list and findByText times out.
Replace the per-test ad-hoc waits with a single waitForUnreferencedAnalysis
helper that polls the unreferenced endpoint until every seeded entity (table
included, via the slower DB-sync path) is present. Apply it to all ten
presence-asserting tests, including five that shared the same race but were
never individually patched. The lone absence-asserting test keeps the global
backfill wait, since presence can't be polled for entities that should be
absent.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Revert to returning unimported fk when importing fk fails
* Only ask for dashboard_id, for perf and to avoid pulling in problematic dangling refs in visualizer settings
* Create HNSW semantic-search index just-in-time, only when configured
The semantic-search index table always got an HNSW index on its embedding
column, regardless of whether anything used it. HNSW indexes are expensive
(build time, memory, disk), and a cluster hosting thousands of instances paid
that cost for every one even though ~99% are fast enough with an exact scan.
Flip the default vector-search strategy to :brute-force (exact, no index) and
only build the HNSW index when an instance is actually configured for :hnsw:
- create-index-table-if-not-exists! builds the HNSW index only when the
configured strategy is :hnsw (covers env-var config and restarts). Extracted
into idempotent create-hnsw-index-if-not-exists! (supports CONCURRENTLY).
- Setting the strategy to :hnsw at runtime triggers an async background build
on the active index table (via core/build-hnsw-index-async!), so the settings
call returns promptly.
- A query that requests :hnsw (e.g. a per-request override) when no HNSW index
exists now fails fast with a clear error, rather than silently doing a slow
exact scan.
* Add clj-kondo ignore marker to index-relfilenode test helper
Matches the sibling pgvector test helpers; suppresses
:metabase/test-helpers-use-non-thread-safe-functions.
* Build HNSW index only on the transition into :hnsw; test trigger and build
The setter previously kicked off a background build on every set to :hnsw,
including the restore that with-temporary-setting-values does. Gate it on the
actual transition (old != :hnsw) so redundant sets don't spawn stray builds.
Tests, split so neither relies on a real (flaky) future:
- index-test asserts the setter *triggers* a build only on the transition into
:hnsw (spying on the async build).
- pgvector-api-test asserts ensure-active-hnsw-index! actually *builds* a missing
index, and is a genuine noop when the index already exists (stable relfilenode)
or when there is no active index.
* Build HNSW index via event instead of requiring-resolve; add startup safety net
* Test the indexer task's startup HNSW safety net
The "allows to edit attributes for tables" test clicked the database to
expand it and then immediately queried for table rows, without waiting
for the schema/tables to load. The tree picker is populated by an async
GET .../schema/public request, so the `[data-type="table"]` rows were
sometimes not yet rendered when the assertion ran, timing out after
4000ms.
Add `cy.wait("@getSchema")` after expanding the database — the same
deterministic wait the sibling tests in this spec already use — so the
tables are guaranteed to be present before selection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two auto-wire suggestion toasts (Orders Question + Reviews Question)
stack and briefly overlap while the undo list re-measures heights and
animates them into their final positions. During that window a sibling
toast can cover the precisely-scoped Auto-connect button, so cy.click()
fails its actionability check with "covered by another element".
Force the two Auto-connect clicks past the transient obstruction; the
target buttons are already uniquely scoped via
.contains(...).closest("toast-undo").
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "preserve zoom and pan after resize" test reads the first leaflet
marker position before and after a 1px viewport resize and asserts the
two are within 5px. The settle helper only required two consecutive
should() retries to land within 0.5px. Cypress retries faster than the
browser repaints, so two reads can fall in the same animation frame and
return an identical getBoundingClientRect() mid-animation, a false settle
that captured the after-zoom baseline before leaflet finished animating,
producing the historical 8-29px gaps.
Anchor the settle on elapsed wall-clock time (performance.now) instead of
read-count: require the marker to hold within 0.5px of an anchor for at
least 200ms before sampling, which a single-frame coincidence cannot
satisfy.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker resolves its initial path asynchronously before auto-navigating
into the data source's collection: a card-metadata fetch plus one
listCollectionItems request per collection level. On a throttled CI runner
that serial chain outlasts Cypress's default 4 s command timeout, so the
clickPickerItem lookup gives up while the picker is still showing the root
collections — the dominant "cannot find menuitem 'QA Postgres12 Orders
Model'" failure.
Give the picker-item lookup a generous timeout so it waits for the path to
finish resolving, and assert visibility before clicking. Also drop the stale
comment referencing a force-click that was already removed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* GDGT-2532 Migrate background-* tokens to background_page / background_surface
Sweep all consumer references from the legacy `background-*` semantic
tokens to their `background_page-*` / `background_surface-*` counterparts
(added in the foundation PR):
- Page: background-primary/secondary/tertiary (+ -inverse), -highlight
(-> -highlighted), -filter -> background_page-*
- Surface: background-disabled (+ -inverse), -hover, -selected, -brand
(-> -brand-subtle), -success, -warning (+ -secondary -> -strong),
-error (+ -secondary -> -subtle) -> background_surface-*
- Replaces: background-info -> background_page-tertiary;
background-success-secondary -> feedback-positive-selected
SDK public theme keys (background, background-secondary, -hover, -disabled,
-error, -light) are part of the embedder-facing contract and are left
unchanged; the SDK_TO_MAIN_APP_COLORS_MAPPING right-hand sides gain the new
internal names as second targets so SDK/whitelabel overrides reach consumers
that now read the new tokens. Alert.config.tsx's `background-${color}`
synthesis is replaced with an explicit lookup map.
Drive-by fixes (pre-existing, surfaced by the sweep):
- EmbedFrame.module.css: fix `-inverseer` typo (undefined var since #65855).
- Bare `var(--mb-color-background)` was never a real token (rendered
transparent at 8 sites); point each at the correct token by role and
remove the dead `--mb-color-background` definition in LogoBadge that was
silencing find-never-defined-css-variables.
* Use surface background tokens for Switch component
The Switch is a control (a surface on the page), so its track/thumb
backgrounds should use `background_surface-*` rather than `background_page-*`.
Disabled states now use the purpose-built `background_surface-disabled`;
the enabled off-track uses `background_surface-secondary`.
* Remove redundant comment
* Use text-secondary for RowChart axis text
The axis tick + title text was borrowing `background_page-tertiary-inverse`
(a background token) for its color. That token recolors as part of the
background migration, which shifted the axis text. Point it at the proper
`text-secondary` token instead — also consistent with the goal label.
* Update Loki Snapshots
* Formatting
* Update Loki Snapshots
* Update dark-mode body background assertion for recolored page-secondary
The body background uses `background_page-secondary`, which this migration
intentionally recolors in dark mode (color-mix off the page primary). Update
`assertDarkMode` from rgb(5, 14, 21) to rgb(5, 17, 25) (#051119) to match.
Light mode is unchanged.
* Assert dark-mode body background as color(srgb ...) computed value
The body background is a color-mix(in srgb, ...), which browsers serialize
in computed style as color(srgb ...). Assert the exact computed value
instead of an approximated rgb().
* Formatting
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* Migrate SDK execute-action to api.request
* Use updateSetting RTK hook in test render helper
* Remove legacy API client
* Flatten ApiClient request dispatch into a single _send
* Call updateSetting hook inside the test store provider
* Use RTK query for executeAction in embedding SDK bundle
* Fix embedding SDK on-demand chunk loading via runtime publicPath
The backend-hosted embedding SDK bundle shipped with output.publicPath: ""
and never set a runtime publicPath, so any dynamic import()/React.lazy chunk
URL resolved against the host app's origin (where SDK chunks don't exist) and
failed with ChunkLoadError.
Set webpack's runtime publicPath to the Metabase-hosted asset directory
(app/embedding-sdk/) so on-demand chunks load correctly:
- sdk-public-path.ts resolves the asset base and assigns __webpack_public_path__;
it is the first import in the entry so it runs before any import().
- The chunked bootstrap exposes the base on
window.METABASE_EMBEDDING_SDK_ASSET_BASE_URL (the entry runs asynchronously,
so document.currentScript is unreliable there).
- The legacy monolithic bundle has no bootstrap, so the resolver falls back to
document.currentScript.src, which is valid during its synchronous evaluation.
The backend already serves any app/embedding-sdk/chunks/*.js generically, so no
backend change is needed.
* Load jspdf and html2canvas lazily in the embedding SDK bundle
These export-only dependencies (~180KB brotli) were force-imported eagerly in
sdk-specific-imports.ts to work around the broken on-demand chunk loading. Now
that the runtime publicPath is set, drop the eager imports so they load on
demand (PDF / image export) instead of weighing down the SDK's critical path.
* Load pivot table grid lazily in the embedding SDK bundle
* Load map renderer lazily in the embedding SDK bundle
* Count async SDK chunks toward total bundle size now that they are loadable
The "back to the dashboard button when navigating to a question" test
clicked "Edit question" from a dashcard and immediately clicked the
"Back to <dashboard>" button without waiting for the notebook editor to
finish loading. Clicking back before the query builder settles produces
a racy back-navigation that can briefly leave the dashboard without a
mounted app bar, so the later `appBar()` lookup ("Navigation bar")
intermittently fails while the dashboard header text is already visible.
Wait for the notebook editor's "Visualize" button to be visible before
clicking back, mirroring the sibling native-editor test and the
dashboard.cy.spec navigate-to-notebook test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "drills through from the conversations by hour/day chart" tests
interact with the timeseries chart immediately after visiting the usage
stats page, which only waits for the audit metadata request. The chart's
dataset query and ECharts render can still be in flight, so
cartesianChartCircle() races the render and intermittently times out
looking for [data-testid="chart-container"].
Anchor clickLastTimeseriesChartDot on assertChartRendered so the chart is
fully rendered (container visible with an svg child) before drilling.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test clicked a result cell immediately after saving a new model,
racing the table that renders/animates in after the redirect to the
model view (Cypress reported the cell as still animating). Wait for the
model create (POST /api/card) and its query (POST /api/dataset) to
resolve and assert the table is visible before clicking the cell, so the
click no longer depends on animation timing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "should duplicate filters and mappings when duplicating a dashcard"
test began interacting with the dashboard parameters (clicking the
"Count" pill) immediately after entering edit mode. Entering edit mode
re-mounts the dashcards and re-runs the card query, so the parameter
header was still re-rendering asynchronously when the click fired,
detaching the target element mid-click ("cy.click() failed because the
page updated while this command was executing").
Gate the first interaction on the dashcard's chart being visible so the
page has settled before editing parameters.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "unsubscribe from an alert when the user has not created it" test
scoped its post-unsubscribe negative assertion inside the
notifications-list container and never waited for the unsubscribe
request. Since admin was the only recipient and not the creator,
unsubscribing empties the list and the notifications-list container
unmounts into the empty state. When the refetch won the race,
findByTestId("notifications-list") timed out.
Wait deterministically on the unsubscribe POST, anchor on the success
toast, then assert the notifications-list is gone (the real
post-condition) instead of querying inside a container that disappears.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When export! or import! falls back from the incremental fast-path to a
full re-serialize/reload, log which guard forced it so operators can see
why a full sync ran:
- export: forced overwrite, or a pending change can't be applied incrementally
- import: forced, local changes pending, first import, or changes not
incrementally loadable
Bind import dirty? as a delay so the fast-path check and the reason
derivation share one query without forcing it on the no-op/merge paths.
* Add metabase.agent-lib.representations.repair/rewrite-lib-type-aliases*
Rewrite the hallucinated lib/type mbql.join/join to the corrected mbql/join. The LLM was observed making this error in BOT-1657.
Similar to the existing rewrite passes:
- rewrite-operator-name-aliases*
- rewrite-temporal-bucket-aliases*
- rewrite-direction-aliases*
* Fill in a missing lib/type for join clauses
We already did this for stages and the top-level query.
* Validate lib/type for ::join clause in metabase.agent-lib.representations/validate-query
* Mark all deftests ^:parallel in metabase.agent-lib.representations-test
* account for extra padding in bar width
* add loki test
* Update Loki Snapshots
---------
Co-authored-by: Metabase Automation <github-automation@metabase.com>
* restrict permissions for glossary
* disable on UI
* adjust FE code + removed hover effect if the row is readOnly
* rename FieldId to GlossaryField
* address comments
---------
Co-authored-by: Egor Grushin <egor@metabase.com>
Serializing the **import** of a very large data model (~1M+ fields) can OOM. The entire retained-memory cost of an import is the **ingest index**, built up-front before any entity loads (a flat memory plateau through the whole load phase).
Fixes:
1. **Drop the dead structures.** The cache value becomes the `File` directly (`{stripped-path File}`); delete `:from-ids` and its `medley` require, removing the second `ingest-list` pass.
2. **Intern path segments while building the index.** Each `{:model … :id …}` segment and its strings are canonicalized through a shared interner so index keys share their `[db schema table]` prefix structure. Value equality is unchanged, so cache lookups and `:seen`-set comparisons behave identically. Interning goes through a new `metabase.util.memoize/fast-interner` - a thread-safe `ConcurrentHashMap`-backed interner that stores the *canonical* value as the key (so the retained object is the string-pooled copy, not the parsed input).
* Lazy load Echarts renderer
* Load Echarts as the vizualisation loads
* Use correct fallback for echarts renderer
* Fix placeholders for some skeletons
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Eagerly load echarts in sdk bundle
* Reenable brush after chart loads
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-23 16:24:56 +10:00
13076 changed files with 2084633 additions and 1563213 deletions
For tests of the config itself, use `./bin/test-agent :only '[metabase.core.modules-test]'`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
For tests of the config itself, use `./bin/mage project-tests modules`. After editing Clojure files, run `clj-paren-repair`. After editing `config.edn`, re-run the modules-test.
**Update your agent memory** as you discover idioms: which modules use `:friends` and why, which models are most-imported (suggesting they belong in their own module), which settings are accessed cross-module via `(setting/get …)` instead of `:uses` deps, and which refactors the team is actively running.
@@ -27,9 +27,28 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
- Format using Markdown conventions
- Reference other vars with `[[other-var]]` not backticks
A docstring states the *contract*: what it returns (including when it returns nil), what its
params mean, what it throws versus swallows, and any precondition or invariant the caller must
respect. A params block for non-obvious or optional args is good documentation — length is not
the problem.
These seven things are, and each is a reason to cut or relocate:
| Anti-pattern | Why | Where it goes instead |
| --- | --- | --- |
| **Facts you don't own** — how a *callee*, another namespace, or another repo works internally | Drifts silently; nothing fails when it goes stale | Cut. Name the callee and let its own docstring say it |
| **Said twice** — ns docstring restating its vars', a caller restating its callee's, sibling fns repeating boilerplate | Two copies means one is already wrong | Cut the copy. One fact, one owner |
| **Design rationale** — why this approach, why it lives in this module, what the alternative was | The caller can't act on it | Commit message, or an inline comment on the specific line it constrains |
| **Development history** — "matching master", "the X branch", "currently", "for now", "used to" | Stale by construction | Cut |
| **Instructions to the reader** — "callers should trust this", "don't bother checking", "you should use X" | State the contract; the caller decides | Cut, keeping any actual guarantee as a plain statement |
| **Invented precision** — score bands, thresholds, or tables no code enforces | Reads as spec, isn't | Cut, or make the code enforce it |
| **A note on the wrong var** — describing how overrides behave, on the base/default impl | Nobody reading the override sees it | Move to the overrides it describes |
**Comments:**
-`TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
- Prefer an inline comment over docstring prose for anything that explains *the body*. Put it on
the line it explains, not at the top of the function.
## Code Organization
@@ -285,12 +304,13 @@ Tag response Malli `:map` schemas with `{:closed true}` so the generated OpenAPI
**Linter Suppressions:**
The right shape is a **reader-discard map** placed directly above the form being silenced, listing the rule keywords explicitly:
The right shape is a **reader-discard map** placed directly above the form being silenced, listing the
rule keywords explicitly and including the required explanation:
For a single require entry, hang it as metadata on that entry:
@@ -301,7 +321,12 @@ For a single require entry, hang it as metadata on that entry:
[metabase.query-processor.store:asqp.store])
```
Common Metabase-specific rules you'll see: `:metabase/modules`, `:metabase/validate-defendpoint-route-uses-kebab-case`, `:metabase/validate-defendpoint-has-response-schema`, `:metabase/prefer-with-dynamic-fn-redefs`, `:deprecated-namespace`, `:discouraged-namespace`. Don't use the keyword form (`#_:clj-kondo/ignore`); always the map form so it's grep-able by which rule(s) are suppressed.
Use the map form, not the keyword form (`#_:clj-kondo/ignore`), so searches can identify the suppressed
rules. Keep `:clj-kondo/ignore` as the map's first key; the ratchet scanner rejects other orderings.
Fix the warning when possible. Adding a suppression is a last resort and requires approval. If one is
necessary, add an explanatory `;;` comment directly above it or at the end of the same line. CI enforces
these requirements with `./bin/mage kondo-ratchets`.
- 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
@@ -22,10 +22,11 @@ Before starting, determine which mode to use:
3.**(Conditional)** If after reading the spec a specific assertion or selector is **ambiguous** — you can't tell if it's the right anchor, or whether the test actually verifies what its title claims — briefly grep the related component for the test ID / role / text. **Don't read components by default.** Only do this when there's a real signal of confusion in the spec.
4. Scan against the checklist + pattern table below.
5. Number all issues sequentially. Skip nits — only flag what's worth fixing.
6.**Always** finish with the honest e2e-vs-unit breakdown (see the section of that name below). It's part of every report, not an optional add-on.
### When to hand off instead of review
If the spec references an issue (`metabase#NNNNN`) and the user wants to **fix** flakiness or assess whether the test still reproduces the original bug, that's outside the review skill's scope. Point them at `/fix-flakey-test` (or the dedicated flake-fixing workflow), which knows to fetch the issue and the resolving PR's diff. The review skill stays focused on "is this test well-written and conformant" — it doesn't fetch external issue context by default.
If the spec references an issue (`metabase#NNNNN`) and the user wants to **fix** flakiness or assess whether the test still reproduces the original bug, that's outside the review skill's scope. Point them at the dedicated flake-fixing workflow, which knows to fetch the issue and the resolving PR's diff. The review skill stays focused on "is this test well-written and conformant" — it doesn't fetch external issue context by default.
## Review checklist
@@ -194,8 +195,41 @@ Use the pending review workflow:
Each comment body starts with `**Issue N: [Brief title]**`.
## Honest e2e-vs-unit breakdown (always include)
Every report ends with this section. The numbered issues above are about whether the test is *well-written*; this section is about whether it should be an **e2e test at all**. They're orthogonal — a flawless spec can still be paying e2e prices for component-test value, and that's worth saying out loud.
The bar is simple: **an `it()` earns its place in e2e only if it exercises a layer you cannot fake without gutting the test's value.** In this codebase that means one or more of:
- a **real backend query against seeded data** (the assertion depends on the server actually filtering/sorting/bucketing rows, not on a mocked response);
- **real rendering you then interact with** — clicking a charted ECharts SVG element at computed coordinates, drag/hit-testing, drill-through;
- **routing across screens** — navigation, URL param round-trips that the backend then honors, browser back/forward;
- **cross-screen traversal** — list → detail → back, where the value is the seam between screens.
Conversely, a test belongs in **Jest + RTL** (the cheaper layer this repo already uses for component/unit coverage) when its real subject is frontend-only:
- **pure frontend logic** — a bucketing/formatting/derivation decision (e.g. "single-day range → bucket by hour") that has one right answer independent of the backend;
- **render assertions** — "the page mounts N titled cards and an SVG exists." A mocked dataset response + RTL verifies the titles; that an ECharts SVG renders is library behavior, not your logic;
- **state/wiring** — tab → `aria-selected` → active-state, or select → querystring mapping. The *URL gets the param* half is component-level; only the *backend honors the param* half needs e2e.
Write the breakdown in three honest buckets. Ground each call in **which layers the test actually touches**, not in how the test is named:
1. **Genuinely must-be-e2e (keep)** — load-bearing. Name the layer(s) only a browser + backend can verify together.
2. **Defensible-but-narrowable e2e** — justified as integration, but over-split or doing more boots than the coverage requires. Say what to merge.
3. **Should be unit / component tests** — where the real savings are. Name the cheaper layer and what it would assert (often *more precisely* than the e2e test does indirectly).
Then a one-paragraph **bottom line**: what to keep as e2e, what to push down, and — if you pulled CI timing from `e2e/support/timings.json` for the Performance check — roughly how much of that wall time is *earned* by the must-be-e2e set versus paid by the rest.
Rules of engagement so this stays honest, not performative:
- **Don't inflate the keep bucket to look balanced.** If most of the spec is component-test value paying e2e prices, say exactly that. If every test is load-bearing, say that too — a spec where all 12 tests must be e2e is a legitimate outcome, not a failure to find candidates.
- **Be specific.** Cite the `it()` titles and line ranges. "Several tests could be unit tests" is useless; "tests 1/17/18 assert only that N titled cards mount — RTL with a mocked dataset" is actionable.
- **This is advice to the author, not a blocking issue.** It's a recommendation, separate from the numbered issues. Don't renumber it into the issue list.
- **Stay rooted in what exists.** The cheaper layer here is Jest + RTL component/unit tests. Don't invent infrastructure the repo doesn't have, and don't recommend deleting backend-dependent coverage just because part of the test is frontend-only — split it, keep the integration half.
## Final check
1. Trim issues that won't materially help the author.
2. Verify sequential numbering with no gaps.
3. In PR mode, verify each issue was posted as a separate review comment.
4. Verify the honest e2e-vs-unit breakdown is present — it's required in every report, even when the answer is "all of these need to be e2e."
@@ -37,14 +37,16 @@ First check if snapshots exist:
ls e2e/snapshots/default.sql 2>/dev/null &&echo"snapshots exist"||echo"snapshots missing"
```
Always pass the spec path via `--spec`. The runner feeds its arguments to Cypress's CLI parser (`cypress.cli.parseRunArguments`), which silently drops bare positional paths and then runs the entire suite. A leading `run` argument is harmless, but the path itself must follow `--spec`. Sanity-check that the first `Running:` line says `(1 of 1)`.
If snapshots exist, skip regeneration for speed:
```bash
MB_EDITION=oss CYPRESS_VIDEO=falseCYPRESS_RETRIES=0CYPRESS_GUI=falseGENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
MB_EDITION=oss CYPRESS_VIDEO=falseCYPRESS_RETRIES=0CYPRESS_GUI=falseGENERATE_SNAPSHOTS=false bun test-cypress --spec path/to/spec.cy.spec.js
```
If snapshots are missing (first run), let the runner generate them:
```bash
MB_EDITION=oss CYPRESS_VIDEO=falseCYPRESS_RETRIES=0CYPRESS_GUI=false bun test-cypress $ARGUMENTS
MB_EDITION=oss CYPRESS_VIDEO=falseCYPRESS_RETRIES=0CYPRESS_GUI=false bun test-cypress --spec path/to/spec.cy.spec.js
```
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
@@ -67,9 +69,11 @@ GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
(The stress script forwards its arguments to `bun test-cypress`, so the same `--spec` rule applies.)
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
description: Deciding where frontend code lives — tier model, move mechanics, extension points, side effects, naming, and the traps. Use when moving code between modules, carving new modules, fixing boundary violations, adding a barrel or an endpoint, or reviewing module-shape decisions.
---
# Frontend modularization
The module is the unit of blast radius: dependency direction, test selection,
bundle cones, and review scope all follow module boundaries. Every decision
here serves one goal: **make the file locations tell the truth about who owns
what and who may depend on whom.**
## Hard rules (check these before anything else)
- NEVER add an `export … from` re-export at an old path, and NEVER `export *`
from a barrel. Move the code, codemod every call site, delete the old path,
all in the same PR.
- NEVER import past a module's `index.ts` when that module has
`enforcePublicApi: true`, and never past `metabase/ui`'s index at all. If
the barrel is heavy, the fix is making the module side-effect free (below),
not a deep import.
- NEVER rewrite a consumer to a lower-level idiom to make the linter pass. If
the fix makes the consumer worse, the plan is wrong.
- NEVER give a new module `enforceSharedTiers: false`; new modules ship
enforced with `enforcePublicApi: true` and an explicit `index.ts`.
- NEVER move code without the consumer-tier check (procedure step 2).
- ALWAYS delete a module's `enforceSharedTiers: false` line in the PR that
takes it to zero violations.
- ALWAYS measure `bun run module-boundaries` before and after, and report the
numbers.
- ALWAYS import `dayjs` from `metabase/dayjs`, routing from `metabase/router`,
Mantine from `metabase/ui`, react-redux hooks from `metabase/redux`; the
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
## The model
Tiers: `lib < basic < shared < feature < app`. Imports point downward, never
sideways at feature tier (features may not import features; app and EE may
import anything). The shared tier is subdivided in `shared-tiers.mjs` into
shared-utils (U0…), shared-platform (P0…), and shared-domain, each ordered
into levels. A module imports only *strictly lower* levels of its sub-tier
plus the sub-tiers below; same-level peers are deliberately forbidden, which
is what makes cycles structurally impossible. A domain may hold two seats
when consumers demand it: a low core and a high surface (metabase-lib below
@@ -31,6 +31,11 @@ Phases 1, 2, 5 and 6 are shared. The middle two differ in **order**: update mode
- **The component's intended API** — variants, sizes, and states the design system supports for this component. This defines the axes of your showcase (Phase 3).
3. Decide the mode (see **Two modes** above): search for an existing component — in `metabase/ui` or as a legacy component elsewhere. Found → **update**; nothing equivalent anywhere → **create**. Confirm with the user when unsure.
4.**Verify a Figma desktop MCP is connected before doing the work** — this skill depends on it for exact tokens in Phase 4 (and styling can't proceed accurately without it). Do a quick read against the issue's node (e.g. `get_metadata`/`get_variable_defs`); if it errors with "nothing selected" or the server isn't connected, prompt the user to enable it (Switch to Dev Mode → MCP section in the right sidebar) and select the relevant layer before continuing.
5.**Figma Desktop MCP environments only:** open the issue's exact page with [`figma-open`](./figma-open) before MCP reads. Do not run this helper when Desktop MCP is unavailable.
A URL with `node-id` needs no token. Bare page-name and `--list` lookups require an already-configured `FIGMA_TOKEN`.
## Phase 2 — Understand the component
@@ -205,7 +210,7 @@ Per `docs/developers-guide/frontend.md` ("Colors"): every color must be a **sema
The same discipline applies to **dimensional** values — radius, spacing/padding/gap, elevation/shadow, and typography (font size, line-height, weight). Prefer the codebase's existing scale variable over a literal: `--mantine-radius-*`, `--mantine-spacing-*`, `--mantine-shadow-*`, and the Mantine font tokens.
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 12px` while `--mantine-radius-md` is `8px`, or a Figma `xxxs` spacing step with no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why.
But Figma's scales don't always line up with the codebase's. When a Figma value **has no matching step** in the corresponding scale — e.g. Figma `radius/md = 16px` while `--mantine-radius-md` is `0.75rem`, or a Figma spacing step that has no Mantine equivalent — **don't silently bake in a literal.** Flag it to the user the same way you flag primitive colors: name the property, the Figma value, and the nearest codebase token, so it can be resolved with design / the scale owners (align the value, or add a scale step). Only fall back to a literal if the user confirms it's an accepted one-off, and leave a short comment in the code explaining why. The codebase scales are defined in `frontend/src/metabase/ui/theme.ts` (`SPACING_SCALE`, `RADIUS_SCALE`, `LIGHT_SHADOWS`/`DARK_SHADOWS`).
### Mantine implementation notes (reference)
@@ -240,5 +245,6 @@ Only after sign-off:
- [ ] Storybook showcase matrix built from the component's full state space (theme excluded — global toggle drives light/dark), single panel, hover/pressed forced via `storybook-addon-pseudo-states`, controls scoped per story; lint + type-check pass. (Update mode: built before styling, **component styles untouched**. Create mode: built after the component is implemented in Phase 4.)
- [ ] Figma desktop MCP available; exact tokens extracted and mapped to semantic `--mb-color-*` (NO `color-mix`, NO primitives) and to existing scale vars for radius/spacing/elevation/type — any color *or* dimensional value with no matching token/scale step flagged to the user, not baked in as a literal.
**Primary standard: the [`typescript-write`](../typescript-write/SKILL.md) skill.** Load it first — it defines the authoring rules this review enforces, alongside `frontend/CLAUDE.md` and `docs/developers-guide/frontend.md`.
Adherence to `typescript-write` is the **highest-priority** review dimension: rank any violation of its provisions above all other findings. Treat its **no-`any` hard rule** (no explicit *or* implicit `any` in new code) as **blocking**, and verify it with the LSP rather than by eye.
Adherence to `typescript-write` is the **highest-priority** review dimension: rank any violation of its provisions above all other findings. Treat its **no-`any` hard rule** (no explicit *or* implicit `any` in new code) as **blocking**. Use TypeScript LSP tools to inspect inferred types when available; otherwise rely on type-checking and linting.
Review in this priority order:
1.**Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule.
1.**Violations of [`typescript-write`](../typescript-write/SKILL.md) provisions** — no-`any`, type tightening, type modeling, function signatures, null/undefined handling, naming, structure, comments. Highest priority; block on the no-`any` rule. Apply conditional guidance in context: explain the unsupported type guarantee or concrete readability problem, rather than treating every preference as a blanket ban.
2. Compliance with `frontend/CLAUDE.md`.
3. Readability and maintainability.
4. Appropriate test coverage.
4. Appropriate test coverage. For internal typed callers, avoid requesting tests solely for inputs the type system excludes. External API data, deserialised values, storage and JavaScript callers can violate annotations: test runtime validation and nontrivial assumptions at those boundaries. Types do not replace behavioural, security or data-integrity tests.
## Blind spots — act as the missing reviewer
@@ -31,3 +31,4 @@ These rarely surface in team reviews, so this skill should raise them. They are
- **Security.** Evaluate potential security issues in new code.
- **Bundle size.** Flag new large dependencies, default imports from icon or util libs, and heavy modules imported at route-load time.
- **Analytics.** User-facing flows should emit tracking events. If a PR adds a new flow (button, modal, navigation) without a tracking event, ask whether one is expected.
- **Public API surface** (embedding SDK). Consumers should be able to use public signatures and name types they need to import. Export those types deliberately and document public behaviour, including `@deprecated` for deprecated APIs; a referenced structural type does not automatically need its own named export.
@@ -13,26 +13,34 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **New code must not introduce `any`, explicit or implicit.** No `any` annotations, no `as any` / `as unknown as`, no untyped parameters or returns that infer `any`, no implicitly-`any` destructures or array/object literals.
- **Untyped third-party / boundary values** must be typed at the boundary (a declared type, `unknown` + type guard, or a small typed wrapper) — never let `any` propagate inward.
- **Mandatory LSP verification.** After writing or editing any TS/TSX, inspect the changed symbols with the TypeScript Language Server (hover to read inferred types; `goToDefinition` to confirm sources) and run the project type-check.
- **Mandatory type verification.** Before finishing a TS/TSX change, run `bun run type-check-pure`. If TypeScript LSP tools are available, also inspect changed symbols with hover and go-to-definition; otherwise skip LSP check.
## Type tightening
- **Avoid type casts and loose `unknown`** — fix the signature instead.
- **If a function only needs one field of a wide object, accept that field** — not the wide object. The cast often disappears once the signature is right.
- **Reach for `Partial<T>`, `Pick<T, K>`, `Record<K, V>`, and generics** before reaching for a cast.
- **Match dictionary keys to the data.** Use a finite key union when the keys are known. Open dictionaries can use an index signature, `Record<string, T>`, or `Map`; index signatures still constrain values, and changing their spelling to `Record<string, T>` does not make missing-key access safe.
- **Prefer making props/components generic** (`<T>`) when a value flows through unchanged and the caller knows the type.
- **Prefer `unknown` over loose typing** and narrow before use — an `unknown` value forces a guard at the point of use.
- **`satisfies` for object literals** that must conform without widening (config objects, lookup maps, discriminated literals) — better than `: T` (widens) or `as T` (unsafe).
- **Avoid non-null assertions (`!`)**. Prefer a guard, early return, or `?.`. Use `!` only when non-nullness is provably true and localized, with a comment.
- **Guard indexed lookups that may miss.** Arrays and dictionaries can return `undefined` even when the inferred type omits it. Use an iteration form that preserves the key/value relationship, and only assert `keyof` when the runtime keys are known to belong to the declared type.
- **Type guards belong in `frontend/src/metabase-types/guards/`**. Do not redefine them locally.
- **A cast you can't avoid needs a real justification comment.** The `metabase/no-unjustified-type-casts` rule accepts any preceding comment — state the actual reason the cast is safe. NEVER write the legacy `// Unjustified type cast. FIXME` placeholder; it exists only on casts that predated the rule, and copying it sneaks an unjustified cast past the linter. If you can't articulate why the cast is correct, the cast is wrong — fix the types.
- **Keep unavoidable assertions local.** Isolate a repeated or complex assertion behind a helper when that makes its invariant easier to enforce. Test nontrivial runtime assumptions that justify it; a trivial assertion does not automatically need a new helper or test. Do not weaken a public signature just to silence implementation errors.
## Type modeling
- **Reuse existing types; don't re-declare them.** Use canonical IDs and domain entity types from `metabase-types/api` (`FieldId`, `TableId`, `ConcreteTableId`, `SchemaName`, …) and key data structures by them (`new Map<ConcreteTableId, …>()`). Don't duplicate generated/API types — compose or derive (`Pick`, `Omit`, indexed access `SomeType["field"]`, `ReturnType`).
- **Use generics to allow TypeScript to infer correct types** when creating functions and components that need to be reusable and type-safe. Don't hesitate to introduce complex generics if they allow to derive types automatically instead of manual narrowing.
- **Generics must make promises the implementation can keep.** A caller-selected `get<T>(): T` must not disguise an unchecked assertion about external data. Return `unknown` and validate, or accept a validator that establishes `T`. A factory such as `function empty<T>(): T[] { return []; }` is valid; judge the implementation, not how often `T` appears in the signature.
- **Prefer an honest type over false precision.** Use generics when they express a real relationship. If a complex type cannot model the behaviour accurately, choose a simpler type or `unknown` with narrowing instead of asserting an unsupported guarantee.
- **Model the actual data contract; keep types narrow.** Optional `field?: T` for a key that may be absent, `field: T | undefined` only when the key is always present but the value may be undefined, `| null` for explicit API nulls. Prefer domain unions over broad `string` / `number` / loose `Record`.
- **Refer to API implementation** when defining or refining types to ensure they match the actual data structure. When considering a type cast, first consider if the type should be refined to match the actual data structure.
- **Optionality must reflect absence.** Keep required fields required and optional fields optional. Normalise input when the application has a meaningful default, not merely to remove a type error. Preserve the actual wire shape in API types.
- **Represent related absence together when modelling internal state.** If several fields exist or disappear together, consider a nullable containing object or a discriminated union. Do not reshape a raw API declaration unless the data actually has that shape.
- **Prefer explicit special states when designing a format.** Use nullability or named union variants when sentinel values such as `-1` hide meaning. Preserve established protocol sentinel values unless the behaviour is deliberately changed, or normalise them at an explicit boundary.
- **Discriminated unions for variant state, with exhaustive checks.** Model "one of N shapes" as a union with a literal discriminant rather than a bag of optional fields, and exhaust it with ts-pattern's `.exhaustive()` so adding a variant becomes a compile error:
```ts
import { match } from "ts-pattern";
@@ -44,20 +52,30 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
.exhaustive(); // Compile-time guarantee all cases handled
```
- **Derive union types from constants** (`as const` + `typeof`/`keyof`) so the type and the values can't drift.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate.
- **`readonly` / immutability where mutation isn't intended** — component props, shared constants, exported config, and unmutated parameters. Prefer `readonly T[]` / `ReadonlyArray<T>` for inputs you don't mutate. Functions that mutate caller-owned data should make that behaviour explicit.
- **Construct complete, well-typed objects where practical.** Prefer an object expression when it avoids partially initialised state or assertions. Incremental construction is fine when it is clearer and maintains the type's invariants.
- **Treat `metabase-lib` opaque types as black boxes.** Use `metabase-lib` functions to work with types such as `Lib.Query`, rather than casting into their internal representation.
- **Type async and error states explicitly** (a discriminated union or the data-layer's typed result) — never leave loading/error/empty implicit.
## Function signatures
- **Make public contracts explicit where it improves stability and clarity.** Annotate parameters and return types at shared boundaries when useful; let local values infer. Use `satisfies` or an annotation when a declaration needs an explicit shape check.
- **Accept the inputs the operation supports and return the most precise honest result.** Narrow avoidable uncertainty inside the function, but preserve meaningful nullability and union variants in its return type.
- **Use named options when positional arguments are easy to confuse.** Consecutive arguments with the same type are a useful warning sign, not an automatic requirement to rewrite a clear API.
- **Use `async`/`await` when it clarifies control flow or error handling.** Returning an existing promise directly is also valid; a Promise return type alone does not require adding `async`.
## Null and undefined
- **Narrow at the source**. If a value is optional only in a corner case, don't thread `undefined` through every layer — guard at the producer.
- **Sensible defaults for optional values**. Use `?.` and `??` at the consumer.
- **Lists should be filtered** before being used in a map or other iteration.
- **Narrow nullable list elements when the operation requires present values.** Filter with a type guard when missing entries should be discarded; preserve them when their absence or position carries meaning.
- **Avoid non-strict null comparisons** (`X != null`) when `X` can never be `null` — use a strict check or narrow the type. Use `checkNotNull` where necessary.
- **Check actual nullability against API implementation**. Find the API endpoint implementation and check if the field can actually be null.
## Naming
- **Names describe the entity, not the mechanism**. A name must reflect what the value holds.
- **Use domain vocabulary and include units where useful** (`timeoutMs`, `widthPx`, `temperatureC`). Prefer a more specific name when `Info`, `Data`, or `Entity` obscures the meaning, while keeping established terminology when it is clear.
- **Align sibling concepts**: keep verb conventions consistent across a related API.
- **No names that encode implementation history** rather than current meaning. Suffixes like `Base`, `New`, `Old`, `Initial` need a real semantic distinction, otherwise drop them.
- **Avoid cryptic identifiers** (`v`, `n`, `$n`) for domain values; short names are fine only in tiny conventional contexts (loop index `i`, coordinates `x`/`y`, generic params `T`/`K`/`V`).
@@ -74,10 +92,6 @@ description: Write TypeScript and JavaScript code following Metabase coding stan
- **No comments by default**. Well-named identifiers carry the `what`.
- **Comments should be concise**. Add a short, concise comment only when the `why` is non-obvious: a workaround, a hidden invariant, a subtle ordering constraint, a clever reduction. Never document the actual implementation, focus on the intent and the why.
## TypeScript Migration
**When touching existing JavaScript files, propose to convert them to TypeScript first**. Create a separate PR for the conversion, then implement the changes.
## Verify before done
- **Run the project type-check** when finished (see the shared TypeScript commands above).
{clojure.core/print{:message"Use metabase.util.log instead of clojure.core/print"}
{metabase-enterprise.entity-retrieval.core/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.core/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
metabase.entity-retrieval.mirror/search-unfiltered{:message"Permission bypass risk: library-index hits are unfiltered — resolve each entity and drop what the current user can't read"}
clojure.core/print{:message"Use metabase.util.log instead of clojure.core/print"}
clojure.core/println{:message"Use metabase.util.log instead of clojure.core/println"}
clojure.core/printf{:message"Use metabase.util.log instead of clojure.core/printf"}
clojure.core/prn{:message"Use metabase.util.log instead of clojure.core/prn"}
(are[task-namespace](=?[{:message"Module task should not be used in the api module. [:metabase/modules api :uses]"
:type:metabase/modules}]
@@ -43,6 +56,16 @@
'{:metabase/modules{api{:uses#{search}}
search{:api#{metabase.search.core}}}}))))
(deftest^:parallelmodule-checker-empty-api-test
(is(=?[{:message"Namespace metabase.embeddings.core is not an allowed external API namespace for the embeddings module. [:metabase/modules embeddings :api]"
echo "Could not cherry pick because of a conflict"
echo "has-conflicts=true" >> $GITHUB_OUTPUT
echo "has-conflicts=true" >> "$GITHUB_OUTPUT"
# Add a shell script for resolving conflicts
echo "git reset HEAD~1" > ./backport.sh
echo "rm ./backport.sh" >> ./backport.sh
echo "git cherry-pick ${COMMIT}" >> ./backport.sh
echo "echo 'Resolve conflicts and force push this branch.\n\nTo backport translations run: bin/i18n/merge-translations <release-branch>'" >> ./backport.sh
write_backport_script "${COMMIT}"
chmod +x ./backport.sh
git add ./backport.sh
git commit -m "Add backport resolution script"
@@ -57,44 +57,43 @@ runs:
#${ORIGINAL_PULL_REQUEST_NUMBER}
> [!IMPORTANT]
> Manual conflict resolution is required.
> Checkout the branch and run \`./backport.sh\` script. Force push your changes after cherry-picking. Check the box below when done.
> Checkout the branch and run \`./backport.sh\` script. Force push your changes after cherry-picking. Mark the PR as ready for review once conflicts have been addressed.
printf'%s\n'"printf 'Resolve conflicts and force push this branch.\\n\\nTo backport translations run: bin/i18n/merge-translations <release-branch>\\n'"
@${githubUsername} You have modified embedding code. Please make sure the PR title follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) style.
?`Renamed alias keys in yarn.lock:\n${renamedKeys.join("\n")}\n`
:"No alias keys to rename in yarn.lock\n",
);
}
main().catch((error)=>{
process.stderr.write(`Failed to rename alias keys in yarn.lock: ${error}\n`);
process.exit(1);
});
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.