Files
dpsutton d4a861b3c9 Checker module (#70866)
* Checker module

```
❯ clj -X:dev:test:ee:ee-dev :module enterprise/checker
Finding tests took 80.0 ms.
Running 10 tests
Running before-run hooks...

10/10   100% [==================================================]  ETA: 00:00

Ran 10 tests in 0.645 seconds
39 assertions, 0 failures, 0 errors.
{:test 10, :pass 39, :fail 0, :error 0, :type :summary, :duration 644.51825, :single-threaded 10}
Ran 0 tests in parallel, 10 single-threaded.
Finding and running tests took 762.0 ms.
```

The number of public functions here is too high but will come down soon.

```
❯ tree export-dir -d -L 2
export-dir
├── collections
│   ├── BFCwHpVZqKMKqth3wVbGF_automatically_generated_dashboards
│   ├── HyB3nRtqb7pBPhFG26evI_examples
│   ├── cards
│   ├── hFKj55TbZN8uDDTNPiZgf_repro1
│   ├── transforms
│   ├── uj8O1_VYi4rPa4Y8ZGb_S_demo_day
│   └── wUkEfoXgg3dBTNCN2egsh_native_queries
├── databases
│   ├── Internal Metabase Database
│   ├── Sample Database
│   └── pg sample-dataset
├── python-libraries
└── transforms
    ├── transform_jobs
    └── transform_tags

17 directories
```

and

```
❯ time clj -X:dev:drivers:ee nocommit.ci/cli :export '"export-dir"' :output '"/tmp/from-cli-with-error.txt"'
Warning: environ value /Users/dan/.sdkman/candidates/java/current for key :java-home has been overwritten with /Users/dan/.sdkman/candidates/java/25.0.2-tem
2026-03-13 14:26:57,899 INFO metabase.util :: Maximum memory available to JVM: 8.0 GB
2026-03-13 14:27:04,139 INFO util.encryption :: Saved credentials encryption is DISABLED for this Metabase instance. 🔓
 For more information, see https://metabase.com/docs/latest/operations-guide/encrypting-database-details-at-rest.html
clj -X:dev:drivers:ee nocommit.ci/cli :export '"export-dir"' :output   34.10s user 1.68s system 228% cpu 15.634 total
```

the output is a big edn map:

```clojure
{"fdkSuB8zWEbjEq_vWMaI-"
 {:card-id 1,
  :name "Heavy-Duty Silk Chair trend",
  :entity-id "fdkSuB8zWEbjEq_vWMaI-",
  :file
  "export-dir/collections/HyB3nRtqb7pBPhFG26evI_examples/cards/fdkSuB8zWEbjEq_vWMaI-_heavy_duty_silk_chair_trend.yaml",
  :refs
  {:tables ["Sample Database.PUBLIC.ORDERS"], :fields ["Sample Database.PUBLIC.ORDERS.CREATED_AT"], :source-cards []},
  :unresolved nil,
  :bad-refs #{}}
  ...
;; card with unknown field
"rvxR7dn4wrKT9_7MHhy7i"
 {:card-id 222,
  :name "Distinct User ID",
  :entity-id "rvxR7dn4wrKT9_7MHhy7i",
  :file "export-dir/collections/uj8O1_VYi4rPa4Y8ZGb_S_demo_day/cards/rvxR7dn4wrKT9_7MHhy7i_distinct_user_id.yaml",
  :refs
  {:tables
   ["pg sample-dataset.public.products" "pg sample-dataset.public.people" "pg sample-dataset.public.demo_transform"],
   :fields [],
   :source-cards []},
  :unresolved [{:type :field, :path ["pg sample-dataset" "public" "demo_transform" "user_iddddd"]}],
  :bad-refs #{}}
  ;; card with unknown table:
 "srEGaobVwDUI177CowuLy"
 {:card-id 226,
  :name "Orders + People + Products model for real by Total",
  :entity-id "srEGaobVwDUI177CowuLy",
  :file
  "export-dir/collections/uj8O1_VYi4rPa4Y8ZGb_S_demo_day/cards/srEGaobVwDUI177CowuLy_orders___people___products_model_for_real_by_total.yaml",
  :refs
  {:tables ["pg sample-dataset.public.begone" "pg sample-dataset.public.products" "pg sample-dataset.public.people"],
   :fields ["pg sample-dataset.public.begone.total"],
   :source-cards []},
  :unresolved [{:type :table, :path ["pg sample-dataset" "public" "begoneeee"]}],
  :bad-refs #{}}
```

* handle foreign key refs in metadata, schemaless dbs like sqlite

checker=> (write-results! (check "export-dir-2") "/tmp/realized.txt")
Results written to: /tmp/realized.txt

```
❯ clj -X:dev:test:ee:ee-dev :module enterprise/checker

Ran 14 tests in 0.936 seconds
58 assertions, 0 failures, 0 errors.
{:test 14, :pass 58, :fail 0, :error 0, :type :summary, :duration 936.312333, :single-threaded 14}
Ran 0 tests in parallel, 14 single-threaded.
Finding and running tests took 1.1 s.
All tests passed.
```

* yaml structural checks:

```shell
❯ clj -X:ee metabase-enterprise.checker.structural/cli :export '"/Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based"'
Warning: environ value /Users/dan/.sdkman/candidates/java/current for key :java-home has been overwritten with /Users/dan/.sdkman/candidates/java/25.0.2-tem
2026-03-17 17:16:18,363 INFO metabase.util :: Maximum memory available to JVM: 8.0 GB
Structural validation complete:
  Valid files: 444
  Invalid files: 1

Errors:
  /Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based/collections/HyB3nRtqb7pBPhFG26evI_examples/cards/-1mRtcrAiZoe-GjYrQFHX_order_value_distribution_by_category.yaml (card)
    #ordered/map ([:name ["missing required key"]])
```

* report errors

```
❯ clj -X:ee metabase-enterprise.checker.structural/cli :export '"/Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based"'
Structural validation complete:
  Valid files: 444
  Invalid files: 1

Errors:

  /Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based/collections/HyB3nRtqb7pBPhFG26evI_examples/cards/-1mRtcrAiZoe-GjYrQFHX_order_value_distribution_by_category.yaml (card)
    - Missing required key 'name' - found 'nname' which may be a typo
```

```
❯ clj -X:dev:test:ee:ee-dev :module enterprise/checker
Running tests in ("enterprise/backend/test/metabase_enterprise/checker")
Looking for test namespaces in directory enterprise/backend/test/metabase_enterprise/checker
Finding tests took 208.0 ms.
Running 22 tests
Running before-run hooks...

22/22   100% [==================================================]  ETA: 00:00

Ran 22 tests in 0.948 seconds
104 assertions, 0 failures, 0 errors.
{:test 22, :pass 104, :fail 0, :error 0, :type :summary, :duration 947.840541, :single-threaded 22}
Ran 0 tests in parallel, 22 single-threaded.
Finding and running tests took 1.2 s.
All tests passed.
Running after-run hooks...
```

* Start to extract format from checker; add readme

ideally the runner persists and we can change the backends that power
it. Now that things are largely working on serdes, i'm extracting the
specifics about the format from the checker stuff so we can begin to
swap them out to simpler formats

* Refactor checker to use MetadataSource protocol

  Introduce protocol-based architecture for the checker module:

  - Add `source.clj` with `MetadataSource` protocol for entity resolution
  - Refactor `checker.clj` to use source protocol instead of global state
    - Session state is now per-check, not global (no more reset-state!)
    - Source and enumerators passed in, enabling different backends
  - Update `format/serdes.clj` to implement MetadataSource
    - Add `make-source`, `check`, `check-cards`, `make-enumerators`
    - High-level API now lives here, not in checker.clj
  - Update tests to use new API
    - In-memory sources via `reify` for edge case testing
    - No fixture cleanup needed between tests
  - Add DESIGN.md documenting two-protocol architecture:
    - EntityResolver: "does this reference exist?"
    - FolderLayout: "where are files in this directory?" (future)
  - Update README.md with current architecture and usage

  This prepares for supporting multiple file formats (serdes, simple LLM-friendly)
  and multiple backends (files, memory, app-db) with the same checker core.

```
❯ clj -X:dev:test:ee:ee-dev :module enterprise/checker
Running tests with options {:mode :cli/local, :namespace-pattern #"^(?:(?:metabase.*)|(?:hooks\..*))", :exclude-directories [".clj-kondo/src" "classes" "dev" "enterprise/backend/src" "local" "resources" "resources-ee" "src" "target" "test_config" "test_resources"], :test-warn-time 3000, :exclude-tags [:metabot-v3/e2e], :module enterprise/checker, :only ("enterprise/backend/test/metabase_enterprise/checker")}
Running tests in ("enterprise/backend/test/metabase_enterprise/checker")
Looking for test namespaces in directory enterprise/backend/test/metabase_enterprise/checker
Finding tests took 196.0 ms.
Running 22 tests
Running before-run hooks...

22/22   100% [==================================================]  ETA: 00:00

Ran 22 tests in 0.976 seconds
110 assertions, 0 failures, 0 errors.
{:test 22, :pass 110, :fail 0, :error 0, :type :summary, :duration 976.301375, :single-threaded 22}
Ran 0 tests in parallel, 22 single-threaded.
Finding and running tests took 1.2 s.
All tests passed.
Running after-run hooks...
```

* proper cli entries

```
❯ clj -M:ee -m metabase.core.bootstrap --mode checker --checker cards --export /Users/dan/projects/work/stats-remote-sync --output /tmp/output.edn
Warning: environ value /Users/dan/.sdkman/candidates/java/current for key :java-home has been overwritten with /Users/dan/.sdkman/candidates/java/25.0.2-tem
2026-03-19 12:27:47,656 INFO metabase.util :: Maximum memory available to JVM: 8.0 GB
2026-03-19 12:27:52,987 INFO util.encryption :: Saved credentials encryption is DISABLED for this Metabase instance. 🔓
 For more information, see https://metabase.com/docs/latest/operations-guide/encrypting-database-details-at-rest.html
Results written to: /tmp/output.edn
Card Check Results
==================
Total cards: 2
  OK: 0
  Errors: 2
  Unresolved refs: 0
  Issues: 0

Failures:
---------

=== Account [beKpAz4TM1cmVN_0HiAvi] ===
  Card ID: 1
  UNRESOLVED REFERENCES:
    - database: Analytics Data Warehouse
    ...
```

and structural:

```
❯ clj -M:ee -m metabase.core.bootstrap --mode checker --checker cards --export /Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based --output /tmp/output.edn
Results written to: /tmp/output.edn
Card Check Results
==================
Total cards: 83
  OK: 83
  Errors: 0
  Unresolved refs: 0
  Issues: 0
```

```
❯ clj -X:dev:test:ee:ee-dev :module enterprise/checker
Running tests with options {:mode :cli/local, :namespace-pattern #"^(?:(?:metabase.*)|(?:hooks\..*))", :exclude-directories [".clj-kondo/src" "classes" "dev" "enterprise/backend/src" "local" "resources" "resources-ee" "src" "target" "test_config" "test_resources"], :test-warn-time 3000, :exclude-tags [:metabot-v3/e2e], :module enterprise/checker, :only ("enterprise/backend/test/metabase_enterprise/checker")}
Running tests in ("enterprise/backend/test/metabase_enterprise/checker")
Looking for test namespaces in directory enterprise/backend/test/metabase_enterprise/checker
Finding tests took 228.0 ms.
Running 22 tests
Running before-run hooks...

22/22   100% [==================================================]  ETA: 00:00

Ran 22 tests in 1.076 seconds
110 assertions, 0 failures, 0 errors.
{:test 22, :pass 110, :fail 0, :error 0, :type :summary, :duration 1075.697958, :single-threaded 22}
Ran 0 tests in parallel, 22 single-threaded.
Finding and running tests took 1.3 s.
All tests passed.
Running after-run hooks...
```

* whoops forgot to add new files

* hybrid format concise db schema, serdes assets

* clean up models.serialization

two things we are cleaning up:

1. protocol

there should be a real protocol that we can implement, rather than
knowing which five dynamic vars we need to override/bind a bunch of
dynamic vars, let's have a real protocol and a way to swap it out.

```
;; for yaml
(def ^:private checker-import-resolver
  (reify resolve/SerdesImportResolver
    (import-fk        [_ eid model]            (import-fk eid model))
    (import-fk-keyed  [_ portable model field] (import-fk-keyed portable model field))
    (import-user      [_ _email]               nil) ; checker doesn't resolve users
    (import-table-fk  [_ path]                 (import-table-fk path))
    (import-field-fk  [_ path]                 (import-field-fk path))))
```

note: future work will put the file system state inside of this and not
as some dynamic var spread throughout the code. But one step at a time.

Now to convert a dataset query we have:
```clojure
(defn- convert-dataset-query
  "Convert a dataset query from serdes format, resolving paths to IDs."
  [query]
  (when query
    (let [db-name (:database query)
          db-id (when (string? db-name) (resolve-db-name db-name))
          query (if db-id (assoc query :database db-id) query)]
      (binding [resolve/*import-resolver* checker-import-resolver]
        (resolve/import-mbql query)))))
```

instead of this:

```clojure
(defn- convert-dataset-query
  "Convert a dataset query from serdes format, resolving paths to IDs."
  [query]
  (when query
    (let [db-name (:database query)
          db-id (when (string? db-name) (resolve-db-name db-name))
          query (if db-id (assoc query :database db-id) query)]
      (binding [serdes/*import-field-fk* import-field-fk
                serdes/*import-table-fk* import-table-fk
                serdes/*import-fk* import-fk
                serdes/*import-fk-keyed* import-fk-keyed]
        (serdes/import-mbql query)))))
```

And in the future it will hopefully be something like this:

```clojure
(defn- convert-dataset-query
  "Convert a dataset query from serdes format, resolving paths to IDs."
  [yaml-store query]
  (when query
    (let [db-name (:database query)
          db-id (when (string? db-name) (resolve-db-name db-name))
          query (if db-id (assoc query :database db-id) query)]
      (binding [resolve/*import-resolver* (checker-import-resolver yaml-store)]
        (resolve/import-mbql query)))))
```

2. not require toucan in the checker.
Before this change, we had to require `metabase.models.serialization`
which brought in `[toucan2.core :as t2]`, models.interface, all kinds of
database specific stuff. We now have the protocol and can implement it
against the yaml store rather than the db. Hopefully startup is faster
now.

```
clj -M:ee -m metabase.core.bootstrap --mode checker --checker cards --export /Users/dan/projects/work/yaml-checked-files-v1/exports/sqlite-based-mixed-versions --output /tmp/mixed.output
Results written to: /tmp/mixed.output
Card Check Results
==================
Total cards: 83
  OK: 83
  Errors: 0
  Unresolved refs: 0
  Issues: 0
```

has gone from 11s to 9s. Small wins, but ~10% savings.

Also, it seems like the vast majority of time is spent in stuff we don't
care about which generates these logs:

```
Warning: environ value /Users/dan/.sdkman/candidates/java/current for key :java-home has been overwritten with /Users/dan/.sdkman/candidates/java/25.0.2-tem
2026-03-20 12:15:32,319 INFO metabase.util :: Maximum memory available to JVM: 8.0 GB
```

* clean up, build file interpreter rather than imperative

* lenient mode

writes out a manifest file of fields that are used

* lenient checker

* add serdes workflow and yaml-edit skill files, fix bootstrap fallthrough

Two new Claude skill files for the serdes demo workflow:
- serdes-workflow: export/validate/import pipeline with exact commands
- serdes-yaml-edit: editing YAML with portable refs, self-healing loop,
  checker error interpretation, and reference lookup guide

Fix bootstrap.clj to return false when no --mode flag is present,
allowing normal Metabase startup (import/export commands) to proceed.

* allow for separate db schema directory

checker: --schema-dir, --errors-only, sentinel IDs for unresolved refs

  --schema-dir lets database schemas live in a separate directory from the
  export. The directory is treated as the databases dir directly (contains
  db entries, not a databases/ subdirectory).

  --errors-only outputs just errors to stdout in a concise format for LLM
  consumption — no summary, no status lines.

  Unresolved field/table refs now get sentinel IDs instead of nil, so
  lib/query can construct the query and lib/find-bad-refs can flag the
  missing refs. Previously these blew up with "should be a positive int,
  got: nil".

  Without --lenient, missing schemas now throw instead of silently falling
  back to lenient mode.

  Want me to go ahead and commit with that?

* validate native sql

* filter false-positive :missing-column errors from SQL validation

Schema-less databases (e.g., SQLite) store tables with nil schema, but
sql-tools/validate-query assumes the driver's default schema ("public")
for unqualified table references. This mismatch causes valid columns to
be reported as missing.

Cross-check :missing-column errors against the store and suppress any
where the column actually exists for that database.

* extract the notion of the store

* down to just a single format

* cleanup, rename some args

* rename to semantic

* dashboard checker, extract a bit of common semantic checks

* check dashboard references

* extra schemas to bespoke file so we can move to a public repo

* Remove schema checker

we are semantic only. Now no reason to use a mode param

* Simplify checker module: remove formats, add dashboard/document, norm

  Major refactoring of the checker module:

- Remove format abstraction: Delete concise, lenient, and hybrid
formats. Serdes is the only format. Delete format/hybrid.clj,
format/concise.clj, format/lenient.clj and their tests.

- Remove structural checker: Schemas moved to separate repo. Delete
structural.clj, schemas.clj, structural_test.clj.

- Rename checker.clj → semantic.clj: The "cards" checker is now the
"semantic" checker. CLI no longer has a --checker flag.

- Simplify store: Replace enumerator thunks with direct file index. Add
fields-by-table reverse index for O(1) table→fields lookup.

- Remove requiring-resolve: checker.clj (now semantic.clj) directly
requires format.serdes. No circular dependency indirection.

- schema-dir is required: No auto-detection. Export dir and schema dir
are always separate arguments.

- Add CompositeSource: In source.clj, delegates db/table/field to one
source and cards to another.

- Index entities by model field: Collections tree indexed by reading
serdes/meta model from YAML content rather than path patterns. Supports
both traditional serdes layout and the new flat layout.

- Dashboard validation: Validate dashcard card_id refs, dashboard_tab_id refs, and grid bounds (24-column grid).

- Document validation: Walk ProseMirror content tree, validate cardEmbed card refs.

- collection_id and dashboard_id validation: Any entity with these fields is checked against the index.

- Duplicate entity_id detection: Detected at index build time, reported with file paths.

- MBQL normalization pipeline: keywordize string operators → resolve
portable refs → legacy normalize. Known temporal unit strings
keywordized. Malli enforcement disabled during checking so lib/query
accepts valid clauses the schema doesn't enumerate.

- Disambiguate internal naming: Accumulator atom renamed from unresolved
to !failures. Qualified keys renamed to ::resolution-failures and
::missing-database.

* segment and measure support

* compile sql, handle native query snippet

* implement transforms checks

* using binding to prevent running t2 middleware

* fixup merge failures

* directory agnostic schema dir; sql parsing fixes

realized we weren't keeping around db information. now we are so we
don't have to bind driver information

```diff
-            compiled    (binding [driver/*driver* driver
-                                  qp.i/*skip-middleware-because-app-db-access* true]
+            compiled    (binding [qp.i/*skip-middleware-because-app-db-access* true]
```

* use lib for native check

* parse transform sql, store tests, fixups

* rename check-cards → check-entities; add direct native validation tests

check-cards was misleading — it validates cards, dashboards,
collections, documents, transforms, and more. Renamed to check-entities
with an updated docstring that documents the three parameters.

Added 6 direct tests for native.clj functions (extract-sql-refs,
validate-native-sql) covering valid queries, missing columns, syntax
errors, unknown databases, and nil db-name.

* remove bespoke native checking, use deps analysis

* compute result metadata for processing:

change:

before:
```
=== Native Query - Card and Snippet References [HiBFSt0BNx5s5MxVDLLKB] ===
  Card ID: 94
  Tables: Sample Database.PUBLIC.ORDERS
  Fields: Sample Database.PUBLIC.ORDERS.CREATED_AT
  Source Cards: Basic Aggregations
  NATIVE SQL ERRORS:
    - {:type :missing-column, :name "total_revenue", :source-entity-type :card, :source-entity-id "h5F2EjHsRd73Dqqh8sAtd"}
    - {:type :missing-column, :name "created_attt", :source-entity-type :card, :source-entity-id "h5F2EjHsRd73Dqqh8sAtd"}
    - {:type :missing-column, :name "cooount", :source-entity-type :card, :source-entity-id "h5F2EjHsRd73Dqqh8sAtd"}
  Status: NATIVE SQL ERRORS
```

after:
```
=== Native Query - Card and Snippet References [HiBFSt0BNx5s5MxVDLLKB] ===
  Card ID: 94
  Tables: Sample Database.PUBLIC.ORDERS
  Fields: Sample Database.PUBLIC.ORDERS.CREATED_AT
  Source Cards: Basic Aggregations
  NATIVE SQL ERRORS:
    - {:type :missing-column, :name "created_attt", :source-entity-type :card, :source-entity-id "h5F2EjHsRd73Dqqh8sAtd"}
    - {:type :missing-column, :name "cooount", :source-entity-type :card, :source-entity-id "h5F2EjHsRd73Dqqh8sAtd"}
  Status: NATIVE SQL ERRORS
```

The total_revenue issue is related to not knowing that the card's
aggregation creates this column. Before it didnt' recognize it. Now with
metadata it does recognize it

* cleanup a bit

- println -> output! to silence the warnings and suppress in one place
- -main -> entrypoint so it doesn't appear as a proper entrypoint for
  the jar, we still go through bootstrap and dispatch

* some dumentation on checker shape

* cljfmt files

* fixup

* include resolver arg

* fix readme and docstrings

* clean things up, introduce provider

provider is what wraps a store into a metadata provider. it's very
qp. the store is what cares about how to load assets and schema from
disk (or anywhere else?)

* e2e tests, report all checked items, roadmap and readme

* fix lint errors

* Fix dependencies module dependency

was using deps.analysis for it's check-entity multimethod. I at first
put an entry in deps.core that passes through to this, but deps.core
brings in t2 which i don't want.

Future work should split this namespace so it has some protocol of how
to load up it's information for deps analysis and the checker can figure
out how to adapt it's store and index for these purposes. Then all the
t2 stuff goes away and we're golden

* sort the api namespaces

* fmt fixes

metabase on  yaml-backed-metadata-provider [$?] via 🥟 v1.3.8 via ☕ v25.0.2 on ☁️  metabase-query
❯ bin/mage cljfmt-files enterprise/backend/test/metabase_enterprise/checker
Reformatted enterprise/backend/test/metabase_enterprise/checker/semantic_test.clj

metabase on  yaml-backed-metadata-provider [$!?] via 🥟 v1.3.8 via ☕ v25.0.2 on ☁️  metabase-query
❯ bin/mage cljfmt-files enterprise/backend/src/metabase_enterprise/serialization/cmd.clj
Reformatted enterprise/backend/src/metabase_enterprise/serialization/cmd.clj

* Checker: unify entity checking, split protocols, extract provider

  Major refactoring of the checker module for clarity and consistency:

  - Split MetadataSource into SchemaSource (databases/tables/fields) and
    AssetsSource (cards/snippets/transforms/segments/dashboards/collections/
    documents/measures). All entity types now go through source protocols
    and the store — no more direct YAML file loading fallbacks.

  - Extract provider.clj from semantic.clj. The provider adapts the store
    into a lib MetadataProvider: reference resolution, MBQL normalization,
    data conversion. semantic.clj is now just validation + orchestration.

  - Replace custom keywordize-mbql-operators (~40 lines) with lib/normalize.

  - Unify entity checking: cards are no longer special-cased. check-entity
    dispatches by kind for all entity types. check-one works for any entity,
    not just cards. check-entities defaults to checking everything.

  - Store sources are now private. Provider creates resolvers internally.
    resolve-ref uses store load functions instead of reaching through to
    source protocols directly.

  - Add dashboard/collection/document/measure to source protocols, store
    load functions, and test helpers. Tests use in-memory sources throughout
    — no more temp YAML files.

  - Add e2e tests against test_resources/serialization_baseline with shape
    assertions for joins, snippets, measures/metrics/segments, and
    transitive ref propagation.

  - Add forward-declare wrapper for deps.analysis/check-entity.

  - Track snippets, measures, metrics, segments in card :refs, propagating
    transitively through source-card chains.

  - All non-card entities now appear in results (not just failures).

* handle multiple databases

* cleanup entrypoint a bit

* Checker: lazy on-demand indexing for schema directories

Index only database YAMLs (~40 files) at startup instead of parsing all
563k YAML files. Tables, fields resolved on-demand from directory structure.
Segments/measures indexed only from export dir, not schema dir.

Key changes:
- SchemaSource protocol gains enumeration methods (fields-for-table,
  tables-for-database, all-table-paths, etc.)
- db-name->dir mapping handles slugified directory names
- Provider scoped to current database (tables-for-database not all-table-paths)
- Store delegates to source instead of maintaining field index

Setup time: 64s → ~100ms for 40 databases, 22k tables, 541k fields.

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

* Checker: handle table-type template tags, lightweight table listing

- Native queries with {{table_one}} (type: table) template tags now get
  placeholder substitution instead of passing through as raw mustache
  syntax that SQLGlot can't parse
- Filter out validation errors referencing table placeholder names
- Skip result-metadata for table-tag queries (columns unknown at check time)
- Provider uses ensure-table-id! for table enumeration (path-derived
  metadata, no YAML parse) instead of load-table! (full YAML parse)
- Remove debug instrumentation from semantic.clj

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

* Checker: update docs to reflect on-demand indexing architecture

- DESIGN.md: rewrite to reflect current architecture (SchemaSource/AssetsSource
  protocols, on-demand field/table resolution, db-name->dir mapping, parallelism
  readiness, performance characteristics)
- CHECKER_RESULTS.md: add analysis of 177-entity run (17 true errors, 2 remaining
  bugs, 1 fixed template tag bug)
- Fix stale docstrings in store.clj and serdes.clj

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

* Checker: consolidate docs into README, remove standalone md files

Move architecture, performance, and on-demand indexing docs into
the existing README. Remove DESIGN.md and CHECKER_RESULTS.md.

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

* Checker: fix test helpers for new SchemaSource protocol methods

- Add fields-for-table, all-field-paths, all-database-names,
  all-table-paths, tables-for-database to test helper reify
- Update index assertion tests to check via source protocol
  instead of pre-indexed table/field counts

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

* Merge branch 'master' into yaml-backed-metadata-provider

* remove bashing in place, unused function

* Checker: single-atom schema model with two-level lazy resolution

Replace the three separate cache atoms (indexed-schemas,
table-cache,field-cache) with a single schema-model atom that owns all
database, table, and field knowledge. Separate from the assets-index
which holds cards, dashboards, segments, etc.

Schema model shape:
  {db-name {:db-file "path"
            :schemas {schema ::not-indexed
                             | {table {:table-file "path"
                                       :fields ::not-indexed | {field "path"}}}}}}

Two levels of laziness:
- Schema → tables: populated when any table in that schema is resolved,
  or when tables-for-database is called. Walks the schema's tables/
  directory and reads each table's name: from YAML via fast regex.
- Table → fields: populated when resolve-field or fields-for-table is
  called for that specific table. Walks the table's fields/ directory
  and reads each field's name: from YAML via fast regex.

Schema name resolution handles the case where directory names differ
from real entity names (e.g. H2: dir=public, real=PUBLIC). This is
isolated in resolve-schema-name as the single point to change when
schema resolution improves.

Also: store paths as strings not Files, fix exists? for databases (now
checked via schema source not assets index), remove dead
has-card-template-tags?.

* inline util lower-case-en

don't want to pull in all the stuff so i just use the definition inline
here

---------

Co-authored-by: Bryan Maass <bryan.maass@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:10:01 -05:00

7.1 KiB

name, description
name description
serdes-yaml-edit Edit Metabase serdes YAML files (cards, dashboards, databases) with correct portable references and structural conventions. Use when modifying exported YAML content.

Serdes YAML Edit Skill

Golden Rule

Run both checkers after every edit. No exceptions. Do not batch multiple edits before validating.

clojure -M:run:ee --mode checker --checker structural --export /path/to/export-dir
clojure -M:run:ee --mode checker --checker cards --export /path/to/export-dir

If either checker fails, fix the issue before making further edits.

Portable References

Serdes YAML uses portable references instead of integer IDs. This is the most important concept for editing.

Database references

String name: "Sample Database"

Table references

Array of [database, schema, table]:

table_id:
- Sample Database
- PUBLIC
- ACCOUNTS

Field references

Array of [database, schema, table, field]:

id:
- Sample Database
- PUBLIC
- ACCOUNTS
- EMAIL

Field refs in queries

field_ref:
- field
- - Sample Database
  - PUBLIC
  - ACCOUNTS
  - EMAIL
- null

The outer array is [field, <field-path>, <options>]. The options are usually null.

Card references

Entity ID string (21 characters): "Qk5TgsNx4ubXIUtsQmT8G"

Source table in queries

dataset_query:
  database: Sample Database
  query:
    source-table:
    - Sample Database
    - PUBLIC
    - ACCOUNTS
  type: query

Safe Edits (low risk)

These rarely break validation:

  • name - card/dashboard display name
  • description - card/dashboard description
  • display - visualization type (table, bar, line, pie, etc.)
  • visualization_settings - chart configuration
  • archived - true/false
  • collection_id - move to a different collection (use entity_id of target collection, or null for root)

Structural Edits (must match schema)

These must use valid portable refs and will be caught by the cards checker if wrong:

  • dataset_query - the query definition
  • result_metadata - column metadata (must match the query's output columns)
  • table_id - must reference a table that exists in the export
  • database_id - must reference a database that exists in the export

Editing result_metadata

Each entry in result_metadata describes an output column. When changing a query's source table or fields, you must update result_metadata to match. Each field entry needs at minimum:

  • name - column name (e.g., EMAIL)
  • base_type - Metabase type (e.g., type/Text, type/Integer, type/DateTime)
  • display_name - human-readable name
  • field_ref - portable field reference
  • id - portable field path
  • table_id - portable table path
  • source - usually fields

Common Operations

Rename a card

Change name: at the top level. Safe, no ref changes needed.

Change a card's source table

Update all of these consistently:

  1. table_id - top-level
  2. dataset_query.query.source-table - in the query
  3. result_metadata - every field entry's id, field_ref, and table_id

Add a filter to a structured query

dataset_query:
  database: Sample Database
  query:
    source-table:
    - Sample Database
    - PUBLIC
    - ORDERS
    filter:
    - ">"
    - - field
      - - Sample Database
        - PUBLIC
        - ORDERS
        - TOTAL
      - null
    - 100
  type: query

Change visualization type

display: bar    # was: table

Valid types: table, bar, line, pie, scalar, row, area, combo, scatter, funnel, map, pivot, progress, gauge, waterfall

Looking Up Valid References

The export directory IS the reference catalog. When you need to find the correct name for a database, table, or field, look it up directly from the export.

List valid databases

ls databases/

Each entry is a database name (directory for serdes format, .yaml for concise format).

List valid tables for a database

ls databases/<db-name>/schemas/<schema>/tables/

Example: ls databases/Sample\ Database/schemas/PUBLIC/tables/ shows ACCOUNTS, ORDERS, PRODUCTS, etc.

List valid fields for a table

ls databases/<db-name>/schemas/<schema>/tables/<table>/fields/

Example: ls databases/Sample\ Database/schemas/PUBLIC/tables/PRODUCTS/fields/ shows CATEGORY.yaml, TITLE.yaml, PRICE.yaml, etc. The filename (minus .yaml) is the field name.

Find card entity IDs

Card filenames encode the entity ID: <entity-id>_<slug>.yaml. The entity ID is the part before the first underscore (21 characters).

Self-Healing Loop

When a checker reports errors, follow this loop:

  1. Read the error message - it tells you what's wrong and often suggests the fix
  2. Look up the correct value from the export directory (see "Looking Up Valid References" above)
  3. Fix the YAML
  4. Re-run both checkers
  5. Repeat until clean

Do not guess at fixes. Always look up the correct value from the export.

Understanding Checker Errors

Structural checker errors

The structural checker validates YAML shape against Malli schemas. Common errors:

Missing required key with typo suggestion:

Missing required key 'name' - found 'nameee' which may be a typo

Fix: rename the typo'd key back to the correct name. The checker tells you what it expected and what it found.

Wrong type:

'archived' should be a boolean, got: "yes"

Fix: use true/false, not strings.

Unknown key:

Unknown key 'foobar' in card

Fix: remove the key, or check if it's a typo of a known key.

Cards checker errors

The cards checker validates that queries resolve against exported metadata. Error types:

UNRESOLVED REFERENCES:

UNRESOLVED REFERENCES:
  - field: Sample Database.PUBLIC.PRODUCTS.CATEGORYYY

The dotted path tells you exactly which reference failed. Look up the correct value:

  • Last segment is the field name - check databases/.../tables/<table>/fields/
  • Third segment is the table name - check databases/.../schemas/<schema>/tables/
  • First segment is the database name - check databases/

ERROR (query construction failed):

ERROR: Error creating query from legacy query: Invalid output: ...

Usually means the query structure is malformed. This often follows unresolved references - fix the refs first, then re-check.

ERROR (nil name):

ERROR: Invalid output: {:name ["should be a string, got: nil"]}

The card is missing its name field (likely a structural issue that also affects the cards checker).

Exit codes

  • Exit 0: all checks passed
  • Exit 1: one or more failures

File Naming Convention

Card files: collections/<collection-path>/cards/<entity-id>_<slug>.yaml

The filename slug should match the card name (lowercase, underscored). If you rename a card, consider renaming the file to match, though import works on entity_id not filename.

What NOT to Edit

  • entity_id - this is the identity of the object, changing it creates a new object on import
  • serdes/meta - internal serdes metadata, leave it alone
  • created_at - timestamp, no reason to change
  • creator_id - email reference, leave it
  • metabase_version - informational, leave it