Compare commits
113 Commits
query-buil
...
python-run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2076d909d9 | ||
|
|
b2c2816d86 | ||
|
|
d8104c911d | ||
|
|
5d5930ab64 | ||
|
|
2738ec81b7 | ||
|
|
cd77eb5140 | ||
|
|
2a4046736a | ||
|
|
660c1ddfe6 | ||
|
|
6845e78a5d | ||
|
|
eea9a750d6 | ||
|
|
123a4f966d | ||
|
|
659d0a37b1 | ||
|
|
59a52b2631 | ||
|
|
a83196324b | ||
|
|
9ab2bfbae8 | ||
|
|
c261122999 | ||
|
|
0b65ccce3a | ||
|
|
bb2adc5f46 | ||
|
|
c5dcdc3238 | ||
|
|
5537f0f258 | ||
|
|
bfdd8a0b23 | ||
|
|
3c05a30b8e | ||
|
|
106a707fcc | ||
|
|
21c8136e7e | ||
|
|
7319599ceb | ||
|
|
13bc693357 | ||
|
|
2b11a3bed2 | ||
|
|
c910ca3fd3 | ||
|
|
d8b40292d1 | ||
|
|
b773f68f23 | ||
|
|
647861b624 | ||
|
|
e741639713 | ||
|
|
5765b0df15 | ||
|
|
40d00b8e7f | ||
|
|
58cde65c4e | ||
|
|
5995621da8 | ||
|
|
70d4a78914 | ||
|
|
cf6e1c032a | ||
|
|
e70f22e0eb | ||
|
|
4fd189509f | ||
|
|
f7fd7fe5c5 | ||
|
|
a0e28bc775 | ||
|
|
a5b4f10c10 | ||
|
|
57502d5ef1 | ||
|
|
b2f981e967 | ||
|
|
fb8f72b7bd | ||
|
|
502b66528c | ||
|
|
2838b6501c | ||
|
|
7603550836 | ||
|
|
ba8074bb87 | ||
|
|
90097715d2 | ||
|
|
12839276af | ||
|
|
2ef536a0fe | ||
|
|
9ad40ab1c3 | ||
|
|
6351e5bc0e | ||
|
|
18d8dadf1b | ||
|
|
d5f606e8f2 | ||
|
|
acbf476d8b | ||
|
|
21af2827f2 | ||
|
|
04b141b975 | ||
|
|
ff9d9acdac | ||
|
|
d7848ee998 | ||
|
|
47c037a3f8 | ||
|
|
ab1ccbf8ec | ||
|
|
8662aaaa87 | ||
|
|
81b10ad959 | ||
|
|
0a67d24d79 | ||
|
|
258305b914 | ||
|
|
21b67b2892 | ||
|
|
3dcbfef5c5 | ||
|
|
1ccf800dee | ||
|
|
d4d83d2f8e | ||
|
|
3e655a0bb2 | ||
|
|
0409c4a20b | ||
|
|
b51c0c9efa | ||
|
|
51f8fb9af1 | ||
|
|
bfb0b42b42 | ||
|
|
09544d950d | ||
|
|
03dd34ec4a | ||
|
|
d08e561420 | ||
|
|
214c38b3f2 | ||
|
|
52ae8a14db | ||
|
|
e153a3290b | ||
|
|
2830b3c94a | ||
|
|
2fa8abc247 | ||
|
|
be2a29d138 | ||
|
|
ab089eb84d | ||
|
|
b40d76f5b4 | ||
|
|
b12dfa4fd9 | ||
|
|
5e2e510811 | ||
|
|
c6a2880b31 | ||
|
|
5cd8e165fa | ||
|
|
05c26a4363 | ||
|
|
8ceeb1fce4 | ||
|
|
49a3037d2a | ||
|
|
fa959aaab4 | ||
|
|
eadbd4b00d | ||
|
|
1da8e5751a | ||
|
|
d47651a2cf | ||
|
|
92001b00de | ||
|
|
186c9dca65 | ||
|
|
42aebbf137 | ||
|
|
df7d834e2e | ||
|
|
bc7ffe094f | ||
|
|
205205174c | ||
|
|
05567456f2 | ||
|
|
9bfb9ed7a4 | ||
|
|
b161e19442 | ||
|
|
c28beea344 | ||
|
|
b22ab2010d | ||
|
|
6f4d71c279 | ||
|
|
c8d5e94ffa | ||
|
|
22a1f275f0 |
@@ -12,14 +12,7 @@
|
||||
|
||||
## Testing
|
||||
|
||||
- **Run a test:** `./bin/mage run-tests namespace/test-name`
|
||||
- **Run all tests in a namespace:** `./bin/mage run-tests namespace`
|
||||
- **Run all tests for a module:** `./bin/mage run-tests test/metabase/notification` Because the module lives in that directory.
|
||||
|
||||
Note: the `./bin/mage run-tests` command accepts multiple args, so you can pass
|
||||
`./bin/mage run-tests namespace/test-name namespace/other-test namespace/third-test`
|
||||
to run 3 tests, or
|
||||
`./bin/mage run-tests test/metabase/module1 test/metabase/module2` to run 2 modules.
|
||||
Use `./bin/test-agent` to run backend tests. See the top-level CLAUDE.md for usage.
|
||||
|
||||
## Code Readability
|
||||
|
||||
|
||||
357
.claude/skills/add-tracing/SKILL.md
Normal file
@@ -0,0 +1,357 @@
|
||||
--b-
|
||||
name: add-tracing
|
||||
description: Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
|
||||
---
|
||||
|
||||
# Add Tracing Spans to Clojure Code
|
||||
|
||||
This skill helps you add OpenTelemetry (OTel) tracing spans to the Metabase backend codebase using the custom `tracing/with-span` macro.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `src/metabase/tracing/core.clj` - `with-span` macro, group registry, SDK lifecycle, `best-effort-sanitize-sql`, Pyroscope integration
|
||||
- `src/metabase/task/impl.clj` - `defjob` macro that wraps Quartz jobs with root spans
|
||||
- `.clj-kondo/config/modules/config.edn` - Module boundary configuration
|
||||
|
||||
## Module Architecture
|
||||
|
||||
The tracing module has a deliberately minimal API surface. **Only 2 namespaces are public** (listed in `:api` in the module config):
|
||||
|
||||
| Namespace | Role | Status |
|
||||
|---|---|---|
|
||||
| `tracing.core` | Primary API: `with-span`, groups, SDK lifecycle, Pyroscope, MDC, `best-effort-sanitize-sql` | **Public API** |
|
||||
| `tracing.init` | Side-effect loader for `quartz` and `settings` | **Public API** (init convention) |
|
||||
| `tracing.attributes` | `best-effort-sanitize-sql` implementation (re-exported via `tracing.core`) | Internal |
|
||||
| `tracing.settings` | Setting definitions (`MB_TRACING_*` env vars) | Internal |
|
||||
| `tracing.quartz` | Quartz JDBC proxy + JobListener | Internal |
|
||||
|
||||
**Rules:**
|
||||
- Only require `[metabase.tracing.core :as tracing]` from outside the module. `tracing/best-effort-sanitize-sql` and all other public functions are available from this single namespace.
|
||||
- Do not add new API namespaces. Add new public functions to `tracing.core` instead.
|
||||
- Do not require internal namespaces (`tracing.attributes`, `tracing.settings`, `tracing.quartz`) from outside the module.
|
||||
- `:uses :any` on the `core` module does NOT bypass the target module's `:api` check — internal namespaces are still enforced.
|
||||
|
||||
### Cyclic Dependency Avoidance
|
||||
|
||||
`tracing/core.clj` is required by many modules across the codebase. It **must NOT** compile-time require `tracing.settings`, as this creates transitive cyclic load dependencies (e.g., `settings/core -> tracing/settings -> tracing/core -> events/impl -> events/core`).
|
||||
|
||||
Instead, `tracing/core.clj` uses `requiring-resolve` for settings access:
|
||||
|
||||
```clojure
|
||||
;; CORRECT — lazy runtime resolution, no compile-time dependency
|
||||
((requiring-resolve 'metabase.tracing.settings/tracing-enabled))
|
||||
|
||||
;; WRONG — creates cyclic load dependency
|
||||
(require '[metabase.tracing.settings :as settings])
|
||||
(settings/tracing-enabled)
|
||||
```
|
||||
|
||||
External library namespaces (clj-otel API, SDK, exporters) are safe to require normally — they don't participate in Metabase namespace cycles.
|
||||
|
||||
**Important:** `requiring-resolve` must use **literal quoted symbols**. Kondo hooks validate that `required-namespaces` are all simple symbols, so dynamic construction fails:
|
||||
|
||||
```clojure
|
||||
;; CORRECT — literal quoted symbol
|
||||
(requiring-resolve 'metabase.tracing.settings/tracing-endpoint)
|
||||
|
||||
;; WRONG — kondo hook rejects this: "Assert failed: (every? simple-symbol? required-namespaces)"
|
||||
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-endpoint"))
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
When adding tracing spans:
|
||||
|
||||
- [ ] Module has `tracing` in its `:uses` set in `.clj-kondo/config/modules/config.edn`
|
||||
- [ ] Added `[metabase.tracing.core :as tracing]` to ns requires (alphabetically sorted)
|
||||
- [ ] Span wraps a meaningful I/O boundary (not pure computation)
|
||||
- [ ] Group matches the domain (check `src/metabase/tracing/core.clj` for registered groups; add a new one if none fit)
|
||||
- [ ] Span name follows dot-notation convention (`"domain.subsystem.operation"`)
|
||||
- [ ] Attributes use namespaced keywords (`:search/query-length`, `:db/id`)
|
||||
- [ ] No sensitive data in attributes (use `best-effort-sanitize-sql` for HoneySQL, never raw SQL)
|
||||
- [ ] No new tracing namespaces created (add to `tracing.core` instead)
|
||||
- [ ] No `DO_NOT_ADD_NEW_FILES_HERE.txt` violations in the target directory
|
||||
- [ ] Run `clj-kondo --lint <files>` to verify 0 errors, 0 warnings
|
||||
- [ ] Add or update tests in the corresponding `test/` path (see Testing section below)
|
||||
- [ ] Run tests: `clojure -X:dev:test :only <test-ns>`
|
||||
|
||||
## The `with-span` Macro
|
||||
|
||||
```clojure
|
||||
(tracing/with-span group span-name attrs & body)
|
||||
```
|
||||
|
||||
- **group** - A keyword selecting which trace group this span belongs to (e.g., `:tasks`, `:sync`)
|
||||
- **span-name** - A string identifying the span in traces (e.g., `"search.execute"`)
|
||||
- **attrs** - A map of span attributes (e.g., `{:db/id 42}`)
|
||||
- **body** - The code to execute inside the span
|
||||
|
||||
**When disabled:** zero overhead -- single atom deref + boolean check, body runs directly.
|
||||
**When enabled:** creates OTel span AND injects `trace_id`/`span_id` into Log4j2 MDC for log-to-trace correlation.
|
||||
|
||||
## Trace Groups
|
||||
|
||||
Groups are registered in `src/metabase/tracing/core.clj`. Check that file for the current list. The general rule: **match the group to the domain, not the call site.** If code runs inside a Quartz job but is logically search work, use `:search`, not `:tasks`.
|
||||
|
||||
To add a new group:
|
||||
|
||||
```clojure
|
||||
;; In src/metabase/tracing/core.clj
|
||||
(register-group! :my-domain "Description of what this covers")
|
||||
```
|
||||
|
||||
Users enable groups via `MB_TRACING_GROUPS=tasks,search,sync` (comma-separated, or `"all"`).
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Span Names
|
||||
|
||||
Use dot-separated hierarchical names: `"domain.subsystem.operation"`. The domain prefix should match the group name:
|
||||
|
||||
```
|
||||
search.execute -- `:search` group
|
||||
sync.fingerprint.table -- `:sync` group
|
||||
task.session-cleanup.delete -- `:tasks` group
|
||||
db-app.collection-items -- `:db-app` group
|
||||
```
|
||||
|
||||
### Attributes
|
||||
|
||||
Use namespaced keywords. The namespace groups related attributes:
|
||||
|
||||
```clojure
|
||||
:db/id -- Database ID (integer)
|
||||
:db/engine -- Database engine name (string)
|
||||
:db/statement -- Sanitized SQL (string, via best-effort-sanitize-sql)
|
||||
:search/engine -- Search engine name (string)
|
||||
:search/query-length -- Query string length (integer)
|
||||
:sync/table -- Table name (string)
|
||||
:sync/step -- Sync step name (string)
|
||||
:task/name -- Task name (string)
|
||||
:http/method -- HTTP method (string)
|
||||
:http/url -- Request URL (string)
|
||||
```
|
||||
|
||||
Invent new namespaced attributes as needed (e.g., `:pulse/id`, `:transform/count`). Keep values as primitives (strings, numbers, booleans) -- no maps or collections.
|
||||
|
||||
## Step-by-Step: Adding a Span
|
||||
|
||||
### 1. Check module boundaries
|
||||
|
||||
Look up the module for your namespace in `.clj-kondo/config/modules/config.edn`. If `tracing` is not in the module's `:uses` set, add it (keep alphabetically sorted):
|
||||
|
||||
```edn
|
||||
my-module
|
||||
{:team "MyTeam"
|
||||
:uses #{analytics config tracing util}}
|
||||
```
|
||||
|
||||
### 2. Add the require
|
||||
|
||||
```clojure
|
||||
(ns metabase.my-module.thing
|
||||
(:require
|
||||
[metabase.tracing.core :as tracing]
|
||||
[metabase.util :as u]))
|
||||
```
|
||||
|
||||
`best-effort-sanitize-sql` is available from `tracing.core` — no additional require needed.
|
||||
|
||||
### 3. Identify the I/O boundary
|
||||
|
||||
Only wrap code at **meaningful I/O boundaries**:
|
||||
|
||||
**DO trace:**
|
||||
- External API calls (embedding APIs, metabot, webhooks)
|
||||
- Database queries (both app DB and user DB)
|
||||
- Network requests (HTTP calls to external services)
|
||||
- Heavy batch processing (batch indexing, batch embedding)
|
||||
- Top-level orchestration functions that coordinate multiple sub-operations
|
||||
|
||||
**DO NOT trace:**
|
||||
- Pure computation (sorting, filtering, mapping)
|
||||
- Simple single-row lookups (`t2/select-one :model/Setting :key k`)
|
||||
- Every function in a call chain (only boundaries matter)
|
||||
- Trivial operations (string formatting, hash calculations)
|
||||
|
||||
### 4. Wrap with `with-span`
|
||||
|
||||
```clojure
|
||||
;; Simple span (no attributes needed)
|
||||
(tracing/with-span :search "search.init-index" {}
|
||||
(do-expensive-thing))
|
||||
|
||||
;; Span with static attributes
|
||||
(tracing/with-span :sync "sync.fingerprint.table"
|
||||
{:db/id (:db_id table)
|
||||
:sync/table (:name table)}
|
||||
(fingerprint-fields! table fields))
|
||||
|
||||
;; Span with computed attributes
|
||||
(tracing/with-span :search "search.execute"
|
||||
{:search/engine (name (:search-engine ctx))
|
||||
:search/query-length (count (:search-string ctx))}
|
||||
(search.engine/results ctx))
|
||||
|
||||
;; Span with sanitized SQL (for dynamic HoneySQL queries)
|
||||
(let [hsql {:delete-from [(t2/table-name :model/Session)]
|
||||
:where [:< :created_at oldest-allowed]}]
|
||||
(tracing/with-span :tasks "task.session-cleanup.delete"
|
||||
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
|
||||
(t2/query-one hsql)))
|
||||
|
||||
;; Sub-spans breaking a function into I/O phases
|
||||
(let [embedding (tracing/with-span :search "search.semantic.embedding"
|
||||
{:search.semantic/provider (:provider model)}
|
||||
(get-embedding model search-string))
|
||||
results (tracing/with-span :search "search.semantic.db-query" {}
|
||||
(into [] xform reducible))]
|
||||
(process results))
|
||||
|
||||
;; Per-item iteration spans
|
||||
(doseq [e (search.engine/active-engines)]
|
||||
(tracing/with-span :search "search.ingestion.update" {:search/engine (name e)}
|
||||
(search.engine/update! e batch)))
|
||||
```
|
||||
|
||||
### 5. Add tests
|
||||
|
||||
Create or update tests in the corresponding `test/` path. Follow the patterns in existing tracing tests:
|
||||
|
||||
- **Reference tests:** `test/metabase/tracing/quartz_test.clj`, `test/metabase/server/middleware/trace_test.clj`
|
||||
- Use `tracing/init-enabled-groups!` / `tracing/shutdown-groups!` with `try`/`finally` to manage group lifecycle
|
||||
- Test both enabled and disabled paths (verify zero overhead when group is off)
|
||||
- Use `reify` mocks for Java interfaces (Connection, PreparedStatement, JobListener, etc.)
|
||||
- Add `(set! *warn-on-reflection* true)` and type-hint proxy/reify calls to avoid reflection warnings
|
||||
|
||||
```clojure
|
||||
(deftest my-span-enabled-test
|
||||
(testing "when group is enabled, span is created"
|
||||
(try
|
||||
(tracing/init-enabled-groups! "my-group" "INFO")
|
||||
;; ... test that span behavior occurs ...
|
||||
(finally
|
||||
(tracing/shutdown-groups!)))))
|
||||
|
||||
(deftest my-span-disabled-test
|
||||
(testing "when group is disabled, code runs without tracing"
|
||||
(tracing/shutdown-groups!)
|
||||
;; ... test that code still works, no wrapping applied ...
|
||||
))
|
||||
```
|
||||
|
||||
### 6. Lint and run tests
|
||||
|
||||
```bash
|
||||
# Lint modified source and test files — expect 0 errors, 0 warnings
|
||||
clj-kondo --lint path/to/modified/file.clj path/to/test/file.clj
|
||||
|
||||
# Run tests (requires Java 21+)
|
||||
clojure -X:dev:test :only my-ns.test-ns
|
||||
```
|
||||
|
||||
Expect: all tests pass, 0 failures, 0 errors, no reflection warnings from your files.
|
||||
|
||||
## Sanitizing SQL for Attributes
|
||||
|
||||
When including SQL in span attributes, **always** use `tracing/best-effort-sanitize-sql`. This converts HoneySQL maps to parameterized SQL strings where values become `?` placeholders -- no data leaks.
|
||||
|
||||
```clojure
|
||||
(let [hsql {:delete-from [:core_session]
|
||||
:where [:< :created_at some-timestamp]}]
|
||||
(tracing/with-span :tasks "task.cleanup.delete"
|
||||
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
|
||||
(t2/query-one hsql)))
|
||||
;; Trace attribute: db/statement = "DELETE FROM core_session WHERE created_at < ?"
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never put raw SQL strings or user-provided values in attributes
|
||||
- Use `best-effort-sanitize-sql` only for app DB (HoneySQL) queries
|
||||
- For external/user DB queries, trace only timing and counts, not SQL content
|
||||
|
||||
## Defjob and Root Spans
|
||||
|
||||
The `defjob` macro in `metabase.task.impl` automatically wraps every Quartz job with a `:tasks` root span:
|
||||
|
||||
```clojure
|
||||
(task/defjob ^{DisallowConcurrentExecution true} SessionCleanup [_]
|
||||
(cleanup-sessions!))
|
||||
;; Automatically creates span: "task.SessionCleanup" {:task/name "SessionCleanup"}
|
||||
```
|
||||
|
||||
You do NOT need a root span inside `defjob` bodies. Add **child spans** for I/O inside the job.
|
||||
|
||||
For code on plain `Thread`s (not Quartz), add the root span manually:
|
||||
|
||||
```clojure
|
||||
(defn init! []
|
||||
(tracing/with-span :search "search.task.init" {}
|
||||
(search/init-index!)))
|
||||
```
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
### Span Usage Mistakes
|
||||
|
||||
```clojure
|
||||
;; WRONG - pure computation, no I/O
|
||||
(tracing/with-span :search "search.format-results" {}
|
||||
(map format-result results))
|
||||
|
||||
;; WRONG - trivial single-row lookup
|
||||
(tracing/with-span :db-app "db-app.get-setting" {}
|
||||
(t2/select-one :model/Setting :key "my-setting"))
|
||||
|
||||
;; WRONG - raw SQL in attributes (data leak)
|
||||
(tracing/with-span :tasks "task.cleanup" {:db/statement raw-sql-string}
|
||||
(execute! raw-sql-string))
|
||||
|
||||
;; WRONG - wrong group (search work should use :search, not :tasks)
|
||||
(tracing/with-span :tasks "search.execute" {} ...)
|
||||
|
||||
;; WRONG - redundant nesting (do-search already has a span)
|
||||
(tracing/with-span :search "search.process" {}
|
||||
(let [results (do-search ctx)]
|
||||
(tracing/with-span :search "search.format" {}
|
||||
(format-results results))))
|
||||
```
|
||||
|
||||
### Architecture Mistakes
|
||||
|
||||
```clojure
|
||||
;; WRONG - creating a new tracing namespace
|
||||
(ns metabase.tracing.my-feature ...)
|
||||
|
||||
;; WRONG - requiring internal tracing namespaces from outside the module
|
||||
(ns metabase.my-module.thing
|
||||
(:require [metabase.tracing.attributes :as trace-attrs] ;; internal!
|
||||
[metabase.tracing.settings :as tracing.settings] ;; internal!
|
||||
[metabase.tracing.quartz :as tracing.quartz])) ;; internal!
|
||||
|
||||
;; WRONG - adding compile-time requires to tracing/core.clj for settings or SDK
|
||||
;; This creates cyclic load dependencies because tracing/core is widely required
|
||||
(ns metabase.tracing.core
|
||||
(:require [metabase.tracing.settings :as settings])) ;; causes cycle!
|
||||
|
||||
;; WRONG - dynamic symbol construction with requiring-resolve (kondo rejects it)
|
||||
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-enabled"))
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are env-var-only (defined in `src/metabase/tracing/settings.clj`):
|
||||
|
||||
```bash
|
||||
# Core
|
||||
MB_TRACING_ENABLED=true # Enable tracing (default: false)
|
||||
MB_TRACING_ENDPOINT=host:4317 # OTLP collector endpoint (default: http://localhost:4317)
|
||||
MB_TRACING_GROUPS=tasks,search,sync # Comma-separated groups or "all" (default: all)
|
||||
MB_TRACING_SERVICE_NAME=metabase # Service name in traces (default: hostname)
|
||||
MB_TRACING_LOG_LEVEL=DEBUG # Log threshold for traced threads: TRACE/DEBUG/INFO (default: INFO)
|
||||
|
||||
# Batch span processor tuning
|
||||
MB_TRACING_MAX_QUEUE_SIZE=2048 # Max spans queued for export; drops when full (default: 2048)
|
||||
MB_TRACING_EXPORT_TIMEOUT_MS=10000 # Max wait for batch export to complete (default: 10000)
|
||||
MB_TRACING_SCHEDULE_DELAY_MS=5000 # Delay between consecutive batch exports (default: 5000)
|
||||
```
|
||||
260
.claude/skills/e2e-test-create/SKILL.md
Normal file
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: e2e-test-create
|
||||
description: >
|
||||
Analyze React component source code to understand UI structure,
|
||||
then generate idiomatic Cypress E2E tests following Metabase conventions.
|
||||
Falls back to Playwright MCP browser exploration only when code reading
|
||||
and screenshot debugging are insufficient.
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Grep
|
||||
- Glob
|
||||
- Skill
|
||||
- "mcp__playwright__*"
|
||||
---
|
||||
|
||||
# Code-Reading-First → Generate Cypress Tests (Metabase)
|
||||
|
||||
You are writing Cypress E2E tests for the **Metabase** codebase.
|
||||
Before generating ANY test code, you MUST analyze React component source code
|
||||
to understand DOM structure, selectors, and user flows.
|
||||
|
||||
## Phase 0 — Research
|
||||
|
||||
1. Read existing helpers before writing anything:
|
||||
- `e2e/support/helpers/` — all shared helpers (restore, signInAs, openOrdersTable, etc.)
|
||||
- `e2e/support/cypress_sample_database.ts` — table/field schema constants (ORDERS, PRODUCTS, etc.)
|
||||
- `e2e/support/cypress_sample_instance_data.ts` — instance-specific IDs (ORDERS_DASHBOARD_ID, NORMAL_USER_ID, etc.)
|
||||
2. Glob `e2e/test/scenarios/` to find the closest existing spec to the area under test.
|
||||
Study its patterns — match them exactly.
|
||||
3. Glob `frontend/src/metabase/` to find React components for the feature area.
|
||||
|
||||
## Phase 1 — Code Analysis
|
||||
|
||||
Read React component source to understand DOM structure. No browser needed — source code has everything.
|
||||
|
||||
1. **Find relevant components**: Glob and grep `frontend/src/metabase/` for the feature area.
|
||||
2. **Extract selectors**: Grep for `data-testid` in relevant components.
|
||||
3. **Note visible text**: Read component JSX for button labels, headings, placeholders.
|
||||
4. **Note aria attributes**: Grep for `aria-label` in relevant components.
|
||||
5. **Understand user flows**: Read event handlers (onClick, onSubmit, onChange) to understand interactions.
|
||||
6. **Find API calls**: Grep for `Api.use`, `fetch`, `useQuery`, endpoint definitions to identify API calls to intercept.
|
||||
7. **Cross-reference with existing specs**: Find specs in the same area and reuse their proven selectors and `cy.intercept` patterns.
|
||||
|
||||
## Phase 2 — Start Backend
|
||||
|
||||
Use `MB_EDITION=oss` by default. Only use `MB_EDITION=ee` when the user explicitly asks to write an enterprise test.
|
||||
|
||||
Start the backend using `run_in_background: true` (NOT `&`).
|
||||
`bin/e2e-backend` automatically detects if a backend is already running and reuses it.
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-backend
|
||||
```
|
||||
|
||||
Do NOT manually generate snapshots by running unrelated test specs.
|
||||
The `bun test-cypress` runner has `GENERATE_SNAPSHOTS: true` by default and automatically
|
||||
generates snapshots before running any spec. When running tests in Phase 4 via the `/e2e-test` skill,
|
||||
snapshots will be generated on the first run if they don't already exist.
|
||||
|
||||
Restore clean test data:
|
||||
```bash
|
||||
curl -sf -X POST http://localhost:4000/api/testing/restore/default
|
||||
```
|
||||
|
||||
## Phase 3 — Generate Cypress Spec
|
||||
|
||||
### File location & naming
|
||||
|
||||
Place specs in `e2e/test/scenarios/<area>/` mirroring the URL structure.
|
||||
Name: `<feature>.cy.spec.js` (NOT `.cy.spec.ts`).
|
||||
|
||||
### Metabase conventions (mandatory)
|
||||
|
||||
- **Helpers via `cy.H`**: All helpers are accessed via `const { H } = cy;` — NOT via direct imports from `e2e/support/helpers`.
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
|
||||
describe("feature name", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- **Sample Database schema** (table/field definitions): Import from `cypress_sample_database`.
|
||||
|
||||
```js
|
||||
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
|
||||
```
|
||||
|
||||
- **Instance data** (dashboard IDs, user IDs, etc.): Import from `cypress_sample_instance_data`.
|
||||
|
||||
```js
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
```
|
||||
|
||||
- **Selectors** (priority order):
|
||||
1. `cy.findByText()` / `cy.findByLabelText()` / `cy.findByRole()` — from `@testing-library/cypress`
|
||||
2. `cy.findByTestId()` — for `data-testid` attributes
|
||||
3. `cy.get("[data-testid='...']")` — fallback
|
||||
4. NEVER use positional selectors, CSS class names, or XPaths.
|
||||
|
||||
- **Navigation helpers**: Use existing helpers like `H.openOrdersTable()`, `H.openNativeEditor()`,
|
||||
`H.visitDashboard(id)`, `H.visitQuestion(id)` instead of raw `cy.visit()` chains.
|
||||
Grep `e2e/support/helpers/` to discover what's available for your area.
|
||||
|
||||
- **API setup over UI setup**: Use `cy.request()` or existing API helpers to set up state.
|
||||
Only use the UI for the flow you're actually testing.
|
||||
|
||||
- **Assertions**: Assert on visible text, URL, aria state — not DOM structure.
|
||||
|
||||
- **Waits**: Never use `cy.wait(ms)`. Use `cy.intercept()` + `cy.wait("@alias")` for API calls,
|
||||
or `cy.findByText().should("be.visible")` for DOM readiness.
|
||||
|
||||
- **Isolation**: Each `it()` block must be independently runnable.
|
||||
Don't depend on state from a previous `it()`.
|
||||
|
||||
### Spec structure template
|
||||
|
||||
```js
|
||||
const { H } = cy;
|
||||
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database";
|
||||
|
||||
describe("area > sub-area > feature (#issue-number)", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should do the primary happy-path thing", () => {
|
||||
// test
|
||||
});
|
||||
|
||||
it("should handle the edge case", () => {
|
||||
// test
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Intercepts
|
||||
|
||||
When you identified API calls during code analysis, stub or wait on them:
|
||||
|
||||
```js
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
// ... trigger action ...
|
||||
cy.wait("@dataset");
|
||||
```
|
||||
|
||||
## Phase 4 — Validate
|
||||
|
||||
After generating specs:
|
||||
1. Check that all imported helpers exist (Grep `e2e/support/helpers/`).
|
||||
2. **You MUST use the `/e2e-test` skill** to run tests — do NOT run `bun test-cypress` directly.
|
||||
The `/e2e-test` skill handles edition selection, snapshot management, and correct env vars.
|
||||
`/e2e-test GREP="should do the thing" --spec e2e/test/scenarios/<path>`
|
||||
If you created multiple `it()` blocks, run each one individually to isolate failures.
|
||||
|
||||
## Phase 5 — Fix Failures (up to 2 attempts)
|
||||
|
||||
When a test fails, **try to fix it from Cypress output first**:
|
||||
1. Read the failure screenshot (path printed under `(Screenshots)`).
|
||||
2. Read the error message and code frame from the console output.
|
||||
3. Fix the test and re-run (back to Phase 4, step 2).
|
||||
|
||||
If you cannot diagnose the issue after 2 attempts, proceed to Phase 6.
|
||||
|
||||
## Phase 6 — Playwright Fallback
|
||||
|
||||
Only reach this phase after 2 failed fix attempts from Phase 5. The backend is already running.
|
||||
|
||||
Restore clean test data:
|
||||
```bash
|
||||
curl -sf -X POST http://localhost:4000/api/testing/restore/default
|
||||
```
|
||||
|
||||
**Bypass CSP** headers before navigating (Metabase serves strict CSP that blocks dev server scripts).
|
||||
Use `browser_run_code` to set this up:
|
||||
|
||||
```js
|
||||
async (page) => {
|
||||
// Strip CSP headers so the page loads (mirrors Cypress chromeWebSecurity: false)
|
||||
await page.context().route('**/*', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const headers = { ...response.headers() };
|
||||
delete headers['content-security-policy'];
|
||||
delete headers['content-security-policy-report-only'];
|
||||
await route.fulfill({ response, headers });
|
||||
});
|
||||
|
||||
// Sign in via API
|
||||
const response = await page.request.post('http://localhost:4000/api/session', {
|
||||
data: { username: 'admin@metabase.test', password: '12341234' }
|
||||
});
|
||||
const session = await response.json();
|
||||
await page.context().addCookies([{
|
||||
name: 'metabase.DEVICE',
|
||||
value: session.id,
|
||||
domain: 'localhost',
|
||||
path: '/'
|
||||
}]);
|
||||
|
||||
await page.goto('http://localhost:4000');
|
||||
await page.waitForLoadState('networkidle');
|
||||
return 'signed in';
|
||||
}
|
||||
```
|
||||
|
||||
**Maintain an observation log incrementally.** After EVERY significant Playwright interaction,
|
||||
IMMEDIATELY append what you observed to the scratch file BEFORE performing the next interaction:
|
||||
|
||||
```bash
|
||||
cat >> /tmp/e2e-observations.md << 'OBSERVATION'
|
||||
## [Page/Flow name]
|
||||
- URL: /question/notebook#...
|
||||
- Clicked: "Box plot" button → visible text "Box plot", role: radio
|
||||
- Selectors: data-testid="viz-type-button", findByText("Box plot")
|
||||
- API call: POST /api/dataset (triggered on viz change)
|
||||
- Key state: after selecting viz type, summary sidebar shows metric picker
|
||||
OBSERVATION
|
||||
```
|
||||
|
||||
For each page/flow:
|
||||
- Take an accessibility snapshot (`browser_snapshot`).
|
||||
- Click through interactive elements, fill forms, trigger modals.
|
||||
- **Append to the observation log immediately after each step**: URLs, visible text, aria labels, `data-testid` attrs, API calls.
|
||||
- Screenshot key states.
|
||||
|
||||
After exploration:
|
||||
1. Read back your observation log: `cat /tmp/e2e-observations.md`
|
||||
2. Fix the test using observed selectors and behavior.
|
||||
3. Re-run the test (back to Phase 4).
|
||||
4. Clean up: `rm -f /tmp/e2e-observations.md`
|
||||
|
||||
## Phase 7 — Cleanup
|
||||
|
||||
After all tests pass (or after giving up on fixing failures), **always kill the backend on port 4000**:
|
||||
```bash
|
||||
lsof -ti:4000 | xargs kill 2>/dev/null || true
|
||||
```
|
||||
|
||||
Do NOT use broad `pkill` patterns — there may be other Metabase instances on different ports.
|
||||
The backend process started in Phase 2 will NOT be killed automatically when the Claude session ends.
|
||||
Leaving it running wastes resources and can interfere with future sessions. Always clean up.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do NOT use Playwright as the first step — always analyze source code first.
|
||||
- Do NOT kill the backend between phases — it stays running throughout.
|
||||
- Do NOT invent selectors you didn't find in source code or observe in the browser.
|
||||
- Do NOT hardcode database IDs — import from `cypress_sample_database` or `cypress_sample_instance_data`.
|
||||
- Do NOT use `cy.wait(1000)` or any numeric wait.
|
||||
- Do NOT create setup flows through the UI when an API helper exists.
|
||||
- Do NOT put tests in `cypress/e2e/` — Metabase uses `e2e/test/scenarios/`.
|
||||
- Do NOT import helpers directly from `e2e/support/helpers` — use `const { H } = cy;`.
|
||||
78
.claude/skills/e2e-test/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: e2e-test
|
||||
description: Run Cypress E2E tests, analyze failures including screenshots, and stress test for flakiness
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
Run Cypress E2E test: $ARGUMENTS
|
||||
|
||||
## Edition selection
|
||||
|
||||
**Default: `MB_EDITION=oss`** — runs without enterprise tokens, simpler and faster.
|
||||
|
||||
Only use `MB_EDITION=ee` when the user explicitly asks to write or run an enterprise test.
|
||||
|
||||
**For `MB_EDITION=ee`:**
|
||||
These tokens must be exported in the shell before starting Claude Code:
|
||||
- `CYPRESS_MB_ALL_FEATURES_TOKEN` — all features (most EE tests need this)
|
||||
- `CYPRESS_MB_PRO_SELF_HOSTED_TOKEN` — pro self-hosted (permissions, SSO, sandboxing)
|
||||
- `CYPRESS_MB_STARTER_CLOUD_TOKEN` — starter plan (rarely needed)
|
||||
- `CYPRESS_MB_PRO_CLOUD_TOKEN` — pro cloud (rarely needed)
|
||||
|
||||
**NEVER echo, print, or log token values. Only check if they are set:**
|
||||
```bash
|
||||
echo "ALL_FEATURES: ${CYPRESS_MB_ALL_FEATURES_TOKEN:+set}" && echo "PRO_SELF_HOSTED: ${CYPRESS_MB_PRO_SELF_HOSTED_TOKEN:+set}" && echo "STARTER: ${CYPRESS_MB_STARTER_CLOUD_TOKEN:+set}" && echo "PRO_CLOUD: ${CYPRESS_MB_PRO_CLOUD_TOKEN:+set}"
|
||||
```
|
||||
|
||||
If tokens are missing, tell the user: "EE tokens are not set. Either export them and restart Claude Code, or add `MB_EDITION=oss` to run OSS-only tests."
|
||||
|
||||
## Running
|
||||
|
||||
First check if snapshots exist:
|
||||
```bash
|
||||
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
|
||||
```
|
||||
|
||||
If snapshots exist, skip regeneration for speed:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
If snapshots are missing (first run), let the runner generate them:
|
||||
```bash
|
||||
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
|
||||
```
|
||||
|
||||
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
|
||||
|
||||
If `$ARGUMENTS` is empty, ask the user which spec to run.
|
||||
|
||||
If the user provides a test name or fuzzy name instead of a spec path, find the spec file:
|
||||
```bash
|
||||
find e2e/test/scenarios -name "*FUZZY_NAME*.cy.spec.*" -type f
|
||||
```
|
||||
Then run with `--spec "path/to/matched.cy.spec.js"`.
|
||||
|
||||
To filter by test name, use the `GREP` env var (not `--env grep=`, which breaks on commas):
|
||||
```bash
|
||||
GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
|
||||
```
|
||||
|
||||
## Stress test (flaky detection)
|
||||
|
||||
When the user asks to verify a test is not flaky:
|
||||
|
||||
```bash
|
||||
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
|
||||
```
|
||||
|
||||
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
|
||||
|
||||
## Analyzing failures
|
||||
|
||||
When tests fail:
|
||||
|
||||
1. Read the **screenshot** for each failure — paths are printed in Cypress output under `(Screenshots)`, typically at `cypress/screenshots/<spec-name>/<test-name> (failed).png`
|
||||
2. Read the error message and code frame from the console output
|
||||
3. Identify the root cause: is it a test bug, a product bug, or a timing issue?
|
||||
4. Suggest a fix with specific code changes
|
||||
@@ -215,6 +215,7 @@
|
||||
settings
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util}}
|
||||
|
||||
auth-identity
|
||||
@@ -397,6 +398,7 @@
|
||||
request
|
||||
revisions
|
||||
settings
|
||||
tracing
|
||||
transforms
|
||||
upload
|
||||
util}}
|
||||
@@ -589,6 +591,7 @@
|
||||
settings
|
||||
sql-tools
|
||||
system
|
||||
tracing
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}}
|
||||
@@ -667,7 +670,8 @@
|
||||
:api #{metabase.events.core
|
||||
metabase.events.init}
|
||||
:uses #{models
|
||||
util}}
|
||||
util}
|
||||
:friends #{tracing}}
|
||||
|
||||
formatter
|
||||
{:team "UX West"
|
||||
@@ -749,6 +753,7 @@
|
||||
metabase.lib.schema.common
|
||||
metabase.lib.schema.expression
|
||||
metabase.lib.schema.expression.temporal
|
||||
metabase.lib.schema.filter
|
||||
metabase.lib.schema.id
|
||||
metabase.lib.schema.info
|
||||
metabase.lib.schema.join
|
||||
@@ -775,6 +780,7 @@
|
||||
:friends #{legacy-mbql ;; Reuses internal schemas and a few helper functions. Deeply coupled to MBQL.
|
||||
lib-be ;; BE-specific extension of lib; tight coupling is acceptable.
|
||||
query-processor ;; Necessarily manipulates MBQL; better to pull most logic into a new module.
|
||||
source-swap ;; Manipulates MBQL queries; tight coupling with lib is expected.
|
||||
enterprise/sandbox ;; Really part of the QP, though its lives in the enterprise/ tree.
|
||||
}}
|
||||
|
||||
@@ -889,6 +895,7 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util}}
|
||||
|
||||
models
|
||||
@@ -962,6 +969,7 @@
|
||||
system
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
users
|
||||
util}}
|
||||
|
||||
@@ -1005,6 +1013,7 @@
|
||||
premium-features
|
||||
remote-sync
|
||||
settings
|
||||
tracing
|
||||
util
|
||||
enterprise/advanced-permissions}}
|
||||
|
||||
@@ -1050,6 +1059,7 @@
|
||||
settings
|
||||
startup
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
product-feedback
|
||||
@@ -1117,6 +1127,7 @@
|
||||
request
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util
|
||||
enterprise/advanced-permissions
|
||||
enterprise/sandbox}}
|
||||
@@ -1260,6 +1271,7 @@
|
||||
server
|
||||
settings
|
||||
system
|
||||
tracing
|
||||
users
|
||||
util}}
|
||||
|
||||
@@ -1341,6 +1353,7 @@
|
||||
settings
|
||||
startup
|
||||
task
|
||||
tracing
|
||||
transforms
|
||||
util}}
|
||||
|
||||
@@ -1387,6 +1400,7 @@
|
||||
session
|
||||
settings
|
||||
system
|
||||
tracing
|
||||
users
|
||||
util
|
||||
enterprise/sso}}
|
||||
@@ -1408,6 +1422,7 @@
|
||||
sso
|
||||
system
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
settings
|
||||
@@ -1454,6 +1469,15 @@
|
||||
system
|
||||
util}}
|
||||
|
||||
source-swap
|
||||
{:team "Graphy"
|
||||
:api #{metabase.source-swap.core
|
||||
metabase.source-swap.schema}
|
||||
:uses #{driver
|
||||
lib
|
||||
sql-tools
|
||||
util}}
|
||||
|
||||
sql-parsing
|
||||
{:team "DevEx"
|
||||
:api #{metabase.sql-parsing.core}
|
||||
@@ -1521,6 +1545,7 @@
|
||||
settings
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses}}
|
||||
@@ -1537,6 +1562,7 @@
|
||||
metabase.task.core}
|
||||
:uses #{app-db
|
||||
classloader
|
||||
tracing
|
||||
util}}
|
||||
|
||||
task-history
|
||||
@@ -1553,6 +1579,7 @@
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
tracing
|
||||
util}}
|
||||
|
||||
tenants
|
||||
@@ -1600,6 +1627,15 @@
|
||||
models
|
||||
util}}
|
||||
|
||||
tracing
|
||||
{:team "DevEx"
|
||||
:api #{metabase.tracing.core
|
||||
metabase.tracing.init}
|
||||
:uses #{events
|
||||
settings
|
||||
task
|
||||
util}}
|
||||
|
||||
transforms
|
||||
{:team "Querying"
|
||||
:api #{metabase.transforms.core
|
||||
@@ -1622,6 +1658,7 @@
|
||||
settings
|
||||
task
|
||||
task-history
|
||||
tracing
|
||||
transforms-base
|
||||
util}
|
||||
:friends #{models
|
||||
@@ -1967,7 +2004,9 @@
|
||||
request
|
||||
server
|
||||
util
|
||||
enterprise/metabot-v3}}
|
||||
enterprise/api
|
||||
enterprise/metabot-v3
|
||||
enterprise/workspaces}}
|
||||
|
||||
enterprise/ai-entity-analysis
|
||||
{:team "Metabot"
|
||||
@@ -2159,7 +2198,8 @@
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.dependencies.api
|
||||
metabase-enterprise.dependencies.core
|
||||
metabase-enterprise.dependencies.init}
|
||||
metabase-enterprise.dependencies.init
|
||||
metabase-enterprise.dependencies.models.dependency}
|
||||
:uses #{analyze
|
||||
api
|
||||
app-db
|
||||
@@ -2180,6 +2220,7 @@
|
||||
request
|
||||
revisions
|
||||
settings
|
||||
sql-tools
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
@@ -2342,6 +2383,21 @@
|
||||
enterprise/serialization
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/replacement
|
||||
{:team "Graphy"
|
||||
:api #{metabase-enterprise.replacement.api}
|
||||
:uses #{api
|
||||
app-db
|
||||
events
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
queries
|
||||
source-swap
|
||||
task
|
||||
util
|
||||
enterprise/dependencies}}
|
||||
|
||||
enterprise/sandbox
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.sandbox.api.routes
|
||||
@@ -2405,6 +2461,7 @@
|
||||
search
|
||||
settings
|
||||
task
|
||||
tracing
|
||||
util
|
||||
enterprise/llm
|
||||
enterprise/metabot-v3}}
|
||||
@@ -2540,8 +2597,8 @@
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.workspaces.api
|
||||
metabase-enterprise.workspaces.init}
|
||||
:friends #{enterprise/agent-api}
|
||||
:uses #{api
|
||||
api-keys
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
|
||||
8
.gitattributes
vendored
@@ -1,7 +1,9 @@
|
||||
# Custom merge driver for YAML migration files
|
||||
# Treats each changeset as an atomic unit to avoid conflicts within changesets
|
||||
# Custom merge driver for per-release YAML migration files.
|
||||
# Treats each changeset as an atomic unit to avoid conflicts within changesets.
|
||||
# Only applies to per-release *_update_migrations.yaml files — directory-based
|
||||
# migrations (e.g. 060/*.yaml) have one file per migration and don't need this.
|
||||
# Execute this in your shell to enable custom merge driver:
|
||||
# echo '\n[include]\npath=../.gitconfig' >> .git/config
|
||||
#
|
||||
# TODO (Bryan 12/12/25) -- reenable this when we can completely verify correctness
|
||||
# resources/migrations/*.yaml merge=yaml-migrations
|
||||
# resources/migrations/*_update_migrations.yaml merge=yaml-migrations
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# execute this in your shell to enable custom merge driver:
|
||||
# echo '\n[include]\npath=../.gitconfig' >> .git/config
|
||||
[merge "yaml-migrations"]
|
||||
name = YAML migration changeset-aware merge
|
||||
name = Per-release YAML migration changeset-aware merge
|
||||
driver = bin/mage -merge-yaml-migrations %O %A %B %L %P
|
||||
|
||||
268
.github/CODEOWNERS
vendored
@@ -1,266 +1,30 @@
|
||||
# Before changing this file, make sure you have read how we use it:
|
||||
# https://www.notion.so/metabase/Pull-Request-Code-Review-Guide-374f05df889748b69d67af753f9bc9b8?pvs=4#36550c04ceab40b5a8b8bee6264b090c
|
||||
|
||||
# @metabase/core-backend-querying-platform
|
||||
src/metabase/lib @metabase/core-backend-querying-platform
|
||||
src/metabase/lib_be @metabase/core-backend-querying-platform
|
||||
src/metabase/query_processor @metabase/core-backend-querying-platform
|
||||
|
||||
# @metabase/graphy
|
||||
src/metabase/graph @metabase/graphy
|
||||
enterprise/backend/src/metabase_enterprise/dependencies @metabase/graphy
|
||||
enterprise/frontend/src/metabase-enterprise/dependencies @metabase/graphy
|
||||
|
||||
# @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-package @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-bundle @metabase/embedding
|
||||
enterprise/frontend/src/embedding-sdk-shared @metabase/embedding
|
||||
frontend/src/metabase/embedding-sdk @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedQuestion @metabase/embedding
|
||||
frontend/src/metabase/public/containers/PublicOrEmbeddedDashboard @metabase/embedding
|
||||
frontend/src/metabase/ui @metabase/core-frontend-ux-west
|
||||
# snowplow/* @metabase/data
|
||||
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
|
||||
.github @metabase/devexp
|
||||
.clj-kondo @metabase/devexp
|
||||
deps.edn @metabase/backend-developers
|
||||
|
||||
# @metabase/tech-writers
|
||||
docs @metabase/tech-writers
|
||||
docs/developers-guide # exclude from tech-writer ownership
|
||||
|
||||
#
|
||||
# Backend modules <=> teams that own them
|
||||
#
|
||||
# @metabase/devexp
|
||||
.github @metabase/devexp
|
||||
|
||||
# modules/drivers @metabase/drivers
|
||||
src/metabase/driver/sql_jdbc/actions.clj @metabase/core-ux-east
|
||||
test/metabase/driver/sql_jdbc/actions_test.clj @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_config @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_config @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/advanced_permissions @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/advanced_permissions @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/airgap @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/airgap @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/api @metabase/devexp
|
||||
# enterprise/backend/test/metabase_enterprise/api @metabase/devexp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/audit_app @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/audit_app @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/auth_provider @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/auth_provider @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/billing @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/billing @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/cache @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/cache @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/content_verification @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/content_verification @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/core @metabase/devexp
|
||||
# enterprise/backend/test/metabase_enterprise/core @metabase/devexp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/dashboard_subscription_filters @metabase/core-ux-east
|
||||
# enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/enhancements @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/enhancements @metabase/core-backend-admin-webapp
|
||||
|
||||
# # enterprise/backend/src/metabase_enterprise/llm
|
||||
# # enterprise/backend/test/metabase_enterprise/llm
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/query_reference_validation @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/query_reference_validation @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/sandbox @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/sandbox @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/scim @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/scim @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/search ...
|
||||
# enterprise/backend/test/metabase_enterprise/search ...
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/serialization @metabase/core-ux-east
|
||||
# enterprise/backend/test/metabase_enterprise/serialization @metabase/core-ux-east
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/snippet_collections @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/snippet_collections @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/sso @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/sso @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/stale @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/stale @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/stats @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/stats @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/task @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/task @metabase/core-backend-admin-webapp
|
||||
|
||||
# enterprise/backend/src/metabase_enterprise/upload_management @metabase/core-backend-admin-webapp
|
||||
# enterprise/backend/test/metabase_enterprise/upload_management @metabase/core-backend-admin-webapp
|
||||
|
||||
src/metabase/actions @metabase/core-ux-east
|
||||
test/metabase/actions @metabase/core-ux-east
|
||||
|
||||
# src/metabase/activity_feed @metabase/core-backend-admin-webapp
|
||||
# test/metabase/activity_feed @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/analytics @metabase/core-backend-admin-webapp
|
||||
# test/metabase/analytics @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/analyze @metabase/drivers
|
||||
# test/metabase/analyze @metabase/drivers
|
||||
|
||||
# src/metabase/api @metabase/devexp
|
||||
# test/metabase/api @metabase/devexp
|
||||
|
||||
# src/metabase/audit @metabase/core-backend-admin-webapp
|
||||
# test/metabase/audit @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/auth_provider @metabase/core-backend-admin-webapp
|
||||
# test/metabase/auth_provider @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/bookmarks @metabase/core-backend-admin-webapp
|
||||
# test/metabase/bookmarks @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/cmd @metabase/devexp
|
||||
# test/metabase/cmd @metabase/devexp
|
||||
|
||||
# src/metabase/channel @metabase/core-ux-east
|
||||
# test/metabase/channel @metabase/core-ux-east
|
||||
|
||||
# src/metabase/cloud_migration ...
|
||||
# test/metabase/cloud_migration ...
|
||||
|
||||
# src/metabase/cmd @metabase/devexp
|
||||
# test/metabase/cmd @metabase/devexp
|
||||
|
||||
# src/metabase/config @metabase/devexp
|
||||
# test/metabase/config @metabase/devexp
|
||||
|
||||
# src/metabase/core @metabase/devexp
|
||||
# test/metabase/core @metabase/devexp
|
||||
|
||||
# src/metabase/db @metabase/core-backend-admin-webapp
|
||||
# test/metabase/db @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/driver @metabase/drivers
|
||||
# test/metabase/driver @metabase/drivers
|
||||
|
||||
# src/metabase/eid_translation @metabase/core-backend-admin-webapp
|
||||
# test/metabase/eid_translation @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/embed @metabase/embedding
|
||||
# test/metabase/embed @metabase/embedding
|
||||
|
||||
# src/metabase/events @metabase/devexp
|
||||
# test/metabase/events @metabase/devexp
|
||||
|
||||
# src/metabase/formatter @metabase/core-backend-querying
|
||||
# test/metabase/formatter @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/indexed_entities @metabase/core-backend-querying
|
||||
# test/metabase/indexed_entities @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/integrations ...
|
||||
# test/metabase/integrations ...
|
||||
|
||||
# src/metabase/legacy_mbql @metabase/core-backend-querying
|
||||
# test/metabase/legacy_mbql @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/lib @metabase/core-backend-querying
|
||||
# test/metabase/lib @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/logger @metabase/devexp
|
||||
# test/metabase/logger @metabase/devexp
|
||||
|
||||
# src/metabase/login_history @metabase/core-backend-admin-webapp
|
||||
# test/metabase/login_history @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/model_persistence @metabase/core-backend-querying
|
||||
# test/metabase/model_persistence @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/models @metabase/devexp
|
||||
# test/metabase/models @metabase/devexp
|
||||
|
||||
# src/metabase/moderation @metabase/core-backend-admin-webapp
|
||||
# test/metabase/moderation @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/notification @metabase/core-ux-east
|
||||
# test/metabase/notification @metabase/core-ux-east
|
||||
|
||||
# src/metabase/permissions @metabase/core-backend-admin-webapp
|
||||
# test/metabase/permissions @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/plugins @metabase/devexp
|
||||
# test/metabase/plugins @metabase/devexp
|
||||
|
||||
# src/metabase/premium_features @metabase/core-backend-admin-webapp
|
||||
# test/metabase/premium_features @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/public_settings @metabase/core-backend-admin-webapp
|
||||
# test/metabase/public_settings @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/public_sharing @metabase/core-backend-admin-webapp
|
||||
# test/metabase/public_sharing @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/pulse @metabase/core-ux-east
|
||||
# test/metabase/pulse @metabase/core-ux-east
|
||||
|
||||
# src/metabase/query_analysis @metabase/core-backend-querying
|
||||
# test/metabase/query_analysis @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/query_processor @metabase/core-backend-querying
|
||||
# test/metabase/query_processor @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/request @metabase/devexp
|
||||
# test/metabase/request @metabase/devexp
|
||||
|
||||
# src/metabase/revisions @metabase/core-backend-admin-webapp
|
||||
# test/metabase/revisions @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sample_data @metabase/core-backend-admin-webapp
|
||||
# test/metabase/sample_data @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/search ...
|
||||
# test/metabase/search ...
|
||||
|
||||
# src/metabase/segments @metabase/core-backend-querying
|
||||
# test/metabase/segments @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/server @metabase/devexp
|
||||
# test/metabase/server @metabase/devexp
|
||||
|
||||
# src/metabase/session @metabase/core-backend-admin-webapp
|
||||
# test/metabase/session @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/setup @metabase/core-backend-admin-webapp
|
||||
# test/metabase/setup @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sso @metabase/core-backend-admin-webapp
|
||||
# test/metabase/sso @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/sync @metabase/drivers
|
||||
# test/metabase/sync @metabase/drivers
|
||||
|
||||
# src/metabase/task @metabase/devexp
|
||||
# test/metabase/task @metabase/devexp
|
||||
|
||||
# src/metabase/tiles @metabase/core-backend-dashviz
|
||||
# test/metabase/tiles @metabase/core-backend-dashviz
|
||||
|
||||
# src/metabase/timeline @metabase/core-backend-dashviz
|
||||
# test/metabase/timeline @metabase/core-backend-dashviz
|
||||
|
||||
# src/metabase/types @metabase/core-backend-querying
|
||||
# test/metabase/types @metabase/core-backend-querying
|
||||
|
||||
# src/metabase/upload @metabase/core-backend-admin-webapp
|
||||
# test/metabase/upload @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/user_key_value @metabase/core-backend-admin-webapp
|
||||
# test/metabase/user_key_value @metabase/core-backend-admin-webapp
|
||||
|
||||
# src/metabase/util @metabase/devexp
|
||||
# test/metabase/util @metabase/devexp
|
||||
|
||||
# src/metabase/xrays @metabase/core-backend-querying
|
||||
# test/metabase/xrays @metabase/core-backend-querying
|
||||
# @metabase/cloud-ops
|
||||
src/metabase/analytics/prometheus.clj @metabase/cloud-ops
|
||||
|
||||
25
.github/actions/fetch-artifact/action.yml
vendored
@@ -51,12 +51,25 @@ runs:
|
||||
|
||||
const artifact_id = artifacts.data.artifacts[0].id;
|
||||
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact_id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let download;
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: artifact_id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
console.log(`Attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
|
||||
if (attempt === maxAttempts) throw err;
|
||||
const delay = attempt * 15000; // 15s, 30s
|
||||
console.log(`Retrying in ${delay / 1000}s...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/mb.zip`, Buffer.from(download.data));
|
||||
|
||||
|
||||
14
.github/actions/prepare-backend/action.yml
vendored
@@ -19,10 +19,18 @@ runs:
|
||||
distribution: "temurin"
|
||||
- name: Install Clojure CLI
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh &&
|
||||
sudo bash ./linux-install-${{ inputs.clojure-version }}.sh
|
||||
for attempt in 1 2 3; do
|
||||
curl --retry 3 --retry-delay 5 --fail -O https://download.clojure.org/install/linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& sudo bash ./linux-install-${{ inputs.clojure-version }}.sh \
|
||||
&& break
|
||||
echo "::warning::Clojure install attempt $attempt failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if ! clojure --version; then
|
||||
echo "::error::Clojure CLI installation failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Get M2 cache key
|
||||
uses: ./.github/actions/get-cache-keys
|
||||
|
||||
52
.github/scripts/build-cross-version-matrix.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// This script is used in .github/workflows/cross-version.yml
|
||||
// to generate a matrix for cross-version migration testing
|
||||
|
||||
/**
|
||||
* Generate a CI matrix for cross-version migration testing (EE only).
|
||||
*
|
||||
* Creates pairs of [source, target] for testing HEAD against the last N
|
||||
* minor versions in both upgrade and downgrade directions.
|
||||
*
|
||||
* @param {number} currentVersion - The current major version number (e.g., 59)
|
||||
* @param {number} count - Number of previous minor versions to test against (default: 3)
|
||||
* @returns {{ config: Array<{ source: string, target: string }> }}
|
||||
*
|
||||
* @example
|
||||
* buildMatrix(59)
|
||||
* // Returns:
|
||||
* // {
|
||||
* // config: [
|
||||
* // { source: "HEAD", target: "v1.59.x" },
|
||||
* // { source: "v1.59.x", target: "HEAD" },
|
||||
* // { source: "HEAD", target: "v1.58.x" },
|
||||
* // { source: "v1.58.x", target: "HEAD" },
|
||||
* // { source: "HEAD", target: "v1.57.x" },
|
||||
* // { source: "v1.57.x", target: "HEAD" },
|
||||
* // ]
|
||||
* // }
|
||||
*/
|
||||
function buildCrossVersionMatrix(currentVersion, count = 3) {
|
||||
if (!Number.isInteger(currentVersion) || currentVersion < 1) {
|
||||
throw new Error(
|
||||
`Invalid currentVersion: ${currentVersion}. Must be a positive integer.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error(`Invalid count: ${count}. Must be a positive integer.`);
|
||||
}
|
||||
|
||||
const config = Array.from({ length: count }, (_, i) => currentVersion - i)
|
||||
.filter((majorVersion) => majorVersion >= 1)
|
||||
.flatMap((majorVersion) => {
|
||||
const version = `v1.${majorVersion}.x`;
|
||||
return [
|
||||
{ source: "HEAD", target: version },
|
||||
{ source: version, target: "HEAD" },
|
||||
];
|
||||
});
|
||||
|
||||
return { config };
|
||||
}
|
||||
|
||||
module.exports = { buildCrossVersionMatrix };
|
||||
242
.github/scripts/build-cross-version-matrix.unit.spec.js
vendored
Normal file
@@ -0,0 +1,242 @@
|
||||
const { buildCrossVersionMatrix } = require("./build-cross-version-matrix");
|
||||
|
||||
describe("buildCrossVersionMatrix", () => {
|
||||
describe("default behavior", () => {
|
||||
it("should generate matrix with 3 versions by default", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.58.x" },
|
||||
{ source: "v1.58.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.57.x" },
|
||||
{ source: "v1.57.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should use EE prefix (v1)", () => {
|
||||
const result = buildCrossVersionMatrix(50);
|
||||
expect(result.config[0].target).toBe("v1.50.x");
|
||||
expect(result.config[1].source).toBe("v1.50.x");
|
||||
});
|
||||
|
||||
it("should create pairs in both directions (upgrade and downgrade)", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
const entries = result.config;
|
||||
|
||||
// Check HEAD -> version (upgrade test)
|
||||
expect(entries.filter((e) => e.source === "HEAD")).toHaveLength(3);
|
||||
// Check version -> HEAD (downgrade test)
|
||||
expect(entries.filter((e) => e.target === "HEAD")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom count", () => {
|
||||
it("should generate matrix with custom count", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
expect(result.config).toHaveLength(10); // 5 versions × 2 directions
|
||||
expect(result.config).toEqual([
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.58.x" },
|
||||
{ source: "v1.58.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.57.x" },
|
||||
{ source: "v1.57.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.56.x" },
|
||||
{ source: "v1.56.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.55.x" },
|
||||
{ source: "v1.55.x", target: "HEAD" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should generate matrix with count=1", () => {
|
||||
const result = buildCrossVersionMatrix(59, 1);
|
||||
expect(result.config).toHaveLength(2);
|
||||
expect(result.config).toEqual([
|
||||
{ source: "HEAD", target: "v1.59.x" },
|
||||
{ source: "v1.59.x", target: "HEAD" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should generate matrix with large count", () => {
|
||||
const result = buildCrossVersionMatrix(59, 10);
|
||||
expect(result.config).toHaveLength(20);
|
||||
|
||||
// Check first and last entries
|
||||
expect(result.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
expect(result.config[18]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.50.x",
|
||||
});
|
||||
expect(result.config[19]).toEqual({
|
||||
source: "v1.50.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle version 1", () => {
|
||||
const result = buildCrossVersionMatrix(1);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.1.x" },
|
||||
{ source: "v1.1.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop when version would go below 1", () => {
|
||||
const result = buildCrossVersionMatrix(2, 5);
|
||||
// Can only generate v1.2.x and v1.1.x (2 versions, not 5)
|
||||
expect(result.config).toHaveLength(4);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.2.x" },
|
||||
{ source: "v1.2.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.1.x" },
|
||||
{ source: "v1.1.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle high version numbers", () => {
|
||||
const result = buildCrossVersionMatrix(100, 2);
|
||||
expect(result).toEqual({
|
||||
config: [
|
||||
{ source: "HEAD", target: "v1.100.x" },
|
||||
{ source: "v1.100.x", target: "HEAD" },
|
||||
{ source: "HEAD", target: "v1.99.x" },
|
||||
{ source: "v1.99.x", target: "HEAD" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw for undefined currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix()).toThrow(
|
||||
"Invalid currentVersion: undefined. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for non-integer currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(59.5)).toThrow(
|
||||
"Invalid currentVersion: 59.5. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for zero currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(0)).toThrow(
|
||||
"Invalid currentVersion: 0. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for negative currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(-1)).toThrow(
|
||||
"Invalid currentVersion: -1. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for NaN currentVersion", () => {
|
||||
expect(() => buildCrossVersionMatrix(NaN)).toThrow(
|
||||
"Invalid currentVersion: NaN. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for non-integer count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, 2.5)).toThrow(
|
||||
"Invalid count: 2.5. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for zero count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, 0)).toThrow(
|
||||
"Invalid count: 0. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for negative count", () => {
|
||||
expect(() => buildCrossVersionMatrix(59, -1)).toThrow(
|
||||
"Invalid count: -1. Must be a positive integer.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix structure for GitHub Actions", () => {
|
||||
it("should return object with config array", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
expect(result).toHaveProperty("config");
|
||||
expect(Array.isArray(result.config)).toBe(true);
|
||||
});
|
||||
|
||||
it("should produce valid JSON for GitHub Actions", () => {
|
||||
const result = buildCrossVersionMatrix(59);
|
||||
const json = JSON.stringify(result);
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.config).toHaveLength(6);
|
||||
expect(parsed.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
});
|
||||
|
||||
it("should have consistent entry structure", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
|
||||
result.config.forEach((entry) => {
|
||||
expect(entry).toHaveProperty("source");
|
||||
expect(entry).toHaveProperty("target");
|
||||
expect(typeof entry.source).toBe("string");
|
||||
expect(typeof entry.target).toBe("string");
|
||||
// Either source or target should be HEAD
|
||||
expect(entry.source === "HEAD" || entry.target === "HEAD").toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("version ordering", () => {
|
||||
it("should generate versions in descending order", () => {
|
||||
const result = buildCrossVersionMatrix(59, 5);
|
||||
const versions = result.config
|
||||
.filter((e) => e.source === "HEAD")
|
||||
.map((e) => e.target);
|
||||
|
||||
expect(versions).toEqual([
|
||||
"v1.59.x",
|
||||
"v1.58.x",
|
||||
"v1.57.x",
|
||||
"v1.56.x",
|
||||
"v1.55.x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should interleave upgrade and downgrade tests", () => {
|
||||
const result = buildCrossVersionMatrix(59, 2);
|
||||
|
||||
expect(result.config[0]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.59.x",
|
||||
});
|
||||
expect(result.config[1]).toEqual({
|
||||
source: "v1.59.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
expect(result.config[2]).toEqual({
|
||||
source: "HEAD",
|
||||
target: "v1.58.x",
|
||||
});
|
||||
expect(result.config[3]).toEqual({
|
||||
source: "v1.58.x",
|
||||
target: "HEAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
2
.github/scripts/setup-python-runner.sh
vendored
@@ -19,7 +19,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
git clone --depth 1 -b extensible-resource-schema https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
|
||||
20
.github/workflows/app-db.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2
|
||||
junit-name: be-tests-mariadb-10-2-ee
|
||||
image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6
|
||||
junit-name: be-tests-mariadb-10-6-ee
|
||||
image: circleci/mariadb:10.6
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
- name: MariaDB Latest
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
MB_MYSQL_SSL_TEST_USER: metabase
|
||||
MB_MYSQL_SSL_TEST_PASSWORD: ${{ secrets.MYSQL_RDS_SSL_INSTANCE_PASSWORD }}
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-2-ee (0)"
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -134,9 +134,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: cimg/mysql:8.0
|
||||
- name: MySQL 8.4
|
||||
junit-name: be-tests-mysql-8-4-ee
|
||||
image: cimg/mysql:8.4
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_AURORA_IAM_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_AURORA_IAM_SECRET_ACCESS_KEY }}
|
||||
# for historic reasons (I don't want to mess around with required jobs) the job name should be something like
|
||||
# "be-tests-mariadb-10-2-ee (0)"
|
||||
# "be-tests-mariadb-10-6-ee (0)"
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -258,9 +258,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x
|
||||
- name: Postgres 14.x
|
||||
junit-name: postgres-ee
|
||||
docker-image: postgres:12-alpine
|
||||
docker-image: postgres:14-alpine
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
|
||||
2
.github/workflows/backend.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
be-linter-eastwood-test:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
name: Eastwood (test)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
38
.github/workflows/codespell.yml
vendored
@@ -43,27 +43,35 @@ jobs:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Build Claude prompt
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
run: |
|
||||
cat > /tmp/claude_prompt.txt << 'PROMPT_HEADER'
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
PROMPT_HEADER
|
||||
cat codespell_output.txt >> /tmp/claude_prompt.txt
|
||||
cat >> /tmp/claude_prompt.txt << 'PROMPT_FOOTER'
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
PROMPT_FOOTER
|
||||
|
||||
- name: Fix spelling errors with Claude Code
|
||||
if: steps.codespell_check.outcome == 'failure'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
prompt: |
|
||||
You are tasked with fixing spelling errors detected by codespell in this repository.
|
||||
|
||||
## Codespell Output
|
||||
The following spelling errors were detected:
|
||||
```
|
||||
$(cat codespell_output.txt)
|
||||
```
|
||||
|
||||
## Instructions
|
||||
1. **Review the codespell output and identify reported spelling errors** - The usual format codespell uses is `typo ==> correction`
|
||||
2. **Fix every spelling error** - Use the Edit tool to fix each misspelling with the suggested correction
|
||||
- If codespell suggests multiple corrections, choose the most appropriate one based on context
|
||||
|
||||
Human reviewers will verify the changes before merging, so fix all errors reported by codespell.
|
||||
prompt_file: /tmp/claude_prompt.txt
|
||||
claude_args: |
|
||||
--allowedTools "Write,Read,Glob,Grep,Edit,MultiEdit,Bash(cat*),Bash(ls*)"
|
||||
--disallowedTools "Bash(git commit*),Bash(git push*),Bash(gh*)"
|
||||
|
||||
12
.github/workflows/cross-branch-migrations.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-postgres-12-ee:
|
||||
migrations-postgres-14-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
MB_DB_USER: circle_test
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12-alpine
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-mysql-8-0-ee:
|
||||
migrations-mysql-8-4-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
MB_DB_USER: root
|
||||
services:
|
||||
mysql:
|
||||
image: cimg/mysql:8.0
|
||||
image: cimg/mysql:8.4
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
base-ref: ${{ env.BASE_REF }}
|
||||
head-ref: ${{ env.HEAD_REF }}
|
||||
|
||||
migrations-mariadb-10-2-ee:
|
||||
migrations-mariadb-10-6-ee:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
MB_DB_USER: root
|
||||
services:
|
||||
mariadb:
|
||||
image: circleci/mariadb:10.2.23
|
||||
image: circleci/mariadb:10.6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
|
||||
102
.github/workflows/cross-version.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Cross-Version Migration Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_version:
|
||||
description: "Source version (e.g., v1.58.6 or HEAD)"
|
||||
required: true
|
||||
type: string
|
||||
target_version:
|
||||
description: "Target version (e.g., v1.58.7 or HEAD)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "cross-version/**"
|
||||
- ".github/workflows/cross-version.yml"
|
||||
- ".github/scripts/build-cross-version-matrix.js"
|
||||
|
||||
schedule:
|
||||
# Run nightly at 3 AM UTC
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
# Generate matrix for scheduled/PR runs
|
||||
generate-matrix:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
|
||||
- name: Generate version matrix
|
||||
id: set-matrix
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { buildCrossVersionMatrix } = require('${{ github.workspace }}/.github/scripts/build-cross-version-matrix');
|
||||
const { config } = buildCrossVersionMatrix(${{ vars.CURRENT_VERSION }});
|
||||
const matrix = JSON.stringify({ include: config });
|
||||
core.setOutput("matrix", matrix);
|
||||
console.log("matrix:", matrix);
|
||||
|
||||
# Matrix-based tests for scheduled/PR runs
|
||||
test-matrix:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
needs: generate-matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
run: |
|
||||
cd cross-version
|
||||
./test.sh --source "${{ matrix.source }}" --target "${{ matrix.target }}"
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
cd cross-version
|
||||
docker compose logs metabase || true
|
||||
docker compose logs postgres || true
|
||||
|
||||
# Manual test for workflow_dispatch
|
||||
test-manual:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
|
||||
- name: Run cross-version test
|
||||
env:
|
||||
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
|
||||
run: |
|
||||
cd cross-version
|
||||
./test.sh --source "${{ inputs.source_version }}" --target "${{ inputs.target_version }}"
|
||||
|
||||
- name: Show logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
cd cross-version
|
||||
docker compose logs metabase || true
|
||||
docker compose logs postgres || true
|
||||
20
.github/workflows/drivers-stress-test.yml
vendored
@@ -109,7 +109,7 @@ jobs:
|
||||
junit-name: 'be-tests-druid-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mariadb-10-2-ee:
|
||||
be-tests-mariadb-10-6-ee:
|
||||
if: inputs.driver == 'Maria DB'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
@@ -124,15 +124,15 @@ jobs:
|
||||
MB_MYSQL_TEST_USER: root
|
||||
services:
|
||||
mariadb:
|
||||
image: circleci/mariadb:10.2.23
|
||||
image: circleci/mariadb:10.6
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test MariaDB driver (10.2)
|
||||
- name: Test MariaDB driver (10.6)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-mariadb-10-2-ee'
|
||||
junit-name: 'be-tests-mariadb-10-6-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mariadb-latest-ee:
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
junit-name: 'be-tests-mongo-latest-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mysql-8-0-ee:
|
||||
be-tests-mysql-8-4-ee:
|
||||
if: inputs.driver == 'MySQL'
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
@@ -313,15 +313,15 @@ jobs:
|
||||
MB_MYSQL_TEST_USER: root
|
||||
services:
|
||||
mysql:
|
||||
image: cimg/mysql:8.0
|
||||
image: cimg/mysql:8.4
|
||||
ports:
|
||||
- "3306:3306"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test MySQL driver (8.0)
|
||||
- name: Test MySQL driver (8.4)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-mysql-8-0-ee'
|
||||
junit-name: 'be-tests-mysql-8-4-ee'
|
||||
test-args: ":only ${{ inputs.test }} :times ${{ inputs.times }}"
|
||||
|
||||
be-tests-mysql-latest-ee:
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
MB_POSTGRESQL_TEST_USER: mb_test
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12-alpine
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
@@ -447,7 +447,7 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test Postgres driver (12)
|
||||
- name: Test Postgres driver (14)
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
junit-name: 'be-tests-postgres-ee'
|
||||
|
||||
16
.github/workflows/drivers.yml
vendored
@@ -561,9 +561,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2
|
||||
junit-name: be-tests-mariadb-10-2-ee
|
||||
image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6
|
||||
junit-name: be-tests-mariadb-10-6-ee
|
||||
image: circleci/mariadb:10.6
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -573,9 +573,9 @@ jobs:
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
- name: MySQL 8.0
|
||||
junit-name: be-tests-mysql-8-0-ee
|
||||
image: mysql:8.0
|
||||
- name: MySQL 8.4
|
||||
junit-name: be-tests-mysql-8-4-ee
|
||||
image: mysql:8.4
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
@@ -988,9 +988,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x
|
||||
- name: Postgres 14.x
|
||||
junit-name: postgres-ee
|
||||
docker-image: postgres:12-alpine
|
||||
docker-image: postgres:14-alpine
|
||||
env:
|
||||
enable-ssl-tests: 'false'
|
||||
enable-aws-iam-tests: 'false'
|
||||
|
||||
3
.github/workflows/mage.yml
vendored
@@ -2,9 +2,6 @@ name: Mage Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "mage/**"
|
||||
- "bb.edn"
|
||||
|
||||
jobs:
|
||||
# run mage tests even if the rest of be isnt needed
|
||||
|
||||
78
.github/workflows/module-boundaries-stats.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Module Boundaries Stats
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: 40 1 * * 5 # every Friday at 1:40
|
||||
|
||||
jobs:
|
||||
module-boundaries-stats:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: get module boundary stats
|
||||
id: get-stats
|
||||
run: |
|
||||
NAMED_MODULES=$(node --input-type=module -e "
|
||||
import { elements } from './frontend/lint/module-boundaries.mjs';
|
||||
const named = elements.filter(e => !['shared/other', 'app/misc', 'other'].includes(e.type));
|
||||
")
|
||||
|
||||
NAMED_DIRS=$(node --input-type=module -e "
|
||||
import { elements } from './frontend/lint/module-boundaries.mjs';
|
||||
const dirs = new Set();
|
||||
for (const el of elements) {
|
||||
const m = el.pattern.match(/^frontend\/src\/metabase\/(\w[\w-]*)\//)
|
||||
if (m) dirs.add(m[1]);
|
||||
}
|
||||
")
|
||||
TOTAL_DIRS=$(ls -d frontend/src/metabase/*/ | xargs -I{} basename {} | wc -l | tr -d ' ')
|
||||
NAMED_COUNT=$(echo "$NAMED_DIRS" | grep -c . || true)
|
||||
UNMODULED=$(($TOTAL_DIRS - $NAMED_COUNT))
|
||||
|
||||
VIOLATIONS=$(bun run module-boundaries 2>&1 | grep -o '✖ [0-9]* problems' | grep -o '[0-9]*' || echo "0")
|
||||
|
||||
echo "$NAMED_MODULES modules, $UNMODULED unmoduled folders, $VIOLATIONS violations"
|
||||
|
||||
echo "NAMED_MODULES=$NAMED_MODULES" >> $GITHUB_OUTPUT
|
||||
echo "UNMODULED=$UNMODULED" >> $GITHUB_OUTPUT
|
||||
echo "VIOLATIONS=$VIOLATIONS" >> $GITHUB_OUTPUT
|
||||
- name: Upload stats
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
NAMED_MODULES: ${{ steps.get-stats.outputs.NAMED_MODULES }}
|
||||
UNMODULED: ${{ steps.get-stats.outputs.UNMODULED }}
|
||||
VIOLATIONS: ${{ steps.get-stats.outputs.VIOLATIONS }}
|
||||
API_KEY: ${{ secrets.STATS_API_KEY }}
|
||||
with:
|
||||
script: | # js
|
||||
const { uploadCsvToMb } = require('${{ github.workspace }}/.github/scripts/csv-to-mb.js');
|
||||
|
||||
console.log("NAMED_MODULES", Number(process.env.NAMED_MODULES));
|
||||
console.log("UNMODULED", Number(process.env.UNMODULED));
|
||||
console.log("VIOLATIONS", Number(process.env.VIOLATIONS));
|
||||
|
||||
const data = [
|
||||
{
|
||||
'Date': new Date().toISOString().slice(0, 10), // YYYY-MM-DD
|
||||
'Modules': Number(process.env.NAMED_MODULES),
|
||||
'Unmoduled folders': Number(process.env.UNMODULED),
|
||||
'Violations': Number(process.env.VIOLATIONS),
|
||||
}
|
||||
];
|
||||
|
||||
uploadCsvToMb({
|
||||
baseUrl: 'https://stats.metabase.com',
|
||||
tableId: 75146,
|
||||
jsonData: data,
|
||||
mode: 'append',
|
||||
}).then(() => {
|
||||
console.log('Data uploaded successfully');
|
||||
}).catch((error) => {
|
||||
console.error('Error uploading data:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
214
.github/workflows/repro-bot.yml
vendored
@@ -10,6 +10,11 @@ on:
|
||||
description: "Issue number to reproduce"
|
||||
required: true
|
||||
type: string
|
||||
repro_bot_branch:
|
||||
description: "Branch of repro-bot to use"
|
||||
required: false
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
repro-bot:
|
||||
@@ -21,7 +26,7 @@ jobs:
|
||||
id-token: write
|
||||
if: |
|
||||
(github.event_name == 'push') ||
|
||||
(github.event_name == 'issues' && github.event.label.name == 'repro-bot' && github.event.issue.state == 'open') ||
|
||||
(github.event_name == 'issues' && github.event.label.name == '.Run Repro Bot' && github.event.issue.state == 'open') ||
|
||||
(github.event_name == 'workflow_dispatch')
|
||||
|
||||
steps:
|
||||
@@ -36,48 +41,100 @@ jobs:
|
||||
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare-backend
|
||||
- uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Checkout repro-bot
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: metabase/repro-bot
|
||||
ref: ci-support
|
||||
ref: ${{ github.event.inputs.repro_bot_branch || 'main' }}
|
||||
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
path: repro-bot
|
||||
|
||||
- name: Pre-clone metabase bare git repo
|
||||
run: git clone --bare https://github.com/metabase/metabase.git git/metabase
|
||||
run: git clone --bare .git repro-bot/git/metabase
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Set up Python (uv)
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: Capture Node.js path for MCP subprocesses
|
||||
run: echo "NODE_BIN_DIR=$(dirname $(which node))" >> $GITHUB_ENV
|
||||
|
||||
- name: Install playwright-cli
|
||||
run: |
|
||||
npm install -g @playwright/cli@latest
|
||||
playwright-cli install --skills
|
||||
|
||||
- name: Install bbin and clj-nrepl-eval
|
||||
run: |
|
||||
# Install babashka
|
||||
curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install
|
||||
chmod +x install
|
||||
sudo ./install
|
||||
rm install
|
||||
|
||||
# Install bbin
|
||||
mkdir -p ~/.bbin/bin
|
||||
curl -sL https://raw.githubusercontent.com/babashka/bbin/main/bbin > ~/.bbin/bin/bbin
|
||||
chmod +x ~/.bbin/bin/bbin
|
||||
export PATH="$HOME/.bbin/bin:$PATH"
|
||||
export BBIN_HOME="$HOME/.bbin"
|
||||
|
||||
# Install clj-nrepl-eval via bbin
|
||||
bbin install https://github.com/bhauman/clojure-mcp-light.git \
|
||||
--tag v0.2.1 \
|
||||
--as clj-nrepl-eval \
|
||||
--main-opts '["-m" "clojure-mcp-light.nrepl-eval"]'
|
||||
|
||||
# Add to PATH for the rest of the workflow
|
||||
echo "$HOME/.bbin/bin" >> $GITHUB_PATH
|
||||
|
||||
# Verify
|
||||
clj-nrepl-eval --help | head -3
|
||||
|
||||
- name: Register MCP servers
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY_REPRO_BOT }}
|
||||
run: |
|
||||
UV_PATH="$(which uv)"
|
||||
jq -n \
|
||||
--arg uv "$UV_PATH" \
|
||||
--arg key "$LINEAR_API_KEY" \
|
||||
'{
|
||||
mcpServers: {
|
||||
"metabase-repro": {
|
||||
command: $uv,
|
||||
args: ["run", "--directory", "mcp", "--with", "fastmcp", "python", "-m", "metabase_repro"]
|
||||
},
|
||||
"linear-server": {
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
type: "http",
|
||||
headers: {
|
||||
Authorization: ("Bearer " + $key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}' > ~/.claude.json
|
||||
|
||||
- name: Generate GitHub App Token
|
||||
uses: actions/create-github-app-token@v1
|
||||
uses: actions/create-github-app-token@v2
|
||||
id: claude-app-token
|
||||
with:
|
||||
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Run repro-bot with Claude Code
|
||||
id: claude
|
||||
continue-on-error: true
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
CLAUDE_CODE_DEBUG_LOGS_DIR: /tmp/claude-debug-logs
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/repro-bot
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
GITHUB_ACTIONS_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
timeout-minutes: 30
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_REPRO_BOT }}
|
||||
github_token: ${{ steps.claude-app-token.outputs.token }}
|
||||
plugins: |
|
||||
@anthropic/plugin-productivity-linear
|
||||
prompt: |
|
||||
Read and follow the instructions in .claude/commands/repro.md to investigate this GitHub issue:
|
||||
https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}
|
||||
@@ -86,15 +143,124 @@ jobs:
|
||||
- The bare git repo has been pre-cloned at git/metabase. The master worktree still needs setup via setup_master_worktree().
|
||||
- The GITHUB_ACTIONS_RUN_URL environment variable is set. If any phase fails catastrophically, follow the CI error handling in repro.md to post the error to Linear with the action run URL.
|
||||
- Docker is available for appdb (Postgres/MySQL/MariaDB) if needed.
|
||||
- Java 21 and Python/uv are pre-installed.
|
||||
claude_args: |
|
||||
--allowedTools "Bash(playwright-cli*),Bash(gh *),Bash(jq*),Bash(find*),Bash(ls*),Bash(grep*),Bash(cat*),Bash(base64*),Bash(git*),Bash(npx*),Bash(docker*),Bash(kill*),Bash(curl*),Write,Read,Glob,Grep,Edit,MultiEdit,WebFetch,mcp__metabase_repro__*,mcp__plugin_productivity_linear__*"
|
||||
- Java and Python/uv are pre-installed.
|
||||
claude_args: >-
|
||||
--allowedTools
|
||||
"Glob,
|
||||
Grep,
|
||||
Read,
|
||||
ToolSearch,
|
||||
Skill(*),
|
||||
WebSearch,
|
||||
WebFetch(domain:docs.metabase.com),
|
||||
WebFetch(domain:metabase.com),
|
||||
WebFetch(domain:www.metabase.com),
|
||||
Edit(//./**),
|
||||
Edit(repro/**),
|
||||
Edit(/tmp/**),
|
||||
Write(//./**),
|
||||
Write(repro/**),
|
||||
Write(/tmp/**),
|
||||
Bash(base64 *),
|
||||
Bash(cat *),
|
||||
Bash(cd *),
|
||||
Bash(clj-nrepl-eval *),
|
||||
Bash(clojure *),
|
||||
Bash(cp *),
|
||||
Bash(diff *),
|
||||
Bash(docker *),
|
||||
Bash(echo *),
|
||||
Bash(find *),
|
||||
Bash(gh api repos/metabase/metabase/*),
|
||||
Bash(gh issue list * --repo metabase/metabase *),
|
||||
Bash(gh issue view * --repo metabase/metabase *),
|
||||
Bash(gh release *),
|
||||
Bash(gh search *),
|
||||
Bash(git *),
|
||||
Bash(grep *),
|
||||
Bash(head *),
|
||||
Bash(jar *),
|
||||
Bash(jq *),
|
||||
Bash(kill *),
|
||||
Bash(ls *),
|
||||
Bash(mkdir *),
|
||||
Bash(mv *),
|
||||
Bash(npx playwright-cli:*),
|
||||
Bash(pgrep *),
|
||||
Bash(playwright-cli:*),
|
||||
Bash(printf *),
|
||||
Bash(ps *),
|
||||
Bash(psql *),
|
||||
Bash(PGPASSWORD=*),
|
||||
Bash(python -m mcp.metabase_repro *),
|
||||
Bash(python3 *),
|
||||
Bash(sed *),
|
||||
Bash(sort *),
|
||||
Bash(strings *),
|
||||
Bash(tail *),
|
||||
Bash(tee *),
|
||||
Bash(touch *),
|
||||
Bash(unzip *),
|
||||
Bash(wc *),
|
||||
Bash(which *),
|
||||
Bash(xargs *),
|
||||
mcp__metabase-repro__api_call,
|
||||
mcp__metabase-repro__browser_snapshot,
|
||||
mcp__metabase-repro__check_setup,
|
||||
mcp__metabase-repro__check_setup_token,
|
||||
mcp__metabase-repro__cleanup,
|
||||
mcp__metabase-repro__create_env,
|
||||
mcp__metabase-repro__get_session_token,
|
||||
mcp__metabase-repro__get_status,
|
||||
mcp__metabase-repro__jar_download,
|
||||
mcp__metabase-repro__repl_eval,
|
||||
mcp__metabase-repro__reset_metabase,
|
||||
mcp__metabase-repro__run_test,
|
||||
mcp__metabase-repro__save_test_patch,
|
||||
mcp__metabase-repro__setup_master_worktree,
|
||||
mcp__metabase-repro__setup_worktree,
|
||||
mcp__metabase-repro__start_appdb,
|
||||
mcp__metabase-repro__start_metabase,
|
||||
mcp__metabase-repro__stop_appdb_tool,
|
||||
mcp__metabase-repro__stop_metabase,
|
||||
mcp__linear-server__create_attachment,
|
||||
mcp__linear-server__get_issue,
|
||||
mcp__linear-server__list_comments,
|
||||
mcp__linear-server__list_issues,
|
||||
mcp__linear-server__list_issue_labels,
|
||||
mcp__linear-server__save_comment,
|
||||
mcp__linear-server__save_issue"
|
||||
--disallowedTools "Bash(git push*),Bash(rm -rf /*)"
|
||||
|
||||
- name: Upload SUGGESTIONS.md artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Verify repro-bot completed successfully
|
||||
run: |
|
||||
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
|
||||
echo "::error::Claude Code crashed or exited with an error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXEC_FILE="${{ steps.claude.outputs.execution_file }}"
|
||||
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
|
||||
echo "::error::No execution file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "mcp__linear-server__" "$EXEC_FILE"; then
|
||||
echo "::error::Claude did not post a comment on Linear"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Repro-bot completed successfully and posted to Linear"
|
||||
|
||||
- name: Upload repro-bot artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: repro-suggestions
|
||||
path: repro/*/SUGGESTIONS.md
|
||||
name: repro-bot-${{ steps.issue.outputs.number }}
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
repro/*/SUGGESTIONS.md
|
||||
repro/*/*.log
|
||||
repro/*/*.patch
|
||||
repro/*/playwright/**
|
||||
${{ steps.claude.outputs.execution_file }}
|
||||
|
||||
18
.github/workflows/rerun-workflows.yml
vendored
@@ -40,11 +40,19 @@ jobs:
|
||||
const BRANCH = process.env.BRANCH_NAME;
|
||||
|
||||
if (ATTEMPT <= MAX_ATTEMPTS) {
|
||||
github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
try {
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 403) {
|
||||
console.log(`Skipping re-run: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (!BRANCH.includes('backport-')) {
|
||||
// notify slack of repeated failure
|
||||
const { WebClient } = require('@slack/web-api');
|
||||
|
||||
63
.github/workflows/reset-cloud-test-dataset.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Reset Cloud Test Dataset
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
driver:
|
||||
description: 'Driver to reset dataset for'
|
||||
required: true
|
||||
default: 'snowflake'
|
||||
type: choice
|
||||
options:
|
||||
- snowflake
|
||||
- bigquery-cloud-sdk
|
||||
- redshift
|
||||
- databricks
|
||||
dataset-name:
|
||||
description: 'Dataset name to drop (e.g., test-data)'
|
||||
required: true
|
||||
default: 'test-data'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
reset-dataset:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
CI: 'true'
|
||||
DRIVERS: ${{ inputs.driver }}
|
||||
# Snowflake
|
||||
MB_SNOWFLAKE_TEST_USER: METABASE CI
|
||||
MB_SNOWFLAKE_TEST_ACCOUNT: ${{ secrets.MB_SNOWFLAKE_TEST_ACCOUNT }}
|
||||
MB_SNOWFLAKE_TEST_PASSWORD: ${{ secrets.MB_SNOWFLAKE_TEST_PASSWORD }}
|
||||
MB_SNOWFLAKE_TEST_PRIVATE_KEY: ${{ secrets.MB_SNOWFLAKE_TEST_PRIVATE_KEY }}
|
||||
MB_SNOWFLAKE_TEST_WAREHOUSE: ${{ secrets.MB_SNOWFLAKE_TEST_WAREHOUSE }}
|
||||
# BigQuery
|
||||
MB_BIGQUERY_TEST_PROJECT_ID: ${{ secrets.BIGQUERY_TEST_PROJECT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_ID: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_ID }}
|
||||
MB_BIGQUERY_TEST_CLIENT_SECRET: ${{ secrets.MB_BIGQUERY_TEST_CLIENT_SECRET }}
|
||||
MB_BIGQUERY_TEST_ACCESS_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_ACCESS_TOKEN }}
|
||||
MB_BIGQUERY_TEST_REFRESH_TOKEN: ${{ secrets.MB_BIGQUERY_TEST_REFRESH_TOKEN }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON }}
|
||||
MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING: ${{ secrets.MB_BIGQUERY_CLOUD_SDK_TEST_SERVICE_ACCOUNT_JSON_ROUTING }}
|
||||
# Redshift
|
||||
MB_REDSHIFT_TEST_USER: metabase_ci
|
||||
MB_REDSHIFT_TEST_DB: testdb
|
||||
MB_REDSHIFT_TEST_HOST: ${{ secrets.MB_REDSHIFT_TEST_HOST }}
|
||||
MB_REDSHIFT_TEST_PASSWORD: ${{ secrets.MB_REDSHIFT_TEST_PASSWORD }}
|
||||
# Databricks
|
||||
MB_DATABRICKS_TEST_HOST: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HOST }}
|
||||
MB_DATABRICKS_TEST_HTTP_PATH: ${{ secrets.MB_DATABRICKS_JDBC_TEST_HTTP_PATH }}
|
||||
MB_DATABRICKS_TEST_TOKEN: ${{ secrets.MB_DATABRICKS_JDBC_TEST_TOKEN }}
|
||||
MB_DATABRICKS_TEST_CATALOG: metabase_ci
|
||||
MB_DATABRICKS_TEST_CLIENT_ID: ${{ secrets.MB_DATABRICKS_JDBC_TEST_CLIENT_ID }}
|
||||
MB_DATABRICKS_TEST_OAUTH_SECRET: ${{ secrets.MB_DATABRICKS_JDBC_TEST_OAUTH_SECRET }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/prepare-backend
|
||||
- name: Drop dataset
|
||||
run: >-
|
||||
clojure -X:dev:drivers:drivers-dev:test
|
||||
metabase.test.data.impl/drop-dataset!
|
||||
:driver '"${{ inputs.driver }}"'
|
||||
:dataset-name '"${{ inputs.dataset-name }}"'
|
||||
18
.github/workflows/semantic-search.yml
vendored
@@ -75,9 +75,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MariaDB 10.2 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-10-2
|
||||
mariadb-image: circleci/mariadb:10.2.23
|
||||
- name: MariaDB 10.6 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-10-6
|
||||
mariadb-image: circleci/mariadb:10.6
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: MariaDB Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-mariadb-latest
|
||||
@@ -149,9 +149,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: MySQL 8.0 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-8-0
|
||||
mysql-image: cimg/mysql:8.0
|
||||
- name: MySQL 8.4 with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-8-4
|
||||
mysql-image: cimg/mysql:8.4
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: MySQL Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-mysql-latest
|
||||
@@ -223,9 +223,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: Postgres 12.x with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-12
|
||||
postgres-image: postgres:12-alpine
|
||||
- name: Postgres 14.x with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-14
|
||||
postgres-image: postgres:14-alpine
|
||||
pgvector-image: pgvector/pgvector:pg17
|
||||
- name: Postgres Latest with pgvector pg17
|
||||
junit-name: semantic-search-tests-postgres-latest
|
||||
|
||||
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.class
|
||||
*.*.rej
|
||||
*.d.css
|
||||
*.iml
|
||||
*.jar
|
||||
*.log
|
||||
@@ -25,6 +26,7 @@
|
||||
/.env*
|
||||
!/.env.example
|
||||
/.envrc
|
||||
/.workmux.yaml
|
||||
/.lein-env
|
||||
/.lein-failures
|
||||
/.lein-plugins
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
3
.vscode/extensions.json
vendored
@@ -9,6 +9,7 @@
|
||||
"github.vscode-pull-request-github",
|
||||
"limejuly.vscode-yaml-block-highlight",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"styled-components.vscode-styled-components"
|
||||
"styled-components.vscode-styled-components",
|
||||
"vunguyentuan.vscode-css-variables"
|
||||
]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"jake.autoDetect": "off",
|
||||
"gulp.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"cssVariables.lookupFiles": ["**/*.d.css", "node_modules/@mantine/core/*.css"]
|
||||
}
|
||||
|
||||
12
CLAUDE.md
@@ -25,6 +25,18 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
|
||||
**Important**: When working with frontend code, read [frontend/CLAUDE.md](frontend/CLAUDE.md) for project-specific guidelines on component preferences, styling, TypeScript migration, testing requirements, and available scripts.
|
||||
|
||||
## Running Backend Tests
|
||||
|
||||
Use `./bin/test-agent` to run Clojure tests. It produces clean, plain-text output with no progress bars or ANSI codes.
|
||||
|
||||
```bash
|
||||
./bin/test-agent :only '[metabase.foo-test]' # run a namespace
|
||||
./bin/test-agent :only '[metabase.foo-test/some-test]' # run a single test
|
||||
./bin/test-agent :only '[metabase.foo-test metabase.bar-test]' # multiple namespaces
|
||||
```
|
||||
|
||||
Do not use `clj -X:dev:test` directly — its progress-bar output is hard to parse.
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.
|
||||
|
||||
55
bb.edn
@@ -162,6 +162,15 @@
|
||||
(p/shell "bin/pre-lint-migrations-file.sh")
|
||||
(lint-migrations-file/-main))}
|
||||
|
||||
new-migration
|
||||
{:doc "Create a new directory-based migration file (v60+)"
|
||||
:requires [[mage.new-migration :as new-migration]]
|
||||
:examples [["./bin/mage new-migration 60 mq_indexes" "Create resources/migrations/060/20260905_mq_indexes.yaml"]]
|
||||
:arg-schema [:tuple
|
||||
[:int {:name "version" :description "Major version number (e.g., 60)"}]
|
||||
[:string {:name "description" :description "Lowercase alphanumeric/underscore name (e.g., mq_indexes)"}]]
|
||||
:task (task! (new-migration/new-migration parsed))}
|
||||
|
||||
jar-download
|
||||
{:doc "Download (and optionally run) jar for a metabase version or branch"
|
||||
:examples [["./bin/mage jar-download 50" "Download the latest enterprise version of release 50 to ~/path/to/my/metabase/jars"]
|
||||
@@ -342,12 +351,13 @@
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
|
||||
["7" "Cloud driver + query-processor updated" "RUN" "query-processor module updated"]
|
||||
["8" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["9"
|
||||
["8" "Cloud driver + driver deps affected" "RUN" "driver module affected by shared code changes"]
|
||||
["9" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["10"
|
||||
(str (pr-str driver-triggers) " module deps affected")
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by module changes")]
|
||||
["10" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
["11" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
|
||||
[nil "--is-master-or-release MASTER_OR_RELEASE"]
|
||||
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
|
||||
@@ -533,4 +543,41 @@
|
||||
:requires [[mage.escherize.fix-whitespace :as fix-whitespace]]
|
||||
:examples [["mage -escherize-fix-whitespace '**/*.clj{,c,s}'" "Removes all lines like ' ' from all clj files"]]
|
||||
:arg-schema [:sequential [:string {:name "glob-pattern" :description "Glob pattern to match files"}]]
|
||||
:task (task! (fix-whitespace/run-clean parsed))}}}
|
||||
:task (task! (fix-whitespace/run-clean parsed))}
|
||||
|
||||
-edpaget-dev-env
|
||||
{:doc "Stand up (or tear down) an isolated dev environment for this worktree"
|
||||
:examples [["./bin/mage -edpaget-dev-env --app-db postgres" "Postgres app-db with auto ports"]
|
||||
["./bin/mage -edpaget-dev-env --app-db postgres --with mysql --with mongo" "Full stack"]
|
||||
["./bin/mage -edpaget-dev-env" "Minimal: just port config (H2 app-db)"]
|
||||
["./bin/mage -edpaget-dev-env --down" "Tear down containers for this worktree"]
|
||||
["./bin/mage -edpaget-dev-env --status" "Show running containers and ports"]]
|
||||
:requires [[mage.edpaget.dev-env :as dev-env]]
|
||||
:options [[nil "--app-db DB" "App database: h2 (default), postgres, mysql" :default "h2"]
|
||||
[nil "--with SERVICE" "Warehouse service (repeatable): postgres, mysql, mariadb, mongo, clickhouse, ldap, maildev"
|
||||
:multi true :default [] :update-fn conj]
|
||||
[nil "--down" "Tear down all containers for this worktree"]
|
||||
[nil "--status" "Show current env status"]
|
||||
[nil "--slot SLOT" "Override port slot (0-99)" :parse-fn Integer/parseInt]]
|
||||
:task (task! (dev-env/dev-env! parsed))}
|
||||
|
||||
-johnswanson-dev-env
|
||||
{:doc "Stand up (or tear down) an isolated dev environment for this worktree. Interactive prompts when flags omitted."
|
||||
:examples [["./bin/mage -johnswanson-dev-env" "Interactive TUI to configure infra (saves config)"]
|
||||
["./bin/mage -johnswanson-dev-env --run" "Configure + launch backend/frontend"]
|
||||
["./bin/mage -johnswanson-dev-env --run --no-frontend" "Configure + backend only"]
|
||||
["./bin/mage -johnswanson-dev-env --down" "Stop everything for this worktree"]
|
||||
["./bin/mage -johnswanson-dev-env --status" "Show running containers and processes"]]
|
||||
:requires [[mage.johnswanson.dev-env :as jds-dev-env]]
|
||||
:options [["-e" "--edition EDITION" "ee or oss (default: prompt)"]
|
||||
["-t" "--token TOKEN" "Token type: all-features, starter-cloud, pro-cloud, pro-self-hosted, none"]
|
||||
[nil "--app-db DB" "App database: h2, postgres, mysql, mariadb (default: prompt)"]
|
||||
[nil "--with SERVICE" "Warehouse service (repeatable): postgres, mysql, mariadb, mongo, clickhouse, ldap, maildev"
|
||||
:multi true :default [] :update-fn conj]
|
||||
[nil "--run" "Also launch backend + frontend in background"]
|
||||
[nil "--no-frontend" "Skip frontend dev server (only with --run)"]
|
||||
[nil "--down" "Stop everything for this worktree"]
|
||||
[nil "--status" "Show current env status"]
|
||||
[nil "--slot SLOT" "Override port slot (0-99)" :parse-fn Integer/parseInt]
|
||||
[nil "--fresh" "Recreate containers from scratch"]]
|
||||
:task (task! (jds-dev-env/dev-env! parsed))}}}
|
||||
|
||||
96
bin/claude-dangerous
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs Claude Code in a custom Docker container with --dangerously-skip-permissions.
|
||||
# The image includes Clojure, Java, Babashka, and all tools from mise.toml.
|
||||
# DB connection info is forwarded from mise.local.toml.
|
||||
#
|
||||
# Usage: workmux add --agent ./bin/claude-dangerous feat/my-branch
|
||||
#
|
||||
# Pass --build as the first arg to force a rebuild of the image.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE_NAME="metabase-claude-code"
|
||||
DOCKERFILE="$PROJECT_ROOT/dev/docker/claude-code/Dockerfile"
|
||||
|
||||
# Build image if --build passed or image doesn't exist
|
||||
if [[ "${1:-}" == "--build" ]]; then
|
||||
shift
|
||||
echo "Building $IMAGE_NAME image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
|
||||
elif ! docker image inspect "$IMAGE_NAME" &>/dev/null; then
|
||||
echo "Image $IMAGE_NAME not found, building..."
|
||||
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" "$PROJECT_ROOT"
|
||||
fi
|
||||
|
||||
# On macOS, extract Claude OAuth credentials from the system keychain
|
||||
# and prepare a minimal .claude.json for the container.
|
||||
AUTH_TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$AUTH_TMPDIR"' EXIT
|
||||
|
||||
AUTH_MOUNT_ARGS=()
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
KEYCHAIN_CREDS=$(security find-generic-password -s "Claude Code-credentials" -a "$USER" -w 2>/dev/null || true)
|
||||
if [[ -n "$KEYCHAIN_CREDS" ]]; then
|
||||
mkdir -p "$AUTH_TMPDIR/.claude"
|
||||
echo "$KEYCHAIN_CREDS" > "$AUTH_TMPDIR/.claude/.credentials.json"
|
||||
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/.claude/.credentials.json:/home/claude/.claude/.credentials.json")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build a minimal .claude.json with onboarding complete and project trust.
|
||||
# Mounting the host's full ~/.claude.json won't work (host-specific paths).
|
||||
python3 -c "
|
||||
import json, os
|
||||
host_config = '$HOME/.claude.json'
|
||||
config = {}
|
||||
if os.path.exists(host_config):
|
||||
with open(host_config) as f:
|
||||
full = json.load(f)
|
||||
# Copy only the auth-related keys
|
||||
for key in ('oauthAccount', 'customApiKeyResponses'):
|
||||
if key in full:
|
||||
config[key] = full[key]
|
||||
config['hasCompletedOnboarding'] = True
|
||||
config['bypassPermissionsModeAccepted'] = True
|
||||
config['dangerouslySkipPermissions'] = True
|
||||
config['autoAcceptPermissions'] = True
|
||||
config['numStartups'] = 1
|
||||
config['projects'] = {
|
||||
'/app': {
|
||||
'allowedTools': [],
|
||||
'hasTrustDialogAccepted': True,
|
||||
'skipConfirmationDialogs': True,
|
||||
}
|
||||
}
|
||||
with open('$AUTH_TMPDIR/claude.json', 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
"
|
||||
AUTH_MOUNT_ARGS+=(-v "$AUTH_TMPDIR/claude.json:/home/claude/.claude.json")
|
||||
|
||||
# Parse mise.local.toml for env vars (DB connection, ports, etc.)
|
||||
ENV_ARGS=()
|
||||
if [[ -f "$PROJECT_ROOT/mise.local.toml" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key="$(echo "$key" | xargs)"
|
||||
value="$(echo "$value" | sed 's/^[[:space:]]*"//;s/"[[:space:]]*$//')"
|
||||
if [[ -n "$key" && -n "$value" ]]; then
|
||||
# Rewrite localhost to host.docker.internal so container can reach host DBs
|
||||
if [[ "$key" == "MB_DB_CONNECTION_URI" ]]; then
|
||||
value="${value//localhost/host.docker.internal}"
|
||||
fi
|
||||
ENV_ARGS+=(-e "$key=$value")
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+ *= *"' "$PROJECT_ROOT/mise.local.toml")
|
||||
fi
|
||||
|
||||
exec docker run -it --rm \
|
||||
-v "$PROJECT_ROOT:/app" \
|
||||
-w /app \
|
||||
-v /dev/null:/app/mise.local.toml:ro \
|
||||
"${AUTH_MOUNT_ARGS[@]}" \
|
||||
-e ANTHROPIC_API_KEY \
|
||||
"${ENV_ARGS[@]}" \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
"$IMAGE_NAME" \
|
||||
--dangerously-skip-permissions
|
||||
11
bin/e2e-backend
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${MB_JETTY_PORT:-4000}"
|
||||
|
||||
if curl -sf "http://localhost:$PORT/api/health" > /dev/null 2>&1; then
|
||||
echo "⚠️ Backend already running on :$PORT — reusing it. If you need a different MB_EDITION, kill it first: lsof -ti:$PORT | xargs kill"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec node e2e/runner/start-backend.js
|
||||
42
bin/e2e-stress-test
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RUNS="${E2E_STRESS_RUNS:-5}"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: bin/e2e-stress-test [cypress args...]"
|
||||
echo " e.g. bin/e2e-stress-test --spec e2e/test/scenarios/organization/bookmarks-dashboard.cy.spec.js"
|
||||
echo ""
|
||||
echo "Set E2E_STRESS_RUNS=N to change iteration count (default: 5)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf cypress/screenshots cypress/videos
|
||||
|
||||
for i in $(seq 1 "$RUNS"); do
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Run $i / $RUNS"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
if ! CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress "$@"; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " FAILED on run $i / $RUNS"
|
||||
echo "=========================================="
|
||||
|
||||
if ls cypress/screenshots/**/*.png 1>/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "Screenshots:"
|
||||
find cypress/screenshots -name "*.png" -print
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " All $RUNS runs passed"
|
||||
echo "=========================================="
|
||||
@@ -18,16 +18,9 @@
|
||||
This format should no longer be used for new migrations, use `id-timestamp-format-re` instead."
|
||||
#"^v\d{2,}\.\d{2}-\d{3}$")
|
||||
|
||||
(s/def ::id (s/and string?
|
||||
(s/or
|
||||
;; new ids should conform this format
|
||||
:id-with-timestamp ;; e.g: v49.2023-12-14T08:54:54
|
||||
(s/and #(re-matches id-timestamp-format-re %))
|
||||
:id-with-old-version-format ;; e.g: v49.00-008
|
||||
(s/and #(re-matches id-number-format-re %)
|
||||
;; the cut off id for this old format is v49.00-060
|
||||
;; see #36787 for context
|
||||
#(neg? (compare % "v49.00-061"))))))
|
||||
;; Basic type check only — format enforcement is file-aware and happens in
|
||||
;; `lint-migrations-file/require-change-set-ids-match-file-format`.
|
||||
(s/def ::id string?)
|
||||
|
||||
(s/def ::author string?)
|
||||
|
||||
@@ -74,54 +67,6 @@
|
||||
[]))) ; provide an empty list if dbms-val is nil
|
||||
changes))))))
|
||||
|
||||
(def change-types-supporting-rollback
|
||||
"This set was generated with a little grep and awk from the docs here:
|
||||
https://docs.liquibase.com/workflows/liquibase-community/liquibase-auto-rollback.html
|
||||
|
||||
If a new change type is introduced that supports automatic rollback, it should be added
|
||||
to this set."
|
||||
#{:addCheckConstraint
|
||||
:addColumn
|
||||
:addDefaultValue
|
||||
:addForeignKeyConstraint
|
||||
:addLookupTable
|
||||
:addNotNullConstraint
|
||||
:addPrimaryKey
|
||||
:addUniqueConstraint
|
||||
:createIndex
|
||||
:createSequence
|
||||
:createSynonym
|
||||
:createTable
|
||||
:createView
|
||||
:disableCheckConstraint
|
||||
:disableTrigger
|
||||
:dropNotNullConstraint
|
||||
:enableCheckConstraint
|
||||
:enableTrigger
|
||||
:renameColumn
|
||||
:renameSequence
|
||||
:renameTable
|
||||
:renameTrigger
|
||||
:renameView
|
||||
;; assumes all custom changes use the `def-migration` or `define-reversible-migration` in
|
||||
;; [[metabase.app-db.custom-migrations]]
|
||||
:customChange})
|
||||
|
||||
(defn- major-version
|
||||
"Returns major version from id string, e.g. 44 from \"v44.00-034\""
|
||||
[id-str]
|
||||
(when (string? id-str)
|
||||
(some-> (re-find #"\d+" id-str) Integer/parseInt)))
|
||||
|
||||
(defn- rollback-present-when-required?
|
||||
"Ensures rollback key is present when change type doesn't support auto rollback"
|
||||
[{:keys [id changes] :as change-set}]
|
||||
(or
|
||||
(int? id)
|
||||
(< (major-version id) 45)
|
||||
(some change-types-supporting-rollback (mapcat keys changes))
|
||||
(contains? change-set :rollback)))
|
||||
|
||||
(defn- disallow-delete-cascade-with-add-column
|
||||
"Returns false if addColumn changeSet uses deleteCascade. See Metabase issue #14321"
|
||||
[{:keys [changes]}]
|
||||
@@ -135,7 +80,6 @@
|
||||
|
||||
(s/def ::change-set
|
||||
(s/and
|
||||
rollback-present-when-required?
|
||||
disallow-delete-cascade-with-add-column
|
||||
(s/keys :req-un [::id ::author ::changes ::comment]
|
||||
:opt-un [::preConditions])))
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.string :as str])
|
||||
(:import
|
||||
[clojure.lang ExceptionInfo]))
|
||||
(clojure.lang ExceptionInfo)
|
||||
(java.lang Integer)))
|
||||
|
||||
#_:clj-kondo/ignore
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -52,26 +53,58 @@
|
||||
(when (seq duplicates)
|
||||
(throw (validation-error "Change set IDs are not distinct." {:duplicates duplicates})))))
|
||||
|
||||
(defn- require-change-set-ids-in-order [change-log]
|
||||
(let [ids (change-set-ids change-log)
|
||||
out-of-order-ids (->> ids
|
||||
(partition 2 1)
|
||||
(filter (fn [[id1 id2]]
|
||||
(pos? (compare id1 id2)))))]
|
||||
(defn- directory-based-migration-file?
|
||||
"Returns true if the file is a directory-based migration file (e.g., `060/20260905_mq_indexes.yaml`)."
|
||||
[file]
|
||||
(boolean (re-matches #".*\d{3}/\d{8}_[a-z0-9_]+\.yaml$" (str file))))
|
||||
|
||||
(when (seq out-of-order-ids)
|
||||
(throw (validation-error "Change set IDs are not in order"
|
||||
{:out-of-order-ids out-of-order-ids})))))
|
||||
(defn- file-version
|
||||
"Extracts the migration version number from a file.
|
||||
For per-release files like `059_update_migrations.yaml`, parses from the filename.
|
||||
For directory-based files like `060/20260106_125531.yaml`, parses from the parent directory name."
|
||||
[^java.io.File file]
|
||||
(if (directory-based-migration-file? file)
|
||||
(parse-long (re-find #"\d+" (.getName (.getParentFile file))))
|
||||
(parse-long (re-find #"\d+" (.getName file)))))
|
||||
|
||||
(defn- changeset-version+id
|
||||
"Returns [version local-id] for a changeset.
|
||||
Parses from the ID itself, which should be of the form 'v{version}.{local-id}'."
|
||||
[_file changeset-id]
|
||||
(let [id-str (str changeset-id)]
|
||||
(when-let [[_ version local-id] (re-matches #"^v(\d+)\.(.+)$" id-str)]
|
||||
[(parse-long version) local-id])))
|
||||
|
||||
(defn- changeset-at-or-after?
|
||||
"Returns true if the changeset (identified by file context and changeset ID) is at or after
|
||||
the given [threshold-version threshold-id]."
|
||||
[file changeset-id threshold-version threshold-id]
|
||||
(when-let [[v local-id] (changeset-version+id file changeset-id)]
|
||||
(or (> v threshold-version)
|
||||
(and (= v threshold-version)
|
||||
(not (neg? (compare local-id threshold-id)))))))
|
||||
|
||||
(defn- require-change-set-ids-in-order [change-log file]
|
||||
(when-not (directory-based-migration-file? file)
|
||||
(let [ids (change-set-ids change-log)
|
||||
out-of-order-ids (->> ids
|
||||
(partition 2 1)
|
||||
(filter (fn [[id1 id2]]
|
||||
(pos? (compare id1 id2)))))]
|
||||
(when (seq out-of-order-ids)
|
||||
(throw (validation-error "Change set IDs are not in order"
|
||||
{:out-of-order-ids out-of-order-ids}))))))
|
||||
|
||||
(defn- require-change-set-ids-in-correct-file [change-log file]
|
||||
(let [file-version (parse-long (re-find #"\d+" (.getName file)))
|
||||
(let [fv (file-version file)
|
||||
ids (change-set-ids change-log)
|
||||
wrong-file-ids (->> ids
|
||||
(filter (fn [id]
|
||||
(let [id-version (parse-long (re-find #"\d+" id))]
|
||||
(if (= file-version 1)
|
||||
(> id-version 55)
|
||||
(not= file-version id-version))))))]
|
||||
wrong-file-ids
|
||||
(->> ids
|
||||
(filter (fn [id]
|
||||
(let [id-version (parse-long (re-find #"\d+" id))]
|
||||
(if (= fv 1)
|
||||
(> id-version 55)
|
||||
(not= fv id-version))))))]
|
||||
(when (seq wrong-file-ids)
|
||||
(throw (validation-error "Change set IDs are in the wrong file"
|
||||
{:wrong-file-ids wrong-file-ids})))))
|
||||
@@ -101,18 +134,19 @@
|
||||
(some #(check-change-use-types? target-types %) (get-in change-set [:changeSet :changes])))
|
||||
|
||||
(defn- require-no-types-in-change-log!
|
||||
"Returns true if none of the changes in the change-log contain usage of any type specified in `target-types`.
|
||||
|
||||
`id-filter-fn` is a function that takes an ID and return true if the changeset should be checked."
|
||||
"Throws if any changeset at or after [version id] uses a type in target-types.
|
||||
When version and id are not provided, checks all changesets."
|
||||
([target-types change-log]
|
||||
(require-no-types-in-change-log! target-types change-log (constantly true)))
|
||||
([target-types change-log id-filter-fn]
|
||||
(require-no-types-in-change-log! target-types change-log nil nil nil))
|
||||
([target-types change-log file version id]
|
||||
{:pre [(set? target-types)]}
|
||||
(when-let [using-types? (->> change-log
|
||||
(filter (fn [change-set]
|
||||
(let [id (get-in change-set [:changeSet :id])]
|
||||
(and (string? id)
|
||||
(id-filter-fn id)))))
|
||||
(let [cs-id (get-in change-set [:changeSet :id])]
|
||||
(and cs-id
|
||||
(if version
|
||||
(changeset-at-or-after? file cs-id version id)
|
||||
true)))))
|
||||
(filter #(check-change-set-use-types? target-types %))
|
||||
(map #(get-in % [:changeSet :id]))
|
||||
seq)]
|
||||
@@ -132,17 +166,46 @@
|
||||
(defn require-no-bare-boolean-types
|
||||
"Ensures that no \"boolean\" type columns are added in changesets with id later than v49.00-032. From that point on,
|
||||
\"${boolean.type}\" should be used instead, so that we can consistently use `BIT(1)` for Boolean columns on MySQL."
|
||||
[change-log]
|
||||
(require-no-types-in-change-log! #{"boolean"} change-log #(pos? (compare % "v49.00-032"))))
|
||||
[change-log file]
|
||||
(require-no-types-in-change-log! #{"boolean"} change-log file 49 "00-032"))
|
||||
|
||||
(defn require-no-datetime-type
|
||||
"Ensures that no \"datetime\" or \"timestamp without time zone\".
|
||||
From that point on, \"${timestamp_type}\" should be used instead, so that all of our time related columns are tz-aware."
|
||||
[change-log]
|
||||
[change-log file]
|
||||
(require-no-types-in-change-log!
|
||||
#{"datetime" "timestamp" "timestamp without time zone"}
|
||||
change-log
|
||||
#(pos? (compare % "v49.00-000"))))
|
||||
change-log file 49 "00-000"))
|
||||
|
||||
(def ^:private directory-based-id-format-re
|
||||
"For directory-based migration files (v60+), IDs must be prefixed with the correct version.
|
||||
E.g., `v60.my-migration-name` or `v60.1`"
|
||||
#"^v\d{2,}\..+$")
|
||||
|
||||
(defn- require-change-set-ids-match-file-format
|
||||
"Enforces that changeset IDs use the correct format for the file type:
|
||||
- Directory-based files (v60+): IDs must be prefixed with v{version}. (e.g., v60.anything)
|
||||
- 001_update_migrations.yaml: any string ID is allowed (legacy file)
|
||||
- Other per-release files: IDs must match the timestamp format"
|
||||
[change-log file]
|
||||
(cond
|
||||
(directory-based-migration-file? file)
|
||||
(let [ids (change-set-ids change-log)
|
||||
bad-ids (remove #(re-matches directory-based-id-format-re %) ids)]
|
||||
(when (seq bad-ids)
|
||||
(throw (validation-error
|
||||
(format "Directory-based migration file contains IDs without v{version}. prefix: %s"
|
||||
(str/join ", " bad-ids))
|
||||
{:invalid-ids (vec bad-ids)}))))
|
||||
|
||||
(not= (file-version file) 1)
|
||||
(let [ids (change-set-ids change-log)
|
||||
bad-ids (remove #(re-matches change-set.strict/id-timestamp-format-re %) ids)]
|
||||
(when (seq bad-ids)
|
||||
(throw (validation-error
|
||||
(format "Per-release migration file contains non-timestamp ID formats: %s"
|
||||
(str/join ", " bad-ids))
|
||||
{:invalid-ids (vec bad-ids)}))))))
|
||||
|
||||
(defn require-primary-key-exists-has-table-name
|
||||
"Ensures that all primaryKeyExists preconditions specify a tableName."
|
||||
@@ -165,25 +228,82 @@
|
||||
(throw (validation-error "Expected exactly one key." {:keys keys})))
|
||||
(first keys)))
|
||||
|
||||
(defn- major-version
|
||||
"Returns major version from id string, e.g. 44 from \"v44.00-034\".
|
||||
For directory-based migrations (file path matches `NNN/`), extracts
|
||||
version from the file path. Otherwise parses from the id string."
|
||||
[id-str file]
|
||||
(if-let [[_ file-version] (when file (re-find #"(\d{3})[/\\]" (str file)))]
|
||||
(Integer/parseInt file-version)
|
||||
(when (string? id-str)
|
||||
(some-> (re-find #"\d+" id-str) Integer/parseInt))))
|
||||
|
||||
(def change-types-supporting-rollback
|
||||
"This set was generated with a little grep and awk from the docs here:
|
||||
https://docs.liquibase.com/workflows/liquibase-community/liquibase-auto-rollback.html
|
||||
|
||||
If a new change type is introduced that supports automatic rollback, it should be added
|
||||
to this set."
|
||||
#{:addCheckConstraint
|
||||
:addColumn
|
||||
:addDefaultValue
|
||||
:addForeignKeyConstraint
|
||||
:addLookupTable
|
||||
:addNotNullConstraint
|
||||
:addPrimaryKey
|
||||
:addUniqueConstraint
|
||||
:createIndex
|
||||
:createSequence
|
||||
:createSynonym
|
||||
:createTable
|
||||
:createView
|
||||
:disableCheckConstraint
|
||||
:disableTrigger
|
||||
:dropNotNullConstraint
|
||||
:enableCheckConstraint
|
||||
:enableTrigger
|
||||
:renameColumn
|
||||
:renameSequence
|
||||
:renameTable
|
||||
:renameTrigger
|
||||
:renameView
|
||||
;; assumes all custom changes use the `def-migration` or `define-reversible-migration` in
|
||||
;; [[metabase.app-db.custom-migrations]]
|
||||
:customChange})
|
||||
|
||||
(defn- rollback-present-when-required?
|
||||
"Ensures rollback key is present when change type doesn't support auto rollback.
|
||||
`file` is used to extract version for directory-based migrations."
|
||||
[{:keys [id changes] :as change-set} file]
|
||||
(or
|
||||
(let [v (major-version (str id) file)]
|
||||
(and v (< v 45)))
|
||||
(some change-types-supporting-rollback (mapcat keys changes))
|
||||
(contains? change-set :rollback)))
|
||||
|
||||
(defn- validate-database-change-log [change-log file]
|
||||
(when (string? change-log)
|
||||
(throw (validation-error "Expected `:databaseChangeLog` to be a map, not a string.")))
|
||||
|
||||
(require-distinct-change-set-ids change-log)
|
||||
(require-change-set-ids-in-correct-file change-log file)
|
||||
(require-change-set-ids-in-order change-log)
|
||||
(require-change-set-ids-in-order change-log file)
|
||||
(require-change-set-ids-match-file-format change-log file)
|
||||
(require-no-bare-blob-or-text-types change-log)
|
||||
(require-no-bare-boolean-types change-log)
|
||||
(require-no-datetime-type change-log)
|
||||
(require-no-bare-boolean-types change-log file)
|
||||
(require-no-datetime-type change-log file)
|
||||
(require-primary-key-exists-has-table-name change-log)
|
||||
(let [{:keys [changeSet]
|
||||
:as all} (group-by only-key change-log)]
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet})
|
||||
(throw (validation-error "Expected exactly one of :property, :objectQuotingStrategy, or :changeSet."
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet :logicalFilePath})
|
||||
(throw (validation-error "Expected exactly one of :property, :objectQuotingStrategy, :logicalFilePath, or :changeSet."
|
||||
{:keys (keys all)})))
|
||||
(doseq [{:keys [changeSet]} changeSet
|
||||
:when (not (s/valid? ::changeSet changeSet))]
|
||||
(throw (validation-error "Invalid change set." (s/explain-data ::changeSet changeSet))))))
|
||||
(doseq [{:keys [changeSet]} changeSet]
|
||||
(when-not (s/valid? ::changeSet changeSet)
|
||||
(throw (validation-error "Invalid change set." (s/explain-data ::changeSet changeSet))))
|
||||
(when-not (rollback-present-when-required? changeSet file)
|
||||
(throw (validation-error "Rollback is required but not present."
|
||||
{:id (:id changeSet)}))))))
|
||||
|
||||
(defn- validate-migrations [migrations file]
|
||||
(require-database-change-log! migrations)
|
||||
@@ -195,7 +315,14 @@
|
||||
(let [dir-str #?(:bb "resources/migrations" :clj "../../resources/migrations")
|
||||
dir (io/file dir-str)]
|
||||
(->> (file-seq dir)
|
||||
(filter #(and (.isFile %) (str/ends-with? (.getName %) "_update_migrations.yaml"))))))
|
||||
(filter (fn [^java.io.File f]
|
||||
(and (.isFile f)
|
||||
(or
|
||||
;; Per-release files: 059_update_migrations.yaml
|
||||
(str/ends-with? (.getName f) "_update_migrations.yaml")
|
||||
;; Directory-based files: 060/20260106_125531.yaml
|
||||
(directory-based-migration-file? f)))))
|
||||
(sort-by str))))
|
||||
|
||||
(defn- migrations [file]
|
||||
(assert (.exists file) (format "%s does not exist" file))
|
||||
@@ -207,13 +334,22 @@
|
||||
(fix-vals (yaml/parse-string
|
||||
#_:clj-kondo/ignore (slurp file)))))
|
||||
|
||||
(defn- display-name
|
||||
"Returns a human-readable name for a migration file.
|
||||
For per-release files, returns just the filename.
|
||||
For directory-based files, returns `parent/filename`."
|
||||
[^java.io.File file]
|
||||
(if (directory-based-migration-file? file)
|
||||
(str (.getName (.getParentFile file)) "/" (.getName file))
|
||||
(.getName file)))
|
||||
|
||||
(defn- validate-all []
|
||||
(doseq [file (migration-files)]
|
||||
(println "Validating" (.getName file) "...")
|
||||
(println "Validating" (display-name file) "...")
|
||||
(try
|
||||
(validate-migrations (migrations file) file)
|
||||
(catch ExceptionInfo e
|
||||
(throw (ex-info (.getMessage e) (assoc (ex-data e) :file (.getName file)) e))))))
|
||||
(throw (ex-info (.getMessage e) (assoc (ex-data e) :file (display-name file)) e))))))
|
||||
|
||||
(defn -main
|
||||
"Entry point for Clojure CLI task `lint-migrations-file`. Run it with
|
||||
|
||||
@@ -200,17 +200,19 @@
|
||||
(validate-id "v49.2024-01-01T10:30:00"))))
|
||||
|
||||
(testing "invalid date components should throw an error"
|
||||
(are [msg id]
|
||||
(thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid change set\."
|
||||
(validate-id "v49.2024-30-01T10:30:00")
|
||||
msg)
|
||||
"invalid month" "v49.2024-13-01T10:30:00"
|
||||
"invalid day" "v49.2024-01-32T10:30:00"
|
||||
"invalid hour" "v49.2024-01-01T25:30:00"
|
||||
"invalid minute" "v49.2024-01-01T10:60:00"
|
||||
"invalid second" "v49.2024-01-01T10:30:60"))))
|
||||
(let [validate-id-strict (fn [id]
|
||||
(validate-file (io/file "049_update_migrations.yaml")
|
||||
(mock-change-set :id id)))]
|
||||
(are [msg id]
|
||||
(thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"non-timestamp ID formats"
|
||||
(validate-id-strict id))
|
||||
"invalid month" "v49.2024-13-01T10:30:00"
|
||||
"invalid day" "v49.2024-01-32T10:30:00"
|
||||
"invalid hour" "v49.2024-01-01T25:30:00"
|
||||
"invalid minute" "v49.2024-01-01T10:60:00"
|
||||
"invalid second" "v49.2024-01-01T10:30:60")))))
|
||||
|
||||
(deftest ^:parallel validate-id-in-file-test
|
||||
(letfn [(validate-id [id file]
|
||||
@@ -285,7 +287,7 @@
|
||||
(testing "missing rollback key fails"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Invalid change set\."
|
||||
#"Rollback is required but not present\."
|
||||
(validate (update (mock-change-set :id "v49.2024-01-01T10:30:00" :changes [{:sql {:sql "select 1"}}])
|
||||
:changeSet dissoc :rollback)))))
|
||||
(testing "nil rollback is allowed"
|
||||
|
||||
31
bin/test-agent
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Agent-friendly test runner for Metabase backend (Clojure) tests.
|
||||
#
|
||||
# Differences from a normal `clj -X:dev:test` run:
|
||||
# - Uses HAWK_MODE=cli/ci so eftest.report.pretty is used instead of the
|
||||
# progress-bar reporter. Failures print inline as they happen.
|
||||
# - Strips ANSI escape codes from output so results are plain text.
|
||||
# - Filters Maven download noise and reflection warnings.
|
||||
#
|
||||
# Usage:
|
||||
# bin/test-agent :only '[metabase.foo-test]'
|
||||
# bin/test-agent :only '[metabase.foo-test/some-test]'
|
||||
# bin/test-agent # runs all tests (rarely what you want)
|
||||
#
|
||||
# Any extra arguments are forwarded directly to `clojure -X:dev:ee:ee-dev:test`.
|
||||
# The :test alias uses port 0 (OS-assigned random port) so parallel runs never collide.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export HAWK_MODE=cli/ci
|
||||
|
||||
# Filter noise and strip ANSI escape sequences
|
||||
clean_output() {
|
||||
grep -v '^Downloading: ' \
|
||||
| grep -v '^Reflection warning,' \
|
||||
| grep -v '^Excluding directory' \
|
||||
| grep -v '^Looking for test namespaces in directory' \
|
||||
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
|
||||
}
|
||||
|
||||
exec clojure -X:dev:ee:ee-dev:test "$@" 2>&1 | clean_output
|
||||
80
bun.lock
@@ -102,7 +102,7 @@
|
||||
"hast-util-from-html": "^2.0.1",
|
||||
"hast-util-to-html": "^9.0.0",
|
||||
"history": "3",
|
||||
"html2canvas-pro": "1.5.0",
|
||||
"html2canvas-pro": "^2.0.2",
|
||||
"humanize-plus": "^1.8.1",
|
||||
"icepick": "2.4.0",
|
||||
"iframe-resizer": "~4.3.2",
|
||||
@@ -122,7 +122,6 @@
|
||||
"mustache": "^2.3.2",
|
||||
"normalizr": "^3.0.2",
|
||||
"ora": "^8.0.1",
|
||||
"password-generator": "2.3.2",
|
||||
"prettier": "^3.3.3",
|
||||
"process": "^0.11.10",
|
||||
"prop-types": "^15.5.7",
|
||||
@@ -138,12 +137,12 @@
|
||||
"react-dnd-html5-backend": "4",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-grid-layout": "^1.5.3",
|
||||
"react-innertext": "^1.1.5",
|
||||
"react-is": "^18.2.0",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router": "3",
|
||||
"react-router-redux": "^4.0.8",
|
||||
"react-transition-group": "^4.4.5",
|
||||
@@ -299,6 +298,7 @@
|
||||
"eslint-import-resolver-node": "^0.3.9",
|
||||
"eslint-import-resolver-webpack": "^0.13.10",
|
||||
"eslint-module-utils": "^2.12.1",
|
||||
"eslint-plugin-boundaries": "5.4.0",
|
||||
"eslint-plugin-chai-friendly": "^1.1.0",
|
||||
"eslint-plugin-cypress": "6.1.0",
|
||||
"eslint-plugin-depend": "1.4.0",
|
||||
@@ -686,6 +686,8 @@
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||
|
||||
"@boundaries/elements": ["@boundaries/elements@1.2.0", "", { "dependencies": { "eslint-import-resolver-node": "0.3.9", "eslint-module-utils": "2.12.1", "handlebars": "4.7.8", "is-core-module": "2.16.1", "micromatch": "4.0.8" } }, "sha512-W65Gum02liMd3hmNrLmDBX1u5BmRMcunouFjLXyhxHnNY4YlK1kTxsgfflZ5XBGSnPnO0MkiUzAcoGzYrlx0RQ=="],
|
||||
|
||||
"@bundled-es-modules/cookie": ["@bundled-es-modules/cookie@2.0.1", "", { "dependencies": { "cookie": "^0.7.2" } }, "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw=="],
|
||||
|
||||
"@bundled-es-modules/statuses": ["@bundled-es-modules/statuses@1.0.1", "", { "dependencies": { "statuses": "^2.0.1" } }, "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg=="],
|
||||
@@ -2898,6 +2900,8 @@
|
||||
|
||||
"eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
|
||||
|
||||
"eslint-plugin-boundaries": ["eslint-plugin-boundaries@5.4.0", "", { "dependencies": { "@boundaries/elements": "1.2.0", "chalk": "4.1.2", "eslint-import-resolver-node": "0.3.9", "eslint-module-utils": "2.12.1", "micromatch": "4.0.8" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-6SQmEhXCqGrrxm9YiM24SC95CqrVi2MUOm5SDrfquceh/os8MIAvZYsDU69zvtCSb1S6UbNEmdioi1gCDc8+VQ=="],
|
||||
|
||||
"eslint-plugin-chai-friendly": ["eslint-plugin-chai-friendly@1.1.0", "", { "peerDependencies": { "eslint": ">=3.0.0" } }, "sha512-+T1rClpDdXkgBAhC16vRQMI5umiWojVqkj9oUTdpma50+uByCZM/oBfxitZiOkjMRlm725mwFfz/RVgyDRvCKA=="],
|
||||
|
||||
"eslint-plugin-cypress": ["eslint-plugin-cypress@6.1.0", "", { "dependencies": { "globals": "^17.3.0" }, "peerDependencies": { "eslint": ">=9" } }, "sha512-B8sxtNpINDxFkmsu1qKYjg70VsP8SGneEXgLcZMk1bUZcW08S+JyaiMdof1x6dmt02FgOD7YkT4wOaOD5HotJw=="],
|
||||
@@ -3266,7 +3270,7 @@
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"html2canvas-pro": ["html2canvas-pro@1.5.0", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-izxSphcINRwfEVV6eamsPVdhsxSYqX8n/hxzK+niVWdB+onM+aYRoVO+xgS9iMmZoUleZTWg1tJwryikib2hXg=="],
|
||||
"html2canvas-pro": ["html2canvas-pro@2.0.2", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@6.1.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", "domutils": "^2.5.2", "entities": "^2.0.0" } }, "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A=="],
|
||||
|
||||
@@ -3906,7 +3910,7 @@
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.0", "", {}, "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.5", "", { "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" } }, "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA=="],
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": { "miller-rabin": "bin/miller-rabin" } }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="],
|
||||
|
||||
@@ -4164,8 +4168,6 @@
|
||||
|
||||
"pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="],
|
||||
|
||||
"password-generator": ["password-generator@2.3.2", "", { "bin": { "password-generator": "bin/password-generator" } }, "sha512-kJWUrdveSAqHCeWJWnv5vNc89hFHM5au+pvKja5+xCTxlRF3zQaecJlR6hSoOotAJtQ3otQq4/Q4iWc/TxsXhA=="],
|
||||
|
||||
"patch-console": ["patch-console@1.0.0", "", {}, "sha512-nxl9nrnLQmh64iTzMfyylSlRozL7kAXIaxw1fVcLYdyhNkJCRUzirRZTikXGJsg+hc4fqpneTK6iU2H1Q8THSA=="],
|
||||
|
||||
"patch-package": ["patch-package@8.0.0", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^9.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "rimraf": "^2.6.3", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.0.33", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA=="],
|
||||
@@ -4502,13 +4504,13 @@
|
||||
|
||||
"react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
|
||||
|
||||
"react-draggable": ["react-draggable@4.4.6", "", { "dependencies": { "clsx": "^1.1.1", "prop-types": "^15.8.1" } }, "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw=="],
|
||||
"react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
|
||||
|
||||
"react-dropzone": ["react-dropzone@14.2.3", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.6.0", "prop-types": "^15.8.1" } }, "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug=="],
|
||||
|
||||
"react-fast-compare": ["react-fast-compare@2.0.4", "", {}, "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="],
|
||||
|
||||
"react-grid-layout": ["react-grid-layout@1.5.0", "", { "dependencies": { "clsx": "^2.0.0", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.5", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" } }, "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w=="],
|
||||
"react-grid-layout": ["react-grid-layout@1.5.3", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g=="],
|
||||
|
||||
"react-innertext": ["react-innertext@1.1.5", "", {}, "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q=="],
|
||||
|
||||
@@ -4530,7 +4532,7 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-resizable": ["react-resizable@3.0.5", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.0.3" } }, "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w=="],
|
||||
"react-resizable": ["react-resizable@3.1.3", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw=="],
|
||||
|
||||
"react-router": ["react-router@3.2.6", "", { "dependencies": { "create-react-class": "^15.5.1", "history": "^3.0.0", "hoist-non-react-statics": "^3.3.2", "invariant": "^2.2.1", "loose-envify": "^1.2.0", "prop-types": "^15.7.2", "react-is": "^16.13.0", "warning": "^3.0.0" } }, "sha512-nlxtQE8B22hb/JxdaslI1tfZacxFU8x8BJryXOnR2RxB4vc01zuHYAHAIgmBkdk1kzXaA25hZxK6KAH/+CXArw=="],
|
||||
|
||||
@@ -5450,6 +5452,8 @@
|
||||
|
||||
"@babel/register/source-map-support": ["source-map-support@0.5.19", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw=="],
|
||||
|
||||
"@boundaries/elements/is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
|
||||
|
||||
"@cacheable/memory/keyv": ["keyv@5.5.3", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A=="],
|
||||
@@ -5546,8 +5550,6 @@
|
||||
|
||||
"@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
||||
|
||||
"@jest/core/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"@jest/core/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"@jest/core/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
@@ -5570,8 +5572,6 @@
|
||||
|
||||
"@jest/transform/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
|
||||
"@jest/transform/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"@jest/transform/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
@@ -5620,8 +5620,6 @@
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"@parcel/watcher/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"@pmmmwh/react-refresh-webpack-plugin/source-map": ["source-map@0.7.3", "", {}, "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="],
|
||||
|
||||
"@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
|
||||
@@ -5716,8 +5714,6 @@
|
||||
|
||||
"@storybook/react-docgen-typescript-plugin/find-cache-dir": ["find-cache-dir@3.3.1", "", { "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", "pkg-dir": "^4.1.0" } }, "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ=="],
|
||||
|
||||
"@storybook/react-docgen-typescript-plugin/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"@storybook/react-docgen-typescript-plugin/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@storybook/test/@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
|
||||
@@ -6218,8 +6214,6 @@
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"fast-png/pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||
|
||||
"figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
@@ -6238,8 +6232,6 @@
|
||||
|
||||
"find-test-names/acorn-walk": ["acorn-walk@8.2.0", "", {}, "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="],
|
||||
|
||||
"find-yarn-workspace-root/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"flat-cache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@@ -6324,8 +6316,6 @@
|
||||
|
||||
"http-proxy-middleware/is-plain-obj": ["is-plain-obj@3.0.0", "", {}, "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="],
|
||||
|
||||
"http-proxy-middleware/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"img-diff-js/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -6400,8 +6390,6 @@
|
||||
|
||||
"jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"jest-config/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"jest-config/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"jest-config/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
@@ -6416,16 +6404,12 @@
|
||||
|
||||
"jest-haste-map/jest-worker": ["jest-worker@30.2.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.2.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g=="],
|
||||
|
||||
"jest-haste-map/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"jest-junit/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"jest-leak-detector/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"jest-matcher-utils/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"jest-message-util/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"jest-message-util/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
@@ -6510,6 +6494,8 @@
|
||||
|
||||
"lint-staged/listr2": ["listr2@6.6.1", "", { "dependencies": { "cli-truncate": "^3.1.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^5.0.1", "rfdc": "^1.3.0", "wrap-ansi": "^8.1.0" } }, "sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg=="],
|
||||
|
||||
"lint-staged/micromatch": ["micromatch@4.0.5", "", { "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" } }, "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA=="],
|
||||
|
||||
"listr2/colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"load-json-file/graceful-fs": ["graceful-fs@4.2.4", "", {}, "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="],
|
||||
@@ -6548,8 +6534,6 @@
|
||||
|
||||
"mdast-util-to-markdown/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"miller-rabin/bn.js": ["bn.js@4.11.9", "", {}, "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="],
|
||||
|
||||
"mini-css-extract-plugin/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="],
|
||||
@@ -6762,8 +6746,6 @@
|
||||
|
||||
"react-docgen-typescript-plugin/find-cache-dir": ["find-cache-dir@3.3.1", "", { "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", "pkg-dir": "^4.1.0" } }, "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ=="],
|
||||
|
||||
"react-docgen-typescript-plugin/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"react-draggable/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
|
||||
|
||||
"react-grid-layout/fast-equals": ["fast-equals@4.0.3", "", {}, "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="],
|
||||
@@ -6914,8 +6896,6 @@
|
||||
|
||||
"stylelint/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"stylelint/micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"stylelint-scss/known-css-properties": ["known-css-properties@0.36.0", "", {}, "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA=="],
|
||||
|
||||
"stylelint-scss/mdn-data": ["mdn-data@2.24.0", "", {}, "sha512-i97fklrJl03tL1tdRVw0ZfLLvuDsdb6wxL+TrJ+PKkCbLrp2PCu2+OYdCKychIUm19nSM/35S6qz7pJpnXttoA=="],
|
||||
@@ -7174,8 +7154,6 @@
|
||||
|
||||
"@jest/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
||||
"@jest/core/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"@jest/core/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"@jest/core/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -7202,8 +7180,6 @@
|
||||
|
||||
"@jest/transform/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="],
|
||||
|
||||
"@jest/transform/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping/@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/gen-mapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="],
|
||||
@@ -7254,8 +7230,6 @@
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"@parcel/watcher/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"@redocly/cli/glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
|
||||
|
||||
"@redocly/cli/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
@@ -7354,8 +7328,6 @@
|
||||
|
||||
"@storybook/react-docgen-typescript-plugin/find-cache-dir/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||
|
||||
"@storybook/react-docgen-typescript-plugin/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"@storybook/test/@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.13", "", {}, "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw=="],
|
||||
|
||||
"@svgr/core/cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
@@ -7584,8 +7556,6 @@
|
||||
|
||||
"fast-glob/glob-parent/is-glob": ["is-glob@4.0.1", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg=="],
|
||||
|
||||
"fast-glob/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"file-entry-cache/flat-cache/flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"filing-cabinet/enhanced-resolve/graceful-fs": ["graceful-fs@4.2.6", "", {}, "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ=="],
|
||||
@@ -7596,8 +7566,6 @@
|
||||
|
||||
"find-cypress-specs/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"find-yarn-workspace-root/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"fork-ts-checker-webpack-plugin/chokidar/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"fork-ts-checker-webpack-plugin/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -7628,8 +7596,6 @@
|
||||
|
||||
"htmlparser2/domutils/domelementtype": ["domelementtype@2.2.0", "", {}, "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A=="],
|
||||
|
||||
"http-proxy-middleware/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"import-jsx/make-dir/semver": ["semver@6.3.0", "", { "bin": { "semver": "./bin/semver.js" } }, "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="],
|
||||
|
||||
"ink/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
@@ -7758,8 +7724,6 @@
|
||||
|
||||
"jest-config/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"jest-config/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"jest-config/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"jest-config/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -7776,8 +7740,6 @@
|
||||
|
||||
"jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"jest-haste-map/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"jest-leak-detector/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"jest-leak-detector/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -7786,8 +7748,6 @@
|
||||
|
||||
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-message-util/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"jest-message-util/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -7852,6 +7812,8 @@
|
||||
|
||||
"lint-staged/listr2/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"lint-staged/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"log-update/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
||||
"md5.js/hash-base/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
@@ -8034,8 +7996,6 @@
|
||||
|
||||
"react-docgen-typescript-plugin/find-cache-dir/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||
|
||||
"react-docgen-typescript-plugin/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"react-docgen/@types/babel__core/@types/babel__traverse": ["@types/babel__traverse@7.14.2", "", { "dependencies": { "@babel/types": "^7.3.0" } }, "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA=="],
|
||||
|
||||
"read-pkg-up/find-up/path-exists": ["path-exists@2.1.0", "", { "dependencies": { "pinkie-promise": "^2.0.0" } }, "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ=="],
|
||||
@@ -8110,8 +8070,6 @@
|
||||
|
||||
"stylelint/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"stylelint/micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"stylus/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"svgo/css-tree/mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
|
||||
|
||||
55
cross-version/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Cross-Version Migration Testing
|
||||
|
||||
Tests Metabase database migrations across version upgrades and downgrades.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Test upgrade (OSS)
|
||||
./test.sh --source v0.58.6 --target v0.58.7
|
||||
|
||||
# Test downgrade (OSS)
|
||||
./test.sh --source v0.58.7 --target v0.58.6
|
||||
|
||||
# Test Enterprise edition (use v1.x.x)
|
||||
./test.sh --source v1.58.6 --target v1.58.7
|
||||
```
|
||||
|
||||
Edition is inferred from version prefix: `v0.x.x` = OSS, `v1.x.x` = EE.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker
|
||||
- Docker Compose v2
|
||||
- Bun (for version helpers CLI)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Upgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE Metabase version, wait for healthy
|
||||
3. Stop SOURCE container (PostgreSQL persists)
|
||||
4. Start TARGET version - migrations run automatically
|
||||
5. Verify health check passes
|
||||
|
||||
### Downgrade Test
|
||||
1. Start PostgreSQL container
|
||||
2. Start SOURCE (newer) Metabase version, wait for healthy
|
||||
3. Stop SOURCE container
|
||||
4. Start TARGET (older) version - expects refusal
|
||||
5. Run `migrate down` command
|
||||
6. Start TARGET version again
|
||||
7. Verify health check passes
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `METABASE_PORT` | 3000 | Port to expose Metabase |
|
||||
| `HEALTH_TIMEOUT` | 120 | Seconds to wait for health check |
|
||||
|
||||
## CI
|
||||
|
||||
The GitHub workflow (`.github/workflows/cross-version.yml`) supports:
|
||||
- **Manual trigger**: Specify source/target versions
|
||||
- **Nightly schedule**: Runs predefined version matrix
|
||||
19
cross-version/cli.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
compareVersions,
|
||||
getDockerImage,
|
||||
getMajorVersion,
|
||||
} from "../release/src/cross-version-helpers";
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
|
||||
if (cmd === "compare") {
|
||||
console.log(compareVersions(args[0], args[1]));
|
||||
} else if (cmd === "image") {
|
||||
console.log(getDockerImage(args[0]));
|
||||
} else if (cmd === "major") {
|
||||
console.log(getMajorVersion(args[0]));
|
||||
} else {
|
||||
console.error("Usage: cli.ts <compare|image|major> [args]");
|
||||
process.exit(1);
|
||||
}
|
||||
32
cross-version/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: metabase
|
||||
POSTGRES_PASSWORD: metabase
|
||||
POSTGRES_DB: metabase
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U metabase"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
metabase:
|
||||
image: ${METABASE_IMAGE:-metabase/metabase:latest}
|
||||
ports:
|
||||
- "${METABASE_PORT:-3000}:3000"
|
||||
environment:
|
||||
MB_DB_TYPE: postgres
|
||||
MB_DB_DBNAME: metabase
|
||||
MB_DB_PORT: 5432
|
||||
MB_DB_USER: metabase
|
||||
MB_DB_PASS: metabase
|
||||
MB_DB_HOST: postgres
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
383
cross-version/test.sh
Executable file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Cross-version migration testing for Metabase
|
||||
#
|
||||
# Usage:
|
||||
# ./test.sh --source v0.58.6 --target v0.58.7
|
||||
#
|
||||
# Edition (OSS/EE) is inferred from version prefix (v0.x = OSS, v1.x = EE).
|
||||
# Direction (upgrade/downgrade) is inferred from version comparison.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Defaults
|
||||
SOURCE_VERSION=""
|
||||
TARGET_VERSION=""
|
||||
METABASE_PORT="${METABASE_PORT:-3000}"
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-120}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() { echo -e "${GREEN}[cross-version]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[cross-version]${NC} $*"; }
|
||||
error() { echo -e "${RED}[cross-version]${NC} $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --source VERSION --target VERSION
|
||||
|
||||
Options:
|
||||
--source VERSION Source version (e.g., v0.58.6 for OSS, v1.58.6 for EE)
|
||||
--target VERSION Target version (e.g., v0.58.7 for OSS, v1.58.7 for EE)
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
$0 --source v0.58.6 --target v0.58.7 # Upgrade test (OSS)
|
||||
$0 --source v0.58.7 --target v0.58.6 # Downgrade test (OSS)
|
||||
$0 --source v1.58.6 --target v1.58.7 # Upgrade test (EE)
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--source) SOURCE_VERSION="$2"; shift 2 ;;
|
||||
--target) TARGET_VERSION="$2"; shift 2 ;;
|
||||
--help) usage ;;
|
||||
*) error "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$SOURCE_VERSION" || -z "$TARGET_VERSION" ]]; then
|
||||
error "Both --source and --target are required"
|
||||
usage
|
||||
fi
|
||||
|
||||
# CLI wrapper for version helpers (uses tested TypeScript implementation)
|
||||
cli() {
|
||||
bun "$SCRIPT_DIR/cli.ts" "$@"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local timeout="$1"
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log "Waiting for Metabase to be healthy (timeout: ${timeout}s)..."
|
||||
|
||||
while true; do
|
||||
local current_time=$(date +%s)
|
||||
local elapsed=$((current_time - start_time))
|
||||
|
||||
if (( elapsed >= timeout )); then
|
||||
error "Health check timed out after ${timeout}s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if curl -sf "http://localhost:${METABASE_PORT}/api/health" > /dev/null 2>&1; then
|
||||
local response=$(curl -sf "http://localhost:${METABASE_PORT}/api/health")
|
||||
if [[ "$response" == '{"status":"ok"}' ]]; then
|
||||
log "Health check passed after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# Start Metabase with a specific image
|
||||
start_metabase() {
|
||||
local image="$1"
|
||||
log "Starting Metabase with image: $image"
|
||||
|
||||
METABASE_IMAGE="$image" METABASE_PORT="$METABASE_PORT" \
|
||||
docker compose up -d --quiet-pull
|
||||
}
|
||||
|
||||
# Stop Metabase (but keep postgres)
|
||||
stop_metabase() {
|
||||
log "Stopping Metabase container..."
|
||||
docker compose stop metabase
|
||||
docker compose rm -f metabase
|
||||
}
|
||||
|
||||
# Get edition prefix from version (0 for OSS, 1 for EE)
|
||||
get_edition_prefix() {
|
||||
local version="$1"
|
||||
if [[ "$version" == "HEAD" ]]; then
|
||||
echo "1" # HEAD is EE
|
||||
elif [[ "$version" =~ ^v([01])\. ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
else
|
||||
error "Cannot determine edition from version: $version"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# https://www.metabase.com/docs/latest/installation-and-operation/upgrading-metabase#using-the-migrate-down-command
|
||||
# Run a single migrate down step (rolls back exactly one major version)
|
||||
# This function is idempotent - it takes a version string and handles everything internally.
|
||||
migrate_down_step() {
|
||||
local version="$1"
|
||||
local image is_head="false"
|
||||
|
||||
# Resolve image from version
|
||||
image=$(cli image "$version") || return 1
|
||||
[[ "$version" == "HEAD" ]] && is_head="true"
|
||||
|
||||
log "Running 'migrate down' for version: $version (image: $image)"
|
||||
|
||||
local output
|
||||
local exit_code=0
|
||||
|
||||
# The HEAD Docker image has version "vUNKNOWN" in its version.properties,
|
||||
# which causes `migrate down` to fail with a NullPointerException when trying
|
||||
# to determine the current major version. To work around this, we extract the
|
||||
# JAR, replace version.properties with the upcoming version, and mount the
|
||||
# modified JAR.
|
||||
#
|
||||
# HEAD is the next major version (CURRENT_VERSION + 1).
|
||||
#
|
||||
# See DEV-1636 for a potential backend fix that would make this unnecessary.
|
||||
if [[ "$is_head" == "true" ]]; then
|
||||
if [[ -z "$CURRENT_VERSION" ]]; then
|
||||
error "CURRENT_VERSION must be set when testing HEAD downgrades"
|
||||
return 1
|
||||
fi
|
||||
local next_version=$((CURRENT_VERSION + 1))
|
||||
local upcoming_version="v1.${next_version}.0"
|
||||
local work_dir="/tmp/metabase-head-jar"
|
||||
local modified_jar="$work_dir/metabase.jar"
|
||||
|
||||
log "HEAD JAR needs patching (version.properties says 'vUNKNOWN', setting to $upcoming_version)"
|
||||
rm -rf "$work_dir"
|
||||
mkdir -p "$work_dir"
|
||||
|
||||
# Extract JAR from container (copy to host with proper permissions)
|
||||
docker create --name metabase-jar-extract "$image" >/dev/null
|
||||
docker cp metabase-jar-extract:/app/metabase.jar "$modified_jar"
|
||||
docker rm metabase-jar-extract >/dev/null
|
||||
|
||||
# Create version.properties with upcoming version and update the JAR
|
||||
echo "tag=$upcoming_version" > "$work_dir/version.properties"
|
||||
echo "hash=HEAD" >> "$work_dir/version.properties"
|
||||
echo "date=$(date +%Y-%m-%d)" >> "$work_dir/version.properties"
|
||||
(cd "$work_dir" && zip -u metabase.jar version.properties >/dev/null)
|
||||
|
||||
log "HEAD JAR patched - proceeding with migrate down"
|
||||
|
||||
output=$(METABASE_IMAGE="$image" docker compose run --rm --quiet-pull \
|
||||
-v "$modified_jar:/app/metabase.jar:ro" \
|
||||
metabase "migrate down" 2>&1) || exit_code=$?
|
||||
else
|
||||
output=$(METABASE_IMAGE="$image" docker compose run --rm --quiet-pull metabase "migrate down" 2>&1) || exit_code=$?
|
||||
fi
|
||||
|
||||
# Always print output for visibility
|
||||
echo "$output"
|
||||
|
||||
# Check for errors - Metabase returns 0 even on partial rollback failures
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
error "migrate down failed with exit code $exit_code"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$output" | grep -q "ERROR.*liquibase\|RollbackFailedException\|Command failed with exception"; then
|
||||
error "migrate down encountered errors (check logs above)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Cascading migrate down: rolls back multiple major versions one at a time
|
||||
# Metabase only supports rolling back one major version per `migrate down` call,
|
||||
# so we need to iterate through intermediate versions.
|
||||
cascading_migrate_down() {
|
||||
local source_version="$1"
|
||||
local target_version="$2"
|
||||
|
||||
local source_major target_major edition_prefix
|
||||
source_major=$(cli major "$source_version") || return 1
|
||||
target_major=$(cli major "$target_version") || return 1
|
||||
edition_prefix=$(get_edition_prefix "$source_version") || return 1
|
||||
|
||||
local steps=$((source_major - target_major))
|
||||
log "Cascading migrate down: v$source_major → v$target_major ($steps step(s))"
|
||||
|
||||
# Build version sequence: source version first, then intermediate rolling versions
|
||||
local versions=("$source_version")
|
||||
local current=$((source_major - 1))
|
||||
while (( current > target_major )); do
|
||||
versions+=("v${edition_prefix}.${current}.x")
|
||||
((current--))
|
||||
done
|
||||
|
||||
# Simple loop - each step handles its own image resolution
|
||||
local step=1
|
||||
for version in "${versions[@]}"; do
|
||||
log "--------------------------------------------"
|
||||
log "Cascade step $step/$steps: $version"
|
||||
log "--------------------------------------------"
|
||||
if ! migrate_down_step "$version"; then
|
||||
error "Migrate down failed at step $step ($version)"
|
||||
return 1
|
||||
fi
|
||||
((step++))
|
||||
done
|
||||
|
||||
log "--------------------------------------------"
|
||||
log "Cascading migrate down complete"
|
||||
}
|
||||
|
||||
# Check if Metabase refuses to start (downgrade detection)
|
||||
check_downgrade_refused() {
|
||||
local timeout=60
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log "Checking if Metabase detects downgrade and refuses to start..."
|
||||
|
||||
while true; do
|
||||
local current_time=$(date +%s)
|
||||
local elapsed=$((current_time - start_time))
|
||||
|
||||
if (( elapsed >= timeout )); then
|
||||
error "Timed out waiting for downgrade detection"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check container logs for downgrade error
|
||||
local logs=$(docker compose logs metabase 2>&1 || true)
|
||||
|
||||
if echo "$logs" | grep -qi "migrate.*down\|downgrade\|database.*newer"; then
|
||||
log "Metabase correctly detected downgrade scenario"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local status=$(docker compose ps metabase --format '{{.Status}}' 2>/dev/null || echo "")
|
||||
if [[ "$status" =~ [Ee]xit ]]; then
|
||||
log "Metabase container exited (expected for downgrade)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "Cleaning up..."
|
||||
docker compose down -v --remove-orphans 2>/dev/null || true
|
||||
}
|
||||
|
||||
check_image_exists() {
|
||||
local image="$1"
|
||||
if ! docker manifest inspect "$image" > /dev/null 2>&1; then
|
||||
error "Docker image not found: $image"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local direction source_image target_image
|
||||
|
||||
direction=$(cli compare "$SOURCE_VERSION" "$TARGET_VERSION") || exit 1
|
||||
source_image=$(cli image "$SOURCE_VERSION") || exit 1
|
||||
target_image=$(cli image "$TARGET_VERSION") || exit 1
|
||||
|
||||
if [[ "$direction" == "same" ]]; then
|
||||
error "Source and target versions are the same"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Checking if Docker images exist..."
|
||||
check_image_exists "$source_image"
|
||||
check_image_exists "$target_image"
|
||||
|
||||
log "============================================"
|
||||
log "Cross-Version Migration Test"
|
||||
log "============================================"
|
||||
log "Direction: $direction"
|
||||
log "Source: $source_image"
|
||||
log "Target: $target_image"
|
||||
log "============================================"
|
||||
|
||||
# Ensure clean state
|
||||
cleanup
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
log ""
|
||||
log "Step 1: Starting SOURCE version ($SOURCE_VERSION)..."
|
||||
start_metabase "$source_image"
|
||||
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ SOURCE version ($SOURCE_VERSION) failed health check"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "✅ SOURCE version ($SOURCE_VERSION) is healthy"
|
||||
|
||||
log ""
|
||||
log "Step 2: Stopping SOURCE version ($SOURCE_VERSION)..."
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "Step 3: Starting TARGET version ($TARGET_VERSION)..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if [[ "$direction" == "upgrade" ]]; then
|
||||
# Upgrade: should migrate automatically and become healthy
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after upgrade"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
log "✅ UPGRADE successful - TARGET version ($TARGET_VERSION) is healthy"
|
||||
|
||||
else
|
||||
# Downgrade: should refuse to start, then we run migrate down
|
||||
if ! check_downgrade_refused; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) did not properly detect downgrade"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop the failed container
|
||||
stop_metabase
|
||||
|
||||
log ""
|
||||
log "============================================"
|
||||
log "Step 4: Rolling back database ($SOURCE_VERSION → $TARGET_VERSION)..."
|
||||
log "============================================"
|
||||
if ! cascading_migrate_down "$SOURCE_VERSION" "$TARGET_VERSION"; then
|
||||
error "❌ migrate down failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Try starting again
|
||||
log ""
|
||||
log "Step 5: Starting TARGET version ($TARGET_VERSION) after migrate down..."
|
||||
start_metabase "$target_image"
|
||||
|
||||
if ! wait_for_health "$HEALTH_TIMEOUT"; then
|
||||
error "❌ TARGET version ($TARGET_VERSION) failed health check after migrate down"
|
||||
docker compose logs metabase
|
||||
exit 1
|
||||
fi
|
||||
log "✅ DOWNGRADE successful - TARGET version ($TARGET_VERSION) is healthy after migrate down"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "============================================"
|
||||
log "✅ TEST PASSED"
|
||||
log "============================================"
|
||||
}
|
||||
|
||||
main
|
||||
18
deps.edn
@@ -47,6 +47,8 @@
|
||||
com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} ; Honey SQL 2. SQL generation from Clojure data maps
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"} ; Talk to JDBC DBs
|
||||
com.github.steffan-westcott/clj-otel-api {:mvn/version "0.2.8"} ; Telemetry library
|
||||
com.github.steffan-westcott/clj-otel-exporter-otlp {:mvn/version "0.2.8"} ; OTLP span exporter (gRPC and HTTP)
|
||||
com.github.steffan-westcott/clj-otel-sdk {:mvn/version "0.2.8"} ; OpenTelemetry SDK for programmatic configuration
|
||||
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
|
||||
com.google.guava/guava {:mvn/version "33.5.0-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
|
||||
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
|
||||
@@ -80,7 +82,7 @@
|
||||
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
|
||||
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
|
||||
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.571"}
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
@@ -160,10 +162,10 @@
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
org.clojure/tools.reader {:mvn/version "1.5.2"}
|
||||
org.clojure/tools.trace {:mvn/version "0.8.0"} ; function tracing
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.3"}
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
|
||||
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
|
||||
org.graalvm.polyglot/js-community {:mvn/version "25.0.1" :extension "pom"} ; JavaScript engine
|
||||
@@ -330,9 +332,9 @@
|
||||
"-Dmb.jetty.join=false"
|
||||
"-Dmb.field.filter.operators.enabled=true"
|
||||
"-Dmb.api.key=test-api-key"
|
||||
;; Different port from normal `:dev` so you can run tests on a different server.
|
||||
;; TODO -- figure out how to do a random port like in the old project.clj?
|
||||
"-Dmb.jetty.port=3001"]}
|
||||
;; Port 0 tells the OS to assign a random free port, allowing parallel test runs.
|
||||
;; The actual port is read from the running Jetty server via server.instance/server-port.
|
||||
"-Dmb.jetty.port=0"]}
|
||||
|
||||
;; run the dev server with
|
||||
;; clojure -M:run
|
||||
|
||||
38
dev/docker/claude-code/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM ubuntu:noble
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl git rlwrap bash ca-certificates gnupg ripgrep sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user (--dangerously-skip-permissions refuses to run as root)
|
||||
RUN useradd -m -s /bin/bash claude
|
||||
RUN echo 'claude ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
USER claude
|
||||
|
||||
# Install mise
|
||||
RUN curl https://mise.run | sh
|
||||
ENV PATH="/home/claude/.local/bin:$PATH"
|
||||
|
||||
# Copy project mise.toml to install the same tool versions
|
||||
COPY --chown=claude:claude mise.toml /home/claude/.config/mise/config.toml
|
||||
|
||||
# Trust the mise config files so there's no interactive prompt
|
||||
RUN mise trust /home/claude/.config/mise/config.toml
|
||||
|
||||
# Install all tools defined in mise.toml
|
||||
RUN mise install
|
||||
|
||||
# Put mise shims on PATH
|
||||
RUN echo 'eval "$(mise activate bash)"' >> /home/claude/.bashrc
|
||||
ENV PATH="/home/claude/.local/share/mise/shims:$PATH"
|
||||
|
||||
# Auto-accept mise trust prompts for project configs mounted at runtime
|
||||
ENV MISE_YES=1
|
||||
|
||||
# Create .claude directory so Claude Code can write session data
|
||||
RUN mkdir -p /home/claude/.claude
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["claude"]
|
||||
@@ -133,7 +133,9 @@
|
||||
(let [;; Try different possible file paths
|
||||
possible-paths [(str "src/" file)
|
||||
file
|
||||
(str "test/" file)]
|
||||
(str "test/" file)
|
||||
(str "enterprise/backend/src/" file)
|
||||
(str "enterprise/backend/test/" file)]
|
||||
source-file (first (filter #(and % (.exists (java.io.File. ^String %))) possible-paths))]
|
||||
(when-not source-file
|
||||
(throw (ex-info (str "Could not find source file for: " file)
|
||||
|
||||
@@ -230,8 +230,13 @@
|
||||
(str "mutation-testing-lib-" short-ns "-" fn-name)))
|
||||
|
||||
(defn create-branch!
|
||||
"Create and checkout a new branch from the base branch for mutation testing a function."
|
||||
"Create and checkout a new branch from the base branch for mutation testing a function.
|
||||
Fails early if there are uncommitted changes."
|
||||
[target-ns fn-name]
|
||||
(let [status (:out (sh "git" "status" "--porcelain" "-uno"))]
|
||||
(when-not (str/blank? status)
|
||||
(throw (ex-info (str "Uncommitted changes detected. Commit or stash before running.\n" status)
|
||||
{:status status}))))
|
||||
(let [branch (branch-name target-ns fn-name)]
|
||||
(sh "git" "checkout" (base-branch))
|
||||
(sh "git" "pull")
|
||||
@@ -441,19 +446,22 @@
|
||||
test-path-base (-> test-ns
|
||||
(str/replace "." "/")
|
||||
(str/replace "-" "_"))
|
||||
source-ext (cond
|
||||
(.exists (io/file (str "src/" ns-path ".cljc"))) ".cljc"
|
||||
(.exists (io/file (str "src/" ns-path ".clj"))) ".clj"
|
||||
:else ".cljc")
|
||||
test-ext (cond
|
||||
(.exists (io/file (str "test/" test-path-base ".cljc"))) ".cljc"
|
||||
(.exists (io/file (str "test/" test-path-base ".clj"))) ".clj"
|
||||
:else ".cljc")]
|
||||
source-prefixes ["src/" "enterprise/backend/src/"]
|
||||
test-prefixes ["test/" "enterprise/backend/test/"]
|
||||
find-file (fn [prefixes base exts]
|
||||
(first (for [prefix prefixes, ext exts
|
||||
:let [path (str prefix base ext)]
|
||||
:when (.exists (io/file path))]
|
||||
{:prefix prefix :ext ext :path path})))
|
||||
source-match (or (find-file source-prefixes ns-path [".cljc" ".clj"])
|
||||
{:prefix "src/" :ext ".cljc" :path (str "src/" ns-path ".cljc")})
|
||||
test-match (or (find-file test-prefixes test-path-base [".cljc" ".clj"])
|
||||
{:prefix "test/" :ext ".cljc" :path (str "test/" test-path-base ".cljc")})]
|
||||
{:target-ns (symbol target-ns)
|
||||
:test-ns (symbol test-ns)
|
||||
:short-name short-name
|
||||
:source-path (str "src/" ns-path source-ext)
|
||||
:test-path (str "test/" test-path-base test-ext)
|
||||
:source-path (:path source-match)
|
||||
:test-path (:path test-match)
|
||||
:report-path (str "mutation-testing-report." target-ns ".before.md")}))
|
||||
|
||||
(defn group-functions
|
||||
@@ -717,6 +725,70 @@
|
||||
(println " " (:primary-fn r) "-" (:error r))))
|
||||
(println "========================================")))
|
||||
|
||||
(defn run-one!
|
||||
"Run mutation testing for a single function. No branching or PR creation.
|
||||
Generates a report, invokes Claude to write tests, and verifies mutations are killed.
|
||||
|
||||
Arguments:
|
||||
- fq-fn-sym — fully-qualified function symbol (e.g. 'my.ns/my-fn)
|
||||
- test-ns-sym — test namespace symbol (e.g. 'my.ns-test)
|
||||
|
||||
Options:
|
||||
- :max-retries — max Claude retries for surviving mutations (default 2)"
|
||||
([fq-fn-sym test-ns-sym] (run-one! fq-fn-sym test-ns-sym {}))
|
||||
([fq-fn-sym test-ns-sym opts]
|
||||
(let [fq-fn-name (symbol fq-fn-sym)
|
||||
target-ns (symbol (namespace fq-fn-name))
|
||||
test-ns (symbol (str test-ns-sym))
|
||||
parsed (parse-namespace target-ns)
|
||||
{:keys [source-path test-path report-path]} parsed
|
||||
max-retries (get opts :max-retries 2)]
|
||||
|
||||
;; 1. Generate report
|
||||
(println "Generating mutation testing report for" (str target-ns) "...")
|
||||
(coverage/generate-report target-ns [test-ns] report-path)
|
||||
(println "Report written to" report-path)
|
||||
|
||||
;; 2. Run coverage for the single function
|
||||
(println "Running coverage for" (str fq-fn-name) "...")
|
||||
(let [coverage-results (coverage/test-namespace target-ns [test-ns])
|
||||
fn-data (get coverage-results fq-fn-name)]
|
||||
(when-not fn-data
|
||||
(throw (ex-info (str "Function not found in coverage results: " fq-fn-name)
|
||||
{:available (keys coverage-results)})))
|
||||
(let [mutations (:survived fn-data)]
|
||||
(if (empty? mutations)
|
||||
(do (println "No surviving mutations for" (str fq-fn-name) "— nothing to do.")
|
||||
{:status :success :killed [] :survived []})
|
||||
|
||||
(do
|
||||
(println (count mutations) "surviving mutations")
|
||||
|
||||
;; 3. Invoke Claude
|
||||
(println "Invoking Claude to write tests...")
|
||||
(let [prompt (build-test-prompt {:target-ns target-ns
|
||||
:test-ns test-ns
|
||||
:source-path source-path
|
||||
:test-path test-path
|
||||
:fn-names [fq-fn-name]
|
||||
:mutations mutations})]
|
||||
(invoke-claude! prompt))
|
||||
|
||||
;; 4. Verify and retry
|
||||
(println "Verifying mutations killed...")
|
||||
(let [result (verify-and-retry! {:fn-names [fq-fn-name]
|
||||
:test-ns test-ns
|
||||
:test-path test-path
|
||||
:max-retries max-retries})]
|
||||
(println "\nResult:" (:status result))
|
||||
(println " Killed:" (count (:killed result)))
|
||||
(println " Survived:" (count (:survived result)))
|
||||
(when (seq (:survived result))
|
||||
(println "\n Surviving mutations:")
|
||||
(doseq [{:keys [description]} (:survived result)]
|
||||
(println " -" description)))
|
||||
result))))))))
|
||||
|
||||
(defn run!
|
||||
"Main entry point. Runs the full mutation testing workflow for a namespace.
|
||||
|
||||
@@ -735,6 +807,11 @@
|
||||
(set-config! {:base-branch bb}))
|
||||
(when-let [pid (:project-id opts)]
|
||||
(set-config! {:project-id pid}))
|
||||
;; 0. Fail early if working tree is dirty
|
||||
(let [status (:out (sh "git" "status" "--porcelain" "-uno"))]
|
||||
(when-not (str/blank? status)
|
||||
(throw (ex-info (str "Uncommitted changes detected. Commit or stash before running.\n" status)
|
||||
{:status status}))))
|
||||
(let [parsed (parse-namespace target-ns-sym)
|
||||
{:keys [target-ns test-ns report-path]} parsed]
|
||||
;; 1. Generate report
|
||||
@@ -767,7 +844,15 @@
|
||||
(try
|
||||
(process-group! parsed group)
|
||||
(catch Exception e
|
||||
(println "ERROR:" (.getMessage e))
|
||||
(let [log-file (str "mutation-testing-error-"
|
||||
(name (:primary-fn group))
|
||||
".log")]
|
||||
(with-open [w (io/writer log-file)]
|
||||
(.write w (str "Error processing group: " (:primary-fn group) "\n"))
|
||||
(.write w (str "Message: " (.getMessage e) "\n\n"))
|
||||
(.printStackTrace e (java.io.PrintWriter. w)))
|
||||
(println "ERROR:" (.getMessage e))
|
||||
(println " Full stack trace logged to" log-file))
|
||||
(try (return-to-base!) (catch Exception _))
|
||||
{:error (.getMessage e)
|
||||
:primary-fn (:primary-fn group)})))))]
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
(def cli-spec [["-h" "--help" "Show this help text"]
|
||||
["-H" "--hot" "Enable hot reloading"] ;
|
||||
["-p" "--port PORT" "Port to run the nREPL server on"
|
||||
:default 50605
|
||||
:default (if-let [p (env/env :nrepl-port)]
|
||||
(Integer/parseInt p)
|
||||
50605)
|
||||
:parse-fn #(Integer/parseInt %)]])
|
||||
|
||||
(defn -main
|
||||
|
||||
3776
docs/api.json
@@ -109,6 +109,7 @@ config:
|
||||
can-run-adhoc-query-check-threshold: 250
|
||||
check-for-updates: true
|
||||
config-from-file-sync-databases: true
|
||||
csv-field-separator: ','
|
||||
custom-formatting: {}
|
||||
custom-geojson: null
|
||||
custom-geojson-enabled: true
|
||||
@@ -292,6 +293,7 @@ config:
|
||||
sync-leaf-fields-limit: 1000
|
||||
synchronous-batch-updates: false
|
||||
thread-interrupt-escalation-timeout-ms: 0
|
||||
transform-timeout: 240
|
||||
transforms-enabled: false
|
||||
unaggregated-query-row-limit: null
|
||||
uploads-settings: null
|
||||
|
||||
@@ -316,6 +316,15 @@ Identify when new versions of Metabase are available.
|
||||
Whether to (asynchronously) sync newly created Databases during config-from-file initialization. By default, true,
|
||||
but you can disable this behavior if you want to sync it manually or use SerDes to populate its data model.
|
||||
|
||||
### `MB_CSV_FIELD_SEPARATOR`
|
||||
|
||||
- Type: string
|
||||
- Default: `,`
|
||||
- [Exported as](../installation-and-operation/serialization.md): `csv-field-separator`.
|
||||
- [Configuration file name](./config-file.md): `csv-field-separator`
|
||||
|
||||
Character to use as field separator in CSV exports. Defaults to comma (,). Common alternatives include semicolon (;) for European locales and tab (\t).
|
||||
|
||||
### `MB_CUSTOM_FORMATTING`
|
||||
|
||||
- Type: json
|
||||
@@ -388,9 +397,12 @@ Timeout in milliseconds for connecting to databases, both Metabase application d
|
||||
|
||||
By default, this is 20 minutes.
|
||||
|
||||
Timeout in minutes for databases query execution, both Metabase application database and data connections.
|
||||
If you have long-running queries, you might consider increasing this value.
|
||||
Adjusting the timeout does not impact Metabase’s frontend.
|
||||
Timeout in minutes for the database's query execution, both for the Metabase application database and any data connections.
|
||||
If you have long-running queries, you might consider increasing this value. Adjusting the timeout does not impact Metabase’s frontend.
|
||||
|
||||
This setting also applies to individual queries executed within transforms, so make sure the duration is long enough
|
||||
that it doesn't timeout any long-running queries in your transforms.
|
||||
|
||||
Please be aware that other services (like Nginx) may still drop long-running queries.
|
||||
|
||||
### `MB_DEFAULT_MAPS_ENABLED`
|
||||
@@ -945,7 +957,9 @@ String used to seed the private key used to validate JWT messages. A hexadecimal
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `jwt-user-provisioning-enabled`
|
||||
|
||||
When a user logs in via JWT, create a Metabase account for them automatically if they don't have one.
|
||||
Determines what happens when a user logs in via JWT and doesn't have a Metabase account.
|
||||
|
||||
When set to `true`, users who log in via JWT will automatically get a Metabase account if they don't have one, or get their existing account reactivated. When set to `false`, only users with active Metabase accounts can log in via JWT.
|
||||
|
||||
### `MB_LANDING_PAGE`
|
||||
|
||||
@@ -1128,8 +1142,9 @@ User lookup filter. The placeholder '{login}' will be replaced by the user suppl
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `ldap-user-provisioning-enabled`
|
||||
|
||||
When we enable LDAP user provisioning, we automatically create a Metabase account on LDAP signin for users who
|
||||
don't have one.
|
||||
Determines what happens when a user logs in via LDAP and doesn't have a Metabase account.
|
||||
|
||||
When set to `true`, users who log in via LDAP will automatically get a Metabase account if they don't have one, or get their existing account reactivated. When set to `false`, only users with active Metabase accounts can log in via LDAP.
|
||||
|
||||
### `MB_LICENSE_TOKEN_MISSING_BANNER_DISMISSAL_TIMESTAMP`
|
||||
|
||||
@@ -1332,7 +1347,9 @@ JSON containing OIDC provider configurations.
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `oidc-user-provisioning-enabled`
|
||||
|
||||
When a user logs in via OIDC, create a Metabase account for them automatically if they don't have one.
|
||||
Determines what happens when a user logs in via OIDC and doesn't have a Metabase account.
|
||||
|
||||
When set to `true`, users who log in via OIDC will automatically get a Metabase account if they don't have one, or get their existing account reactivated. When set to `false`, only users with active Metabase accounts can log in via OIDC.
|
||||
|
||||
### `MB_PERSISTED_MODEL_REFRESH_CRON_SCHEDULE`
|
||||
|
||||
@@ -1661,8 +1678,9 @@ Is SAML Single Log Out enabled?
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `saml-user-provisioning-enabled`
|
||||
|
||||
When we enable SAML user provisioning, we automatically create a Metabase account on SAML signin for users who
|
||||
don't have one.
|
||||
Determines what happens when a user logs in via SAML and doesn't have a Metabase account.
|
||||
|
||||
When set to `true`, users who log in via SAML will automatically get a Metabase account if they don't have one, or get their existing account reactivated. When set to `false`, only users with active Metabase accounts can log in via SAML.
|
||||
|
||||
### `MB_SCIM_ENABLED`
|
||||
|
||||
@@ -2035,6 +2053,18 @@ By default, this is 0 and the thread interrupt escalation does not run.
|
||||
Timeout in milliseconds to wait after query cancellation before escalating to thread interruption.
|
||||
This is used to free up threads that are stuck waiting for a DB response after a query has been cancelled.
|
||||
|
||||
### `MB_TRANSFORM_TIMEOUT`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: integer
|
||||
- Default: `240`
|
||||
|
||||
The timeout for a transform job, in minutes.
|
||||
|
||||
Each query executed by a transform is also subject to the MB_DB_QUERY_TIMEOUT_MINUTES timeout,
|
||||
so make sure that value isn't lower, or it will timeout your transform.
|
||||
|
||||
### `MB_TRANSFORMS_ENABLED`
|
||||
|
||||
- Type: boolean
|
||||
@@ -2439,13 +2469,26 @@ Default: `""`
|
||||
|
||||
When set, starts a Java Flight Recorder (JFR) recording at startup that can be analyzed with JDK Mission Control or other JFR tools.
|
||||
|
||||
- `"true"` generates a timestamped output file like `metabase-2026_01_15.jfr`
|
||||
- Any other non-empty value is used as the output filename (`.jfr` extension is appended if missing)
|
||||
- `"true"` generates a timestamped output file like `metabase-20260115_143000.jfr`
|
||||
- A value ending in `.jfr` is used as the output filename
|
||||
- Any other non-empty value is treated as a directory path. A new timestamped JFR file is written to that directory every 30 minutes (e.g., `metabase-20260115_143000.jfr`). Data is only written at the end of each 30-minute interval.
|
||||
- `""` or `"false"` disables monitoring (the default)
|
||||
|
||||
The performance recording stores only method signature calls and other code execution metrics.
|
||||
It does not store any sensitive information such as environment variables, system properties, or other machine information.
|
||||
|
||||
### `MB_MONITOR_PERFORMANCE_SAVE_RATE`
|
||||
|
||||
Type: integer<br>
|
||||
Default: mode-specific (5 minutes for single-file mode, 30 minutes for rolling mode)
|
||||
|
||||
Override the interval (in minutes) at which JFR recording data is saved to disk. Only applies when [MB_MONITOR_PERFORMANCE](#mb_monitor_performance) is enabled.
|
||||
|
||||
- In single-file mode, the default is `5` minutes.
|
||||
- In rolling directory mode, the default is `30` minutes.
|
||||
|
||||
Setting this value overrides the default for whichever mode is active.
|
||||
|
||||
### `MB_NO_SURVEYS`
|
||||
|
||||
Type: boolean<br>
|
||||
|
||||
@@ -6,7 +6,7 @@ redirect_from:
|
||||
|
||||
# Models
|
||||
|
||||
> Prefer [Transforms](../data-studio/transforms/transforms-overview.md) instead of models. Models remain supported for now, but will be deprecated in future versions of Metabase.
|
||||
> Consider using [Transforms](../data-studio/transforms/transforms-overview.md) instead of models.
|
||||
|
||||
Models curate data from another table or tables from the same database to anticipate the kinds of questions people will ask of the data. You can think of them as derived tables, or a special kind of saved question meant to be used as the starting point for new questions. You can base a model on a SQL or query builder question, which means you can include custom, calculated columns in your model.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Currently, Metabase can create transforms on the following databases:
|
||||
|
||||
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database.
|
||||
|
||||
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md).
|
||||
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) option for your database.
|
||||
|
||||
## Types of transforms
|
||||
|
||||
@@ -48,11 +48,10 @@ Metabase supports two types of transforms: query-based transforms and Python tra
|
||||
|
||||
If you are running Metabase Open Source/Starter, Admins (and only Admins) can see and run transforms.
|
||||
|
||||
Metabase Pro/Enterprise comes with additional permission controls for transforms, see [Transform permissions](../../permissions/data.md).
|
||||
Metabase Pro/Enterprise comes with additional permission controls for transforms:
|
||||
|
||||
To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md).
|
||||
|
||||
To **execute** transforms on a database, people additionally need to have the [Transform permissions](../../permissions/data.md) for that database.
|
||||
- To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md).
|
||||
- To **execute** transforms on a database, people additionally need to have the [Transform permissions](../../permissions/data.md) for that database.
|
||||
|
||||
## See all transforms
|
||||
|
||||
|
||||
@@ -209,9 +209,9 @@ If Metabase also needs to create tables, you'll need additional AWS Glue permiss
|
||||
}
|
||||
```
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -161,9 +161,9 @@ FROM [my_dataset.my_table]
|
||||
|
||||
If you're having trouble with your BigQuery connection, you can check out this [troubleshooting guide](../../troubleshooting-guide/bigquery-drive.md) that covers BigQuery issues, [this one](../../troubleshooting-guide/db-connection.md) on data warehouse connections, or visit [Metabase's discussion forum](https://discourse.metabase.com/search?q=bigquery) to see if someone has encountered and resolved a similar issue.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ You can limit the number of HTTP connections in the JDBC driver used to connect
|
||||
|
||||
### Additional JDBC connection string options
|
||||
|
||||
You can append options to the JDBC connection string. Separate options with `&`, like so:
|
||||
You can append options to the JDBC connection string. All [server settings must be prefixed with `clickhouse_setting_`](https://clickhouse.com/docs/integrations/language-clients/java/jdbc#connection-properties). Separate options with `&`, like so:
|
||||
|
||||
```
|
||||
connection_timeout=1000&socket_timeout=300000
|
||||
clickhouse_setting_connection_timeout=1000&clickhouse_setting_socket_timeout=300000
|
||||
```
|
||||
|
||||
### Re-run queries for simple explorations
|
||||
@@ -101,9 +101,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ JSON schema inference doesn't work with MariaDB, due to implementation differenc
|
||||
|
||||
If your password contains characters that aren't UTF-8, then you might need to add an additional variable to the connection string `passwordCharacterEncoding=<your_encoding_here>`. This ensures that MariaDB understands the special characters in the password during authentication.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Database routing
|
||||
|
||||
|
||||
@@ -130,9 +130,9 @@ Instead, Metabase gets a sample of the fields in a collection by scanning a samp
|
||||
|
||||
If you're not seeing all of the fields show up for a collection in Metabase, one workaround is to include all possible keys in the first document of the collection, and give those keys null values. That way, Metabase will be able to recognize the correct schema for the entire collection.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Database routing
|
||||
|
||||
|
||||
@@ -187,9 +187,9 @@ mysql:
|
||||
|
||||
If your password contains characters that aren't UTF-8, then you might need to add an additional variable to the connection string `passwordCharacterEncoding=<your_encoding_here>`. This ensures that MySQL understands the special characters in the password during authentication.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -131,9 +131,9 @@ For example, if you're running Metabase from a directory called `/app/`, you sho
|
||||
|
||||
The process for adding plugins when running via Docker is similar, but you'll need to mount the `plugins` directory. Refer to instructions [here](../../installation-and-operation/running-metabase-on-docker.md#adding-external-dependencies-or-plugins) for more details.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Danger zone
|
||||
|
||||
|
||||
@@ -186,9 +186,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -94,9 +94,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Database routing
|
||||
|
||||
|
||||
@@ -98,9 +98,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -77,9 +77,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Danger zone
|
||||
|
||||
|
||||
@@ -97,9 +97,9 @@ A fingerprinting query examines the first 10,000 rows from each column and uses
|
||||
|
||||
To connect to Azure SQL, you'll need to set the port to 1433.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Database routing
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Database routing
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
|
||||
|
||||
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@ For example, if you're running Metabase from a directory called `/app/`, you sho
|
||||
|
||||
The process for adding plugins when running via Docker is similar, but you'll need to mount the `plugins` directory. Refer to instructions [here](../../installation-and-operation/running-metabase-on-docker.md#adding-external-dependencies-or-plugins) for more details.
|
||||
|
||||
## Writeable connection
|
||||
## Writable connection
|
||||
|
||||
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
|
||||
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
|
||||
|
||||
## Model features
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Bundling your privileges into roles based on use cases makes it easier to manage
|
||||
- Use the same `analytics` role for other BI tools in your [data stack](https://www.metabase.com/learn/grow-your-data-skills/data-fundamentals/data-landscape) that need read-only access to the analytics tables in your database.
|
||||
- Revoke the write access for `metabase_model_persistence` without affecting the write access for `metabase_actions`.
|
||||
|
||||
For operations that require write access, we recommend using separate [writeable connections](./writeable-connection.md).
|
||||
For operations that require write access, we recommend using separate [writable connections](./writable-connection.md).
|
||||
|
||||
## Minimum database privileges
|
||||
|
||||
@@ -102,7 +102,7 @@ if you're using actions or editable tables, then in addition to the [minimum dat
|
||||
|
||||
- Create a new role called `metabase_writer`.
|
||||
- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to the relevant tables.
|
||||
- Give the `metabase_writer` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
|
||||
- Give the `metabase_writer` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
|
||||
|
||||
```sql
|
||||
-- Create a role to bundle database privileges for Metabase writing to your database.
|
||||
@@ -124,7 +124,7 @@ In addition to the [minimum database privileges](#minimum-database-privileges):
|
||||
- Create a new role called `metabase_model_persistence`.
|
||||
- Give the role `CREATE` access to the database.
|
||||
- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to the schema used for model persistence.
|
||||
- Give the `metabase_model_persistence` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
|
||||
- Give the `metabase_model_persistence` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
|
||||
|
||||
```sql
|
||||
-- Create a role to bundle database privileges for Metabase model persistence.
|
||||
@@ -144,20 +144,20 @@ GRANT metabase_model_persistence TO metabase;
|
||||
|
||||
## Privileges to enable transforms
|
||||
|
||||
[Transforms](../data-studio/transforms/transforms-overview.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables. We recommend using a [writeable connection](./writeable-connection.md) and only granting write access to the user used for the writeable connection.
|
||||
[Transforms](../data-studio/transforms/transforms-overview.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables. We recommend using a [writable connection](./writable-connection.md) and only granting write access to the user used for the writable connection.
|
||||
|
||||
In addition to the [minimum database privileges](#minimum-database-privileges):
|
||||
|
||||
- Create a new role called `metabase_transforms`.
|
||||
- Create a dedicated schema for transforms.
|
||||
- Give the `metabase_transforms` role `CREATE TABLE` / `ALTER` / `DROP` privileges for the specified schema.
|
||||
- Give the `metabase_transforms` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
|
||||
- Give the `metabase_transforms` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
|
||||
|
||||
In certain cases, Metabase will give you an option to create new schemas for transforms, in which case you'll also need `CREATE SCHEMA` privileges for your metabase users (or `CREATE DATABASE` for ClickHouse).
|
||||
|
||||
## Privileges to enable uploads
|
||||
|
||||
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads. We recommend using a [writeable connection](./writeable-connection.md) and only granting write access to the user used for the writeable connection.
|
||||
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads. We recommend using a [writable connection](./writable-connection.md) and only granting write access to the user used for the writable connection.
|
||||
|
||||
You'll first need to create a schema to store uploads (or use an existing schema) and tell Metabase that you want to [use that schema to store uploads](./uploads.md#select-the-database-and-schema-that-you-want-to-store-the-data-in).
|
||||
|
||||
@@ -228,7 +228,7 @@ We recommend bundling privileges into roles based on use cases per customer. Tha
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Writeable connections](./writeable-connection.md)
|
||||
- [Writable connections](./writable-connection.md)
|
||||
- [Permissions strategies](https://www.metabase.com/learn/metabase-basics/administration/permissions/strategy)
|
||||
- [Permissions introduction](../permissions/introduction.md)
|
||||
- [People overview](../people-and-groups/start.md)
|
||||
|
||||
62
docs/databases/writable-connection.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Writable connection
|
||||
summary: Add an optional separate database connection for write operations like transforms or editable tables to keep the main Metabase connection read-only for analytics.
|
||||
redirect_from:
|
||||
- /docs/latest/databases/writeable-connection
|
||||
---
|
||||
|
||||
## Writable connection
|
||||
|
||||
> Writable connection requires the **Advanced transforms (SQL + Python)** add-on
|
||||
|
||||
_Admin > Databases > Writable connection_
|
||||
|
||||
Writable connection is an optional connection that Metabase will use for functionality that involves writing into the database (for example, transforms or CSV uploads). This allows you to keep your main Metabase connection read-only, and only to switch to connection with write permissions for the operations that require write access.
|
||||
|
||||

|
||||
|
||||
## How writable connection works
|
||||
|
||||
Writable connection is an additional connection option added to an existing database connection.
|
||||
|
||||
When a writable connection is configured for a database, Metabase automatically switches between connections behind the scenes based on what people are doing with the database. The main connection is used for querying, and the writable connection is used for [features that require writes](#features-that-use-writable-connection).
|
||||
|
||||
Writable connection can use a different database user than the main connection, or even connect to a different database. For example, your main connection (used for querying) can go to a read replica, but the writable connection can be to a primary database.
|
||||
|
||||
## Add a writable connection
|
||||
|
||||
To add a writable connection to a database:
|
||||
|
||||
1. Go to **Admin > Databases**.
|
||||
2. Select the database to add a writable connection to.
|
||||
3. Under **Writable connection**, click **Add writable connection**.
|
||||
4. Metabase will pre-fill some fields with information from your main connection. Input the credentials for the writable connection.
|
||||
5. Click **Save**. Metabase will try to connect using the new connection.
|
||||
|
||||
## Remove a writable connection
|
||||
|
||||
To remove a writable connection from a database:
|
||||
|
||||
1. Go to **Admin > Databases**.
|
||||
2. Select the database you want to remove the writable connection from.
|
||||
3. Under **Writable connection**, click **Remove writable connection**.
|
||||
|
||||
Once writable connection is removed, Metabase will revert to using the main connection for [features that need write access](#features-that-use-writable-connection), so your main connection will need to have appropriate database permissions, see [Users, roles, and privileges](./users-roles-privileges.md)
|
||||
|
||||
## Features that use writable connection
|
||||
|
||||
When configured, writable connection will be used for:
|
||||
|
||||
- [Transforms](../data-studio/transforms/transforms-overview.md)
|
||||
- [CSV uploads](../databases/uploads.md)
|
||||
- [Editable tables](../data-modeling/editable-tables.md)
|
||||
- [Actions](../actions/introduction.md)
|
||||
- [Model persistence](../data-modeling/model-persistence.md)
|
||||
|
||||
Your writable connection will need to have the privileges required for these operations. See [Users, roles, and privileges](./users-roles-privileges.md).
|
||||
|
||||
## Limitations
|
||||
|
||||
- Writable connection can't be used with [database routing](../permissions/database-routing.md).
|
||||
|
||||
- Writable connection must use the same database engine as the primary connection - so for example, if the primary connection is to a PostgreSQL database, the writable connection must be to a PostgreSQL database as well.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
title: Writeable connections
|
||||
summary: Add an optional separate database connection for write operations like transforms or editable tables to keep the main Metabase connection read-only for analytics.
|
||||
---
|
||||
|
||||
## Writeable connections
|
||||
|
||||
> Writeable connection requires the **Advanced transforms (SQL + Python)** add-on
|
||||
|
||||
_Admin > Databases > Writeable connection_
|
||||
|
||||
Writeable connection is an optional connection that Metabase will use for functionality that involves writing into the database (for example, transforms or CSV uploads). This allows you to keep your main Metabase connection read-only, and only to switch to connection with write permissions for the operations that require write access.
|
||||
|
||||

|
||||
|
||||
## How writeable connection works
|
||||
|
||||
Writeable connection is an additional connection option added to an existing database connection.
|
||||
|
||||
When a writeable connection is configured for a database, Metabase automatically switches between connections behind the scenes based on what people are doing with the database. The main connection is used for querying, and the writeable connection is used for [features that require writes](#features-that-use-writeable-connection).
|
||||
|
||||
Writeable connection can use a different database user than the main connection, or even connect to a different database. For example, your main connection (used for querying) can go to a read replica, but the writeable connection can be to a primary database.
|
||||
|
||||
## Add a writeable connection
|
||||
|
||||
To add a writeable connection to a database:
|
||||
|
||||
1. Go to **Admin > Databases**.
|
||||
2. Select the database to add a writeable connection to.
|
||||
3. Under **Writeable connection**, click **Add writeable connection**.
|
||||
4. Metabase will pre-fill some fields with information from your main connection. Input the credentials for the writeable connection.
|
||||
5. Click **Save**. Metabase will try to connect using the new connection.
|
||||
|
||||
## Remove a writeable connection
|
||||
|
||||
To remove a writeable connection from a database:
|
||||
|
||||
1. Go to **Admin > Databases**.
|
||||
2. Select the database you want to remove the writeable connection from.
|
||||
3. Under **Writeable connection**, click **Remove writeable connection**.
|
||||
|
||||
Once writeable connection is removed, Metabase will revert to using the main connection for [features that need write access](#features-that-use-writeable-connection), so your main connection will need to have appropriate database permissions, see [Users, roles, and privileges](./users-roles-privileges.md)
|
||||
|
||||
## Features that use writeable connection
|
||||
|
||||
When configured, writeable connection will be used for:
|
||||
|
||||
- [Transforms](../data-studio/transforms/transforms-overview.md)
|
||||
- [CSV uploads](../databases/uploads.md)
|
||||
- [Editable tables](../data-modeling/editable-tables.md)
|
||||
- [Actions](../actions/introduction.md)
|
||||
- [Model persistence](../data-modeling/model-persistence.md)
|
||||
|
||||
Your writeable connection will need to have the privileges required for these operations. See [Users, roles, and privileges](./users-roles-privileges.md).
|
||||
|
||||
## Limitations
|
||||
|
||||
- Writeable connection can't be used with [database routing](../permissions/database-routing.md).
|
||||
|
||||
- Writeable connection must use the same database engine as the primary connection - so for example, if the primary connection is to a PostgreSQL database, the writeable connection must be to a PostgreSQL database as well.
|
||||
@@ -40,7 +40,7 @@ Different database have different setups, so _what_ you can route between (datab
|
||||
|
||||
Database routing **can't be used** on databases with:
|
||||
|
||||
- [Writeable connections](../databases/writeable-connection.md)
|
||||
- [Writable connections](../databases/writable-connection.md)
|
||||
- [Editable tables](../data-modeling/editable-tables.md)
|
||||
- [Actions](../actions/introduction.md)
|
||||
- [CSV uploads](../databases/uploads.md)
|
||||
|
||||
@@ -6,36 +6,29 @@ title: Creating a HAR file for troubleshooting
|
||||
|
||||
HAR (short for _HTTP Archive_) files record network requests generated by your browser. HAR files can be helpful when troubleshooting issues with Metabase performance.
|
||||
|
||||
> HAR files can contain sensitive information that you submit during the recording, like passwords and API keys. Before sharing the HAR file with anyone, review the file contents in a text editor.
|
||||
> The instructions below include steps for creating **HAR files that contain sensitive information** that you submit during the recording. Sensitive information includes session cookies and authentication information. Only record this information when specifically instructed, and when it is needed for diagnosing issues with sessions or authentication, etc. Before sharing such a HAR file with anyone, review the file contents in a text editor.
|
||||
|
||||
To create the HAR file, follow the instructions for your web browser:
|
||||
|
||||
- [Chrome](#create-a-har-file-in-chrome)
|
||||
- [Edge](#create-a-har-file-in-edge)
|
||||
- [Chrome or Edge](#create-a-har-file-in-google-chrome-and-microsoft-edge)
|
||||
- [Firefox](#create-a-har-file-in-firefox)
|
||||
- [Safari](#create-a-har-file-in-safari)
|
||||
|
||||
## Create a HAR file in Chrome
|
||||
## Create a HAR file in Google Chrome and Microsoft Edge
|
||||
|
||||
1. Open Chrome **Developer tools**.
|
||||
1. Open Chrome/Edge **Developer tools**.
|
||||
You can right-click anywhere on the page and select "Inspect".
|
||||
|
||||
2. In Developer tools, switch to the **Network** tab.
|
||||
The network log recording should start automatically.
|
||||
|
||||
3. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
|
||||
3. Click on the small **gear icon** in the top bar of the developer tools to open Settings. Scroll down to the **Network** section and enable **Allow to generate HAR files with sensitive data**
|
||||
|
||||
4. Once you're finished reproducing the issue, right-click anywhere in the table of network calls and select **Save all as HAR with content**.
|
||||

|
||||
|
||||
## Create a HAR file in Edge
|
||||
4. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
|
||||
|
||||
1. Open Edge **Developer tools**. You can right-click anywhere on the page and select "Inspect".
|
||||
|
||||
2. In Developer tools, switch to the **Network** tab (look for the Wi-Fi icon). The network log recording should start automatically.
|
||||
|
||||
3. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
|
||||
|
||||
4. Once you're finished reproducing the issue, right-click anywhere in the table and select **Save all as HAR with content**
|
||||
5. Once you're finished reproducing the issue, click the download icon on the far right of the toolbar just below the tab bar. Choose **Export HAR (with sensitive data)…** and save the file.
|
||||
|
||||
## Create a HAR file in Firefox
|
||||
|
||||
@@ -51,7 +44,7 @@ To create the HAR file, follow the instructions for your web browser:
|
||||
|
||||
1. If you haven't yet, enable the **Develop** menu by going to **Safari > Settings > Advanced**, and select **Show features for web developers**.
|
||||
|
||||
2. Open the Safari developer tools by going to **Develop > Show Web Inspector**.
|
||||
2. Open the Safari developer tools by going to **Develop > Show Web Inspector** or by right-clicking anywhere on the page and selecting **Inspect Element** from the context menu.
|
||||
|
||||
3. In Developer tools, switch to the **Network** tab. The network log recording should start automatically.
|
||||
|
||||
|
||||
BIN
docs/troubleshooting-guide/images/chrome-har-settings.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
27
e2e/runner/start-backend.js
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const backend = require("./cypress-runner-backend");
|
||||
|
||||
async function main() {
|
||||
const jarPath = process.env.JAR_PATH;
|
||||
|
||||
if (jarPath) {
|
||||
await backend.runFromJar(jarPath);
|
||||
} else {
|
||||
await backend.runFromSource();
|
||||
}
|
||||
|
||||
console.log(`Backend ready on :${process.env.MB_JETTY_PORT}`);
|
||||
|
||||
const cleanup = () => {
|
||||
backend.stop();
|
||||
process.exit();
|
||||
};
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("SIGINT", cleanup);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -181,7 +181,7 @@ const defaultConfig = {
|
||||
viewportHeight: 800,
|
||||
viewportWidth: 1280,
|
||||
// enable video recording in run mode
|
||||
video: true,
|
||||
video: process.env["CYPRESS_VIDEO"] !== "false",
|
||||
videoCompression: true,
|
||||
};
|
||||
|
||||
@@ -213,7 +213,10 @@ const mainConfig = {
|
||||
},
|
||||
},
|
||||
retries: {
|
||||
runMode: 2,
|
||||
runMode:
|
||||
process.env["CYPRESS_RETRIES"] != null
|
||||
? parseInt(process.env["CYPRESS_RETRIES"], 10)
|
||||
: 2,
|
||||
openMode: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ const embeddingSdkComponentTestConfig = {
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
video: false,
|
||||
experimentalMemoryManagement: true,
|
||||
numTestsKeptInMemory: 1,
|
||||
specPattern: "e2e/test-component/scenarios/embedding-sdk/**/*.cy.spec.tsx",
|
||||
indexHtmlFile: "e2e/support/component-index.html",
|
||||
supportFile: "e2e/support/component-cypress.js",
|
||||
|
||||
@@ -39,12 +39,13 @@ export function createTransform(
|
||||
tag_ids,
|
||||
collection_id,
|
||||
})
|
||||
.then(({ body }) => {
|
||||
.then((response) => {
|
||||
if (wrapId) {
|
||||
cy.wrap(body.id).as(idAlias);
|
||||
cy.wrap(response.body.id).as(idAlias);
|
||||
}
|
||||
if (visitTransform) {
|
||||
cy.visit(`/data-studio/transforms/${body.id}`);
|
||||
cy.visit(`/data-studio/transforms/${response.body.id}`);
|
||||
}
|
||||
return cy.wrap(response);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export const DataStudio = {
|
||||
dependenciesTab: () =>
|
||||
DataStudio.Metrics.header().findByText("Dependencies"),
|
||||
cachingTab: () => DataStudio.Metrics.header().findByText("Caching"),
|
||||
overviewPageDescriptionSidebar: () =>
|
||||
metricOverviewPage().findByTestId("metric-description-sidebar"),
|
||||
},
|
||||
Tables: {
|
||||
overviewPage: () => cy.findByTestId("table-overview-page"),
|
||||
@@ -150,6 +152,8 @@ export const DataStudio = {
|
||||
.click(),
|
||||
|
||||
Overview: {
|
||||
descriptionSidebar: () => cy.findByTestId("table-description-sidebar"),
|
||||
|
||||
descriptionText: () =>
|
||||
cy
|
||||
.findByTestId("table-description-section")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DependencyNode,
|
||||
ListBreakingGraphNodesResponse,
|
||||
ListUnreferencedGraphNodesResponse,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export const DependencyGraph = {
|
||||
@@ -15,7 +16,11 @@ export const DependencyDiagnostics = {
|
||||
visitBrokenDependencies: () =>
|
||||
cy.visit("/data-studio/dependency-diagnostics/broken"),
|
||||
visitUnreferencedEntities: () => {
|
||||
cy.intercept("GET", "/api/ee/dependencies/graph/unreferenced*").as(
|
||||
"unreferencedEntities",
|
||||
);
|
||||
cy.visit("/data-studio/dependency-diagnostics/unreferenced");
|
||||
cy.wait("@unreferencedEntities");
|
||||
DependencyDiagnostics.list().should("be.visible");
|
||||
},
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
@@ -40,6 +45,27 @@ export const DependencyDiagnostics = {
|
||||
const WAIT_TIMEOUT = 10000;
|
||||
const WAIT_INTERVAL = 100;
|
||||
|
||||
export function waitForUnreferencedEntities(
|
||||
filter: (nodes: DependencyNode[]) => boolean,
|
||||
timeout = WAIT_TIMEOUT,
|
||||
): Cypress.Chainable {
|
||||
return cy
|
||||
.request<ListUnreferencedGraphNodesResponse>(
|
||||
"GET",
|
||||
"/api/ee/dependencies/graph/unreferenced?include-personal-collections=true",
|
||||
)
|
||||
.then((response) => {
|
||||
if (filter(response.body.data)) {
|
||||
return cy.wrap(response);
|
||||
} else if (timeout > 0) {
|
||||
cy.wait(WAIT_INTERVAL);
|
||||
return waitForUnreferencedEntities(filter, timeout - WAIT_INTERVAL);
|
||||
} else {
|
||||
throw new Error("Unreferenced entities analysis retry timeout");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function waitForBreakingDependencies(
|
||||
filter: (nodes: DependencyNode[]) => boolean,
|
||||
timeout = WAIT_TIMEOUT,
|
||||
|
||||
@@ -77,6 +77,7 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
|
||||
mountSdkContent(
|
||||
<StaticDashboard dashboardId={dashboardId} withSubscriptions />,
|
||||
{ strictMode: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,6 +86,9 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
cy.icon("subscription").click();
|
||||
cy.findByText("Email this dashboard").should("be.visible");
|
||||
|
||||
// EMB-1413
|
||||
cy.findByDisplayValue("Hourly").click();
|
||||
|
||||
cy.log("can customize filter values");
|
||||
cy.findByRole("heading", {
|
||||
name: "Set filter values for when this gets sent",
|
||||
@@ -107,6 +111,7 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
|
||||
mountSdkContent(
|
||||
<InteractiveDashboard dashboardId={dashboardId} withSubscriptions />,
|
||||
{ strictMode: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,6 +120,9 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
cy.icon("subscription").click();
|
||||
cy.findByText("Email this dashboard").should("be.visible");
|
||||
|
||||
// EMB-1413
|
||||
cy.findByDisplayValue("Hourly").click();
|
||||
|
||||
cy.log("can customize filter values");
|
||||
cy.findByRole("heading", {
|
||||
name: "Set filter values for when this gets sent",
|
||||
@@ -137,6 +145,7 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
|
||||
mountSdkContent(
|
||||
<EditableDashboard dashboardId={dashboardId} withSubscriptions />,
|
||||
{ strictMode: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -145,6 +154,9 @@ describe("scenarios > embedding-sdk > subscriptions", () => {
|
||||
cy.icon("subscription").click();
|
||||
cy.findByText("Email this dashboard").should("be.visible");
|
||||
|
||||
// EMB-1413
|
||||
cy.findByDisplayValue("Hourly").click();
|
||||
|
||||
cy.log("can customize filter values");
|
||||
cy.findByRole("heading", {
|
||||
name: "Set filter values for when this gets sent",
|
||||
|
||||
@@ -807,8 +807,7 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
cy.findByLabelText(/Choose when syncs and scans happen/).click({
|
||||
force: true,
|
||||
});
|
||||
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Hourly").click();
|
||||
cy.findByDisplayValue("Hourly").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Daily").click({ force: true });
|
||||
});
|
||||
@@ -819,10 +818,7 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
.click();
|
||||
|
||||
H.popover().findByText("Regularly, on a schedule").click();
|
||||
cy.findAllByRole("button", { name: /Daily/ })
|
||||
.should("have.length", 2)
|
||||
.eq(1)
|
||||
.click();
|
||||
cy.findAllByDisplayValue("Daily").should("have.length", 2).eq(1).click();
|
||||
H.popover().findByText("Weekly").click();
|
||||
|
||||
cy.button("Save changes").click();
|
||||
|
||||
@@ -805,6 +805,82 @@ describe("scenarios > content translation > static embeds > dashboards", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("translates selected static-list filter label in guest embed", () => {
|
||||
const staticListFilter = {
|
||||
name: "Number",
|
||||
slug: "number",
|
||||
id: "static-list-id",
|
||||
type: "number/=",
|
||||
sectionId: "number",
|
||||
values_source_type: "static-list" as const,
|
||||
values_source_config: {
|
||||
values: [
|
||||
["1", "Gadget"],
|
||||
["2", "Widget"],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
cy.signInAsAdmin();
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: {
|
||||
name: "Expression Question",
|
||||
query: {
|
||||
"source-table": PEOPLE_ID,
|
||||
},
|
||||
},
|
||||
dashboardDetails: {
|
||||
parameters: [staticListFilter as any], // current API isn't set to accept string[][] as values
|
||||
enable_embedding: true,
|
||||
embedding_params: {
|
||||
[staticListFilter.slug]: "enabled",
|
||||
},
|
||||
},
|
||||
}).then(({ body: { id, card_id, dashboard_id } }) => {
|
||||
// Map the parameter to an expression column so the filter widget
|
||||
// is visible, but hasFields() returns false (testing the tc()
|
||||
// fix in FormattedParameterValue)
|
||||
cy.request("PUT", `/api/dashboard/${dashboard_id}`, {
|
||||
dashcards: [
|
||||
{
|
||||
id,
|
||||
card_id,
|
||||
row: 0,
|
||||
col: 0,
|
||||
size_x: 24,
|
||||
size_y: 9,
|
||||
parameter_mappings: [
|
||||
{
|
||||
parameter_id: staticListFilter.id,
|
||||
card_id,
|
||||
target: [
|
||||
"dimension",
|
||||
["expression", "Thing", { "base-type": "type/Integer" }],
|
||||
{ "stage-number": 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
H.visitEmbeddedPage(
|
||||
{
|
||||
resource: { dashboard: dashboard_id as number },
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
setFilters: { [staticListFilter.slug]: "1" },
|
||||
additionalHashOptions: {
|
||||
locale: "de",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
H.filterWidget().findByText("Gerät").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -242,6 +242,70 @@ describe(
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep the resize handle connected to the cursor while resizing (metabase#70451)", () => {
|
||||
H.createDashboardWithQuestions({
|
||||
dashboardDetails: {},
|
||||
questions: [
|
||||
{
|
||||
display: "table",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).then(({ dashboard }) => {
|
||||
H.visitDashboard(dashboard.id);
|
||||
});
|
||||
|
||||
H.editDashboard();
|
||||
|
||||
let targetX, targetY;
|
||||
|
||||
H.getDashboardCard(0)
|
||||
.find(".react-resizable-handle")
|
||||
.then(($handle) => {
|
||||
const rect = $handle[0].getBoundingClientRect();
|
||||
const startX = Math.round(rect.left + rect.width / 2);
|
||||
const startY = Math.round(rect.top + rect.height / 2);
|
||||
targetX = startX + 200;
|
||||
targetY = startY + 150;
|
||||
|
||||
// Not using the wrapper here because we want to avoid waits and we need the starting position for later
|
||||
cy.wrap($handle)
|
||||
.trigger("mousedown", {
|
||||
button: 0,
|
||||
clientX: startX,
|
||||
clientY: startY,
|
||||
force: true,
|
||||
})
|
||||
.trigger("mousemove", {
|
||||
clientX: targetX,
|
||||
clientY: targetY,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
// After dragging, the resize handle should still be near the cursor position.
|
||||
H.getDashboardCard(0)
|
||||
.find(".react-resizable-handle")
|
||||
.then(($handle) => {
|
||||
const rect = $handle[0].getBoundingClientRect();
|
||||
const handleCenterX = Math.round(rect.left + rect.width / 2);
|
||||
const handleCenterY = Math.round(rect.top + rect.height / 2);
|
||||
|
||||
const maxDrift = 50;
|
||||
expect(
|
||||
Math.abs(handleCenterX - targetX),
|
||||
`Horizontal drift: handle at ${handleCenterX}, cursor at ${targetX}`,
|
||||
).to.be.lessThan(maxDrift);
|
||||
expect(
|
||||
Math.abs(handleCenterY - targetY),
|
||||
`Vertical drift: handle at ${handleCenterY}, cursor at ${targetY}`,
|
||||
).to.be.lessThan(maxDrift);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow cards to be resized smaller than min height", () => {
|
||||
const cardIds = [];
|
||||
TEST_QUESTIONS.forEach((question) => {
|
||||
|
||||
@@ -58,12 +58,12 @@ describe("scenarios > dashboard cards > sections", () => {
|
||||
selectQuestion("Orders, Count, Grouped by Created At (year)");
|
||||
|
||||
overwriteDashCardTitle(
|
||||
1,
|
||||
2,
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Line chart",
|
||||
);
|
||||
// TODO: if the mapping is done before the title is changed, the mapping is lost
|
||||
mapDashCardToFilter(H.getDashboardCard(1), "Category");
|
||||
mapDashCardToFilter(H.getDashboardCard(2), "Category");
|
||||
|
||||
H.goToTab("Tab 1");
|
||||
H.saveDashboard();
|
||||
@@ -101,7 +101,7 @@ describe("scenarios > dashboard cards > sections", () => {
|
||||
// Ensure parameter mapping is persisted
|
||||
H.editDashboard();
|
||||
filterPanel().findByText("Category").click();
|
||||
H.getDashboardCard(1).findByText("Product.Category").should("exist");
|
||||
H.getDashboardCard(2).findByText("Product.Category").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ describe("scenarios > dashboard > filters > management", () => {
|
||||
|
||||
changeFilterType("ID");
|
||||
|
||||
cy.log("verify ID doesn't render operator select");
|
||||
H.sidebar().findByText("Filter operator").should("not.exist");
|
||||
cy.log("verify ID default value: Numeric ID");
|
||||
verifyOperatorValue("Numeric ID");
|
||||
|
||||
changeFilterType("Date picker");
|
||||
|
||||
|
||||
@@ -70,11 +70,18 @@ describe("scenarios > data studio > library > metrics", () => {
|
||||
cy.findByText("Sum of all order totals across the store").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
H.DataStudio.Metrics.overviewPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Last edited by Bobby Tables").should("be.visible");
|
||||
|
||||
cy.findByText("Sample Database").should("be.visible");
|
||||
cy.findByText("PUBLIC").should("be.visible");
|
||||
|
||||
cy.findByText("Orders").should("be.visible");
|
||||
cy.findByText("Creator and last editor").should("be.visible");
|
||||
cy.findAllByText(/by Bobby Tables/).should("be.visible");
|
||||
cy.findByText("Source table").should("be.visible");
|
||||
|
||||
cy.findByText("Dependencies").should("be.visible");
|
||||
cy.findByText("Dependents").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("Ensure chart is visible");
|
||||
|
||||
@@ -83,6 +83,33 @@ describe("scenarios > data studio > library > tables", () => {
|
||||
.contains("Table description updated")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to view additional properties in sidebar", () => {
|
||||
H.DataStudio.Tables.Overview.descriptionSidebar().within(() => {
|
||||
cy.findByText("Entity type").should("be.visible");
|
||||
|
||||
cy.findByText("Last edited at").should("be.visible");
|
||||
|
||||
cy.findByText("Database").should("be.visible");
|
||||
cy.findByText("Sample Database").should("be.visible");
|
||||
|
||||
cy.findByText("Source").should("be.visible");
|
||||
cy.findByPlaceholderText("Select a data source").should(
|
||||
"have.value",
|
||||
"Ingested",
|
||||
);
|
||||
|
||||
cy.findByText("Owner").should("be.visible");
|
||||
|
||||
cy.findByPlaceholderText("Pick someone, or type an email").should(
|
||||
"have.value",
|
||||
"No owner",
|
||||
);
|
||||
cy.findByText("Fields").should("be.visible");
|
||||
|
||||
cy.findByText("Dependents").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fields", () => {
|
||||
|
||||
@@ -98,25 +98,22 @@ describe("scenarios > admin > transforms", () => {
|
||||
query: "SELECT 1",
|
||||
})
|
||||
.then((query) =>
|
||||
H.createTransform(
|
||||
{
|
||||
name: "MBQL",
|
||||
source: {
|
||||
type: "query",
|
||||
query,
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: TARGET_TABLE,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
H.createTransform({
|
||||
name: "MBQL",
|
||||
source: {
|
||||
type: "query",
|
||||
query,
|
||||
},
|
||||
{ wrapId: true },
|
||||
),
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: TARGET_TABLE,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.then((transformId) => {
|
||||
cy.visit(`/data-studio/transforms/${transformId}`);
|
||||
.then(({ body: transform }) => {
|
||||
cy.visit(`/data-studio/transforms/${transform.id}`);
|
||||
});
|
||||
|
||||
function testSimpleTemplateTag(
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
CardType,
|
||||
CollectionId,
|
||||
PythonTransformTableAliases,
|
||||
TransformId,
|
||||
TransformSourceCheckpointStrategy,
|
||||
TransformTagId,
|
||||
} from "metabase-types/api";
|
||||
@@ -1509,108 +1508,6 @@ LIMIT
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
});
|
||||
|
||||
it("should be possible to continue editing a transform after closing check dependencies modal (metabase#68272)", () => {
|
||||
const transformTableName = "output_table";
|
||||
const dependentCardName = "Question depending on transform";
|
||||
|
||||
cy.log("create MBQL transform with name and score columns");
|
||||
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
|
||||
(sourceTableId) => {
|
||||
H.getFieldId({ tableId: sourceTableId, name: "name" }).then(
|
||||
(nameFieldId) => {
|
||||
H.getFieldId({ tableId: sourceTableId, name: "score" }).then(
|
||||
(scoreFieldId) => {
|
||||
H.createTransform(
|
||||
{
|
||||
name: "MBQL transform with deps",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
database: WRITABLE_DB_ID,
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": sourceTableId,
|
||||
fields: [
|
||||
["field", nameFieldId, null],
|
||||
["field", scoreFieldId, null],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
database: WRITABLE_DB_ID,
|
||||
name: transformTableName,
|
||||
schema: TARGET_SCHEMA,
|
||||
},
|
||||
},
|
||||
{ wrapId: true },
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
cy.log("run the transform to create the output table");
|
||||
H.runTransformAndWaitForSuccess(transformId);
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: transformTableName,
|
||||
});
|
||||
|
||||
cy.log("create a question that depends on the score column");
|
||||
H.getTableId({
|
||||
databaseId: WRITABLE_DB_ID,
|
||||
name: transformTableName,
|
||||
}).then((tableId) => {
|
||||
H.createQuestion({
|
||||
name: dependentCardName,
|
||||
database: WRITABLE_DB_ID,
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: [
|
||||
">",
|
||||
["field", "score", { "base-type": "type/Integer" }],
|
||||
10,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
H.visitTransform(transformId);
|
||||
|
||||
cy.log("remove score column (breaking change)");
|
||||
H.DataStudio.Transforms.clickEditDefinition();
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Score").click();
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
|
||||
cy.wait("@checkTransformDependencies");
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByText(
|
||||
"These changes will break some other things. Save anyway?",
|
||||
).should("be.visible");
|
||||
cy.findByText(dependentCardName).should("be.visible");
|
||||
|
||||
cy.log("cancel to continue editing");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
|
||||
H.DataStudio.Transforms.editDefinitionButton().should("not.exist");
|
||||
H.DataStudio.Transforms.saveChangesButton()
|
||||
.should("be.visible")
|
||||
.and("be.enabled");
|
||||
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Name").should("be.disabled");
|
||||
H.popover().findByLabelText("Score").should("not.be.checked");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to update a Python query", { tags: ["@python"] }, () => {
|
||||
H.setPythonRunnerSettings();
|
||||
cy.log("create a new transform");
|
||||
|
||||
@@ -25,72 +25,6 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
});
|
||||
|
||||
describe("questions", () => {
|
||||
it("should be able to confirm or cancel breaking changes to a MBQL question", () => {
|
||||
createMbqlQuestionWithDependentMbqlQuestions();
|
||||
|
||||
cy.log("make breaking changes");
|
||||
H.visitQuestion("@questionId");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
|
||||
|
||||
cy.log("cancel breaking changes");
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Question with fields").should("be.visible");
|
||||
cy.findByText("Question without fields").should("not.exist");
|
||||
cy.findByText("Base question").should("not.exist");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
cy.get("@updateCard.all").should("have.length", 0);
|
||||
|
||||
cy.log("confirm breaking changes");
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().button("Save").click();
|
||||
H.modal().button("Save anyway").click();
|
||||
cy.wait("@updateCard");
|
||||
});
|
||||
|
||||
it("should be able to navigate to affected questions or their collection", () => {
|
||||
createMbqlQuestionWithDependentMbqlQuestions();
|
||||
|
||||
cy.log("check that we can navigate to the broken question");
|
||||
H.visitQuestion("@questionId");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().button("Save").click();
|
||||
H.modal().findByText("Question with fields").click();
|
||||
confirmDiscardChanges();
|
||||
H.queryBuilderHeader()
|
||||
.findByDisplayValue("Question with fields")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("check that we can navigate to the collection of that question");
|
||||
cy.go("back");
|
||||
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Question with fields").should("be.visible");
|
||||
cy.findByText("Our analytics").click();
|
||||
});
|
||||
confirmDiscardChanges();
|
||||
H.collectionTable().should("be.visible");
|
||||
|
||||
cy.log("check that we can navigate to the dashboard of that question");
|
||||
cy.go("back");
|
||||
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
|
||||
H.queryBuilderHeader().button("Save").click();
|
||||
H.modal().button("Save").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Question with fields").should("be.visible");
|
||||
cy.findByText("Orders in a dashboard").click();
|
||||
});
|
||||
confirmDiscardChanges();
|
||||
H.dashboardHeader().should("be.visible");
|
||||
});
|
||||
|
||||
it("should not show a confirmation if there are no breaking changes when updating a MBQL question", () => {
|
||||
createMbqlQuestionWithDependentMbqlQuestions();
|
||||
H.visitQuestion("@questionId");
|
||||
@@ -105,37 +39,6 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
});
|
||||
|
||||
describe("metrics", () => {
|
||||
it("should be able to confirm or cancel breaking changes to a metric", () => {
|
||||
createMetricWithDependentMbqlQuestionsAndTransforms();
|
||||
|
||||
cy.log("make breaking changes");
|
||||
cy.get<number>("@metricId").then(H.visitMetric);
|
||||
H.openQuestionActions("Edit metric definition");
|
||||
H.getNotebookStep("summarize").findByText("Min of Score").click();
|
||||
H.popover().within(() => {
|
||||
cy.icon("chevronleft").click();
|
||||
cy.findByText("Maximum of ...").click();
|
||||
cy.findByText("Score").click();
|
||||
});
|
||||
|
||||
cy.log("cancel breaking changes");
|
||||
cy.findByTestId("edit-bar").button("Save changes").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Question with 2 stages").should("be.visible");
|
||||
cy.findByText("Transform with 2 stages").should("be.visible");
|
||||
cy.findByText("Question with 1 stage").should("not.exist");
|
||||
cy.findByText("Transform with 1 stage").should("not.exist");
|
||||
cy.findByText("Base metric").should("not.exist");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
cy.get("@updateCard.all").should("have.length", 0);
|
||||
|
||||
cy.log("confirm breaking changes");
|
||||
cy.findByTestId("edit-bar").button("Save changes").click();
|
||||
H.modal().button("Save anyway").click();
|
||||
cy.wait("@updateCard");
|
||||
});
|
||||
|
||||
it("should not show a warning when a change to a metric is backward-compatible with existing content", () => {
|
||||
createMetricWithDependentMbqlQuestionsAndTransforms();
|
||||
cy.get<number>("@metricId").then(H.visitMetric);
|
||||
@@ -154,21 +57,6 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
H.NativeEditor.clear().type(queryString);
|
||||
};
|
||||
|
||||
it("should ignore breaking changes to a SQL transform after it was run", () => {
|
||||
createSqlTransformWithDependentMbqlQuestions();
|
||||
|
||||
cy.log("make breaking changes");
|
||||
cy.get<number>("@transformId").then(H.visitTransform);
|
||||
goToEditorAndType('SELECT name FROM "Schema A"."Animals"');
|
||||
|
||||
cy.log("confirmation is shown");
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
cy.contains(
|
||||
"h2",
|
||||
"These changes will break some other things. Save anyway?",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show a confirmation if there are no breaking changes when updating a SQL transform after it was run", () => {
|
||||
createSqlTransformWithDependentMbqlQuestions();
|
||||
cy.get<number>("@transformId").then(H.visitTransform);
|
||||
@@ -177,35 +65,6 @@ describe("scenarios > dependencies > dependency checks", () => {
|
||||
cy.wait("@updateTransform");
|
||||
});
|
||||
|
||||
it("should be able to confirm or cancel breaking changes to a MBQL transform before it was run", () => {
|
||||
createMbqlTransformWithDependentMbqlTransforms();
|
||||
|
||||
cy.log("make breaking changes");
|
||||
cy.get<number>("@transformId").then(H.visitTransform);
|
||||
H.DataStudio.Transforms.clickEditDefinition();
|
||||
H.getNotebookStep("data").findByLabelText("Pick columns").click();
|
||||
H.popover().findByLabelText("Score").click();
|
||||
|
||||
cy.log("cancel breaking changes");
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Score transform").should("be.visible");
|
||||
cy.findByText("Name transform").should("not.exist");
|
||||
cy.findByText("Base transform").should("not.exist");
|
||||
cy.button("Cancel").click();
|
||||
});
|
||||
cy.get("@updateTransform.all").should("have.length", 0);
|
||||
|
||||
cy.log("confirm breaking changes");
|
||||
H.DataStudio.Transforms.saveChangesButton().click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Score transform").should("be.visible");
|
||||
cy.findByText("Name transform").should("not.exist");
|
||||
cy.button("Save anyway").click();
|
||||
});
|
||||
cy.wait("@updateTransform");
|
||||
});
|
||||
|
||||
it("should not show a confirmation if there are no breaking changes when updating a MBQL transform before it was run", () => {
|
||||
createMbqlTransformWithDependentMbqlTransforms();
|
||||
cy.get<number>("@transformId").then(H.visitTransform);
|
||||
@@ -394,6 +253,8 @@ function createSqlTransformWithDependentMbqlQuestions() {
|
||||
}
|
||||
|
||||
function createMbqlTransformWithDependentMbqlTransforms() {
|
||||
const targetTableName = "base_transform";
|
||||
|
||||
H.getTableId({ name: "Animals", databaseId: WRITABLE_DB_ID }).then(
|
||||
(tableId) => {
|
||||
H.createTransform(
|
||||
@@ -411,70 +272,70 @@ function createMbqlTransformWithDependentMbqlTransforms() {
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
name: "base_transform",
|
||||
name: targetTableName,
|
||||
schema: "public",
|
||||
database: WRITABLE_DB_ID,
|
||||
},
|
||||
},
|
||||
{ wrapId: true },
|
||||
);
|
||||
cy.get<number>("@transformId").then((transformId) =>
|
||||
H.runTransformAndWaitForSuccess(transformId),
|
||||
);
|
||||
|
||||
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "base_transform" }).then(
|
||||
(tableId) => {
|
||||
H.getFieldId({ tableId, name: "name" }).then((fieldId) => {
|
||||
H.createTransform({
|
||||
name: "Name transform",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
database: WRITABLE_DB_ID,
|
||||
)
|
||||
.then(({ body }) => H.runTransformAndWaitForSuccess(body.id))
|
||||
.then(() => {
|
||||
H.resyncDatabase({
|
||||
dbId: WRITABLE_DB_ID,
|
||||
tableName: targetTableName,
|
||||
});
|
||||
H.getTableId({
|
||||
databaseId: WRITABLE_DB_ID,
|
||||
name: targetTableName,
|
||||
}).then((tableId) => {
|
||||
H.getFieldId({ tableId, name: "name" }).then((fieldId) => {
|
||||
H.createTransform({
|
||||
name: "Name transform",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
fields: [["field", fieldId, null]],
|
||||
database: WRITABLE_DB_ID,
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
fields: [["field", fieldId, null]],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
name: "name_transform",
|
||||
schema: "public",
|
||||
database: WRITABLE_DB_ID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
H.getFieldId({ tableId, name: "score" }).then((fieldId) => {
|
||||
H.createTransform({
|
||||
name: "Score transform",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
target: {
|
||||
type: "table",
|
||||
name: "name_transform",
|
||||
schema: "public",
|
||||
database: WRITABLE_DB_ID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
H.getFieldId({ tableId, name: "score" }).then((fieldId) => {
|
||||
H.createTransform({
|
||||
name: "Score transform",
|
||||
source: {
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: [">", ["field", fieldId, null], 1],
|
||||
database: WRITABLE_DB_ID,
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": tableId,
|
||||
filter: [">", ["field", fieldId, null], 1],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
name: "score_transform",
|
||||
schema: "public",
|
||||
database: WRITABLE_DB_ID,
|
||||
},
|
||||
target: {
|
||||
type: "table",
|
||||
name: "score_transform",
|
||||
schema: "public",
|
||||
database: WRITABLE_DB_ID,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function confirmDiscardChanges() {
|
||||
H.modal().should("have.length", 2).last().button("Discard changes").click();
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should persist filter changes after page reload", () => {
|
||||
setupEntities();
|
||||
H.waitForUnreferencedEntities((entities) =>
|
||||
entities.some((e) => e.data.name === MODEL_FOR_QUESTION_DATA_SOURCE),
|
||||
);
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({ visibleEntities: MODEL_NAMES });
|
||||
|
||||
@@ -219,6 +222,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should filter by location", () => {
|
||||
setupEntities();
|
||||
H.waitForUnreferencedEntities((entities) =>
|
||||
entities.some((e) => e.data.name === MODEL_FOR_MODEL_DATA_SOURCE),
|
||||
);
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
|
||||