From 3c63e902193438488c7d19e6597752eea8fe2b02 Mon Sep 17 00:00:00 2001 From: sanex3339 Date: Sat, 3 Oct 2026 00:30:01 +0400 Subject: [PATCH] The data app manifest is the app's serialized entity Metabase now loads `data_app.yaml` as a `DataApp` entity: it carries `serdes/meta`, an `entity_id`, and its `slug`, and the validator checks it like every other entity file. The resources are loaded as the ordinary entity files they are: cards and actions name their creator, an action sits in the app's collection and may have no model, and a file that breaks the rules fails the pull, naming the file. HTTP actions are gone. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/import.yml | 2 +- core-spec/v1/schemas/data_app.yaml | 38 +++++++++++++++++-- core-spec/v1/spec.md | 33 ++++++++++------ .../v1/data_apps/order-desk/data_app.yaml | 6 +++ .../resources/actions/update_order.yaml | 2 +- src/data-app-schema.test.ts | 23 +++++++++++ src/validate-schema.test.ts | 15 +++++--- src/validate-schema.ts | 1 + 8 files changed, 98 insertions(+), 22 deletions(-) diff --git a/.github/workflows/import.yml b/.github/workflows/import.yml index a846a82..f50a595 100644 --- a/.github/workflows/import.yml +++ b/.github/workflows/import.yml @@ -127,7 +127,7 @@ jobs: APPS=$(curl -sf http://localhost:3000/api/apps -H "X-API-Key: $API_KEY") for DIR in examples/v1/data_apps/*/; do SLUG=$(basename "$DIR") - if ! echo "$APPS" | jq -e --arg slug "$SLUG" 'any(.[]; .name == $slug and .sync_error == null)' > /dev/null; then + if ! echo "$APPS" | jq -e --arg slug "$SLUG" 'any(.[]; .name == $slug and .resource_collection_id != null)' > /dev/null; then echo "$APPS" | jq --arg slug "$SLUG" '.[] | select(.name == $slug)' echo "Data app $SLUG did not sync" exit 1 diff --git a/core-spec/v1/schemas/data_app.yaml b/core-spec/v1/schemas/data_app.yaml index 48fca1b..3d06e0d 100644 --- a/core-spec/v1/schemas/data_app.yaml +++ b/core-spec/v1/schemas/data_app.yaml @@ -2,19 +2,31 @@ $schema: https://json-schema.org/draft/2020-12/schema title: Data App manifest description: > The `data_app.yaml` at the root of a data app's directory, - `data_apps//data_app.yaml`. The directory's name is the app's slug, - which this file can't check: lowercase letters, numbers, and single dashes - between them, and neither `repo-status` nor `sandbox-host`. + `data_apps//data_app.yaml`: the app's own serialized entity, identified + by its `entity_id` like every other. Its `slug` must be the directory's name, + which this file can't check. type: object required: - name + - slug + - entity_id - path - collection + - serdes/meta properties: name: type: string description: Display name pattern: \S + slug: + type: string + description: > + The app's slug, used verbatim in its URL and as its directory's name: + lowercase letters and numbers separated by single dashes, and neither + `repo-status` nor `sandbox-host`, which collide with Metabase's own routes. + pattern: ^(?!(?:repo-status|sandbox-host)$)[a-z0-9]+(?:-[a-z0-9]+)*$ + entity_id: + $ref: "common/id.yaml#/$defs/entity_id" description: type: [string, "null"] description: > @@ -45,4 +57,24 @@ properties: items: type: string pattern: ^\s*[Hh][Tt][Tt][Pp][Ss]?://(?:\*\.)?[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?::[0-9]+)?/*\s*$ + serdes/meta: + type: array + description: Identity path for serialization, a single `DataApp` entry + minItems: 1 + maxItems: 1 + items: + type: object + required: + - id + - model + properties: + id: + type: string + description: Entity identifier (matches entity_id) + label: + type: string + description: The app's slug + model: + type: string + const: DataApp additionalProperties: true diff --git a/core-spec/v1/spec.md b/core-spec/v1/spec.md index deffe89..4c0cf7b 100644 --- a/core-spec/v1/spec.md +++ b/core-spec/v1/spec.md @@ -87,7 +87,7 @@ serdes/meta: **Important:** Metabase ignores directory structure when importing — all collection relationships are determined solely by each entity's `collection_id` field. The layout below is how Metabase represents user content when exporting; it mirrors the collection hierarchy on disk for readability, but only `collection_id` is authoritative. -Metabase only checks for importable YAML files in these top-level directories: `actions/`, `collections/`, `databases/` (only `segments/` and `measures/` subdirectories), `python_libraries/` (also accepted as `python-libraries/`), and `transforms/`, plus each data app's `data_apps//resources/`. Files outside these directories are ignored during import. +Metabase only checks for importable YAML files in these top-level directories: `actions/`, `collections/`, `databases/` (only `segments/` and `measures/` subdirectories), `python_libraries/` (also accepted as `python-libraries/`), and `transforms/`, plus each data app's `data_apps//data_app.yaml` and `resources/`. Files outside these directories are ignored during import. Collections are organized by namespace. The `main` namespace holds regular content (cards, dashboards, etc.), `snippets` holds SQL snippet collections, and `transforms` holds transform entities. Subcollections must set `parent_id` to the entity_id of their parent collection. All entity types within a collection are stored flat in the same folder — there are no `cards/`, `dashboards/` subdirectories. @@ -3090,7 +3090,7 @@ serdes/meta: ## Data App -A data app is a custom React application that runs inside Metabase, reading and writing data through the content it ships with. It lives in its own directory, `data_apps/{app_slug}/`, and is loaded when Metabase pulls the repository through remote sync. Unlike the other entities, it is not a single YAML file: it is a manifest, a built JavaScript bundle, and a `resources/` directory of ordinary Collection, Card, and Action files that the app owns. +A data app is a custom React application that runs inside Metabase, reading and writing data through the content it ships with. It lives in its own directory, `data_apps/{app_slug}/`, and is loaded when Metabase pulls the repository through remote sync. Unlike the other entities, it is not a single YAML file: it is a manifest (the app's own serialized entity), a built JavaScript bundle beside it, and a `resources/` directory of ordinary Collection, Card, and Action files that the app owns. ### Directory Layout @@ -3107,34 +3107,37 @@ data_apps/ └── {slug}.yaml # Copies of the actions the app runs, on the copies of their models ``` -`{app_slug}` — the directory's name — is the app's slug, used verbatim as its URL: lowercase letters and numbers separated by single dashes (`sales-dashboard`), and neither `repo-status` nor `sandbox-host`, which collide with Metabase's own routes. +`{app_slug}` — the directory's name — is the app's slug, the manifest's `slug`, used verbatim as its URL: lowercase letters and numbers separated by single dashes (`sales-dashboard`), and neither `repo-status` nor `sandbox-host`, which collide with Metabase's own routes. -Files under `resources/` are recognized by their location: `collection.yaml`, `cards/*.yaml`, and `actions/*.yaml`. Anything else there (another file or directory, or a file in `cards/` or `actions/` that isn't `.yaml`) makes the pull refuse the app's resources; only dotfiles are skipped. +Files under `resources/` are recognized by their location: `collection.yaml`, `cards/*.yaml`, and `actions/*.yaml`. Any other `.yaml` file there fails the pull. Files that aren't YAML, and dotfiles, are ignored, as they are everywhere else. ### Manifest -`data_app.yaml` describes the app. It has no `serdes/meta`: the app is identified by its directory. +`data_app.yaml` describes the app. It is the app's serialized entity, identified by its `entity_id` like every other, with a single-entry `serdes/meta` whose `model` is `DataApp` and whose `label` is the slug. | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name | +| `slug` | string | Yes | The app's slug, equal to its directory's name | +| `entity_id` | string | Yes | NanoID identifier | | `path` | string | Yes | Path of the built bundle, relative to the app's directory (e.g., `./dist/index.js`). Must not contain `..` | -| `collection` | string | Yes | Collection FK (entity_id) of the app's collection, the one `resources/collection.yaml` holds | +| `collection` | string | Yes | Collection FK (entity_id) of the app's collection, the one `resources/collection.yaml` holds. Metabase loads a manifest without it as an app that isn't published yet, which no one can open | | `version` | integer | No | The data app contract version the app was built for: a positive integer, `1` when absent. An app below the version the instance supports is marked outdated and not served | | `description` | string | No | One-line summary, at most 255 characters | | `allowed_hosts` | array | No | Origins the app may call with `fetch`/XHR: a scheme and host, with an optional `*.` subdomain wildcard and port, and no path (e.g., `https://api.example.com`, `https://*.internal.example.com`). Every other origin is blocked | +| `serdes/meta` | array | Yes | Identity path: `[{model: DataApp, id: , label: }]` | ### Resources -The app's resources are what it reads and runs: a saved question for each query it makes, and copies of the models, metrics, and actions those use. They are copies because the app's viewers are granted access to the app's collection only, and an action's permissions follow its model's collection. Each file is an ordinary [Collection](#collection), [Card](#card), or [Action](#action), with these constraints: +The app's resources are what it reads and runs: a saved question for each query it makes, and copies of the models, metrics, and actions those use. They are copies because the app's viewers are granted access to the app's collection only. Each file is an ordinary [Collection](#collection), [Card](#card), or [Action](#action), loaded like any other entity file in the repository, with these constraints, which a pull checks before it loads anything: -- **Every file** holds a single entity whose `serdes/meta` id is its `entity_id`, and no `entity_id` is defined by more than one file. +- **Every file** holds a single entity whose `serdes/meta` id is its `entity_id`, and no `entity_id` is defined by more than one file. An `entity_id` that already belongs to a collection, card, or action outside the app can't be used: a load would take that entity over. - **`collection.yaml`** holds the collection the manifest names. It is a plain root collection: no `parent_id`, `namespace`, `type`, `authority_level`, or `personal_owner_id`, and not archived. -- **Cards** have `collection_id` set to the app's collection, are a `question`, `model`, or `metric` with a `dataset_query`, and are not archived, not in a dashboard or document, and not public or embedded. -- **Actions** are `implicit` or `query`, never `http`; their `model_id` is a model card in `resources/`; they carry exactly the one nested record their `type` uses; and they are not archived, not public, and take no parameter values from a card. +- **Cards** have `collection_id` set to the app's collection, name their `creator_id`, are a `question`, `model`, or `metric` with a `dataset_query`, and are not archived, not in a dashboard or document, and not public or embedded. +- **Actions** have `collection_id` set to the app's collection and name their `creator_id`; are `implicit` or `query`; their `model_id`, when they have one, is a model card in `resources/`, and an implicit action always has one; they carry exactly the one nested record their `type` uses; and they are not archived, not public, and take no parameter values from a card. - **References** outside `resources/` may point only at databases, tables, fields, snippets, segments, and measures, all of which must already exist on the instance. A card, collection, or dashboard outside the app cannot be referenced: a question or metric that reads another card must use the app's own copy of it. -Metabase reloads the resources on every pull, so the repository owns them: a change made in Metabase to the app's collection, cards, or actions is put back, and a card or action whose file is removed is deleted. A pull that finds a resource breaking these rules refuses the app's resources and reports the file. +The app's collection is remote-synced content like the rest of the repository: a pull loads the files that changed, deletes a card or action whose file is gone, and puts back what was changed in Metabase; an export writes the collection and what it holds under `resources/`, beside the app, rather than under `collections/`. A file that breaks these rules fails the pull, naming the file, as any other invalid entity file does. ### Example @@ -3142,12 +3145,18 @@ Metabase reloads the resources on every pull, so the repository owns them: a cha ```yaml name: Order Desk +slug: order-desk description: Review orders and apply discounts version: 1 path: ./dist/index.js collection: dApPcOlLeCtIoN0ExAmP1 +entity_id: dApPmAnIfEsT000ExAmP1 allowed_hosts: - https://api.example.com +serdes/meta: +- model: DataApp + id: dApPmAnIfEsT000ExAmP1 + label: order-desk ``` **`resources/collection.yaml`:** @@ -3217,13 +3226,13 @@ serdes/meta: ```yaml name: Update order entity_id: dApPaCtIoNcOpYExAmP12 +collection_id: dApPcOlLeCtIoN0ExAmP1 creator_id: admin@example.com type: implicit model_id: dApPmOdElCoPy0ExAmP12 implicit: - kind: row/update query: [] -http: [] parameters: [] parameter_mappings: [] serdes/meta: diff --git a/examples/v1/data_apps/order-desk/data_app.yaml b/examples/v1/data_apps/order-desk/data_app.yaml index b7a8e39..7b9ccb3 100644 --- a/examples/v1/data_apps/order-desk/data_app.yaml +++ b/examples/v1/data_apps/order-desk/data_app.yaml @@ -1,7 +1,13 @@ name: Order Desk +slug: order-desk description: Review orders and apply discounts version: 1 path: ./dist/index.js collection: dApPcOlLeCtIoN0ExAmP1 +entity_id: dApPmAnIfEsT000ExAmP1 allowed_hosts: - https://api.example.com +serdes/meta: +- model: DataApp + id: dApPmAnIfEsT000ExAmP1 + label: order-desk diff --git a/examples/v1/data_apps/order-desk/resources/actions/update_order.yaml b/examples/v1/data_apps/order-desk/resources/actions/update_order.yaml index 0bccc53..1f1d4e0 100644 --- a/examples/v1/data_apps/order-desk/resources/actions/update_order.yaml +++ b/examples/v1/data_apps/order-desk/resources/actions/update_order.yaml @@ -1,12 +1,12 @@ name: Update order entity_id: dApPaCtIoNcOpYExAmP12 +collection_id: dApPcOlLeCtIoN0ExAmP1 creator_id: admin@example.com type: implicit model_id: dApPmOdElCoPy0ExAmP12 implicit: - kind: row/update query: [] -http: [] parameters: [] parameter_mappings: [] serdes/meta: diff --git a/src/data-app-schema.test.ts b/src/data-app-schema.test.ts index cc97b24..64d006c 100644 --- a/src/data-app-schema.test.ts +++ b/src/data-app-schema.test.ts @@ -41,11 +41,16 @@ const validateManifest = ajv.compile(loadSchema("data_app.yaml")); // The example manifest in the spec's Data App section. const MANIFEST = { name: "Order Desk", + slug: "order-desk", description: "Review orders and apply discounts", version: 1, path: "./dist/index.js", collection: "dApPcOlLeCtIoN0ExAmP1", + entity_id: "dApPmAnIfEsT000ExAmP1", allowed_hosts: ["https://api.example.com"], + "serdes/meta": [ + { model: "DataApp", id: "dApPmAnIfEsT000ExAmP1", label: "order-desk" }, + ], }; describe("data app manifest schema", () => { @@ -55,8 +60,11 @@ describe("data app manifest schema", () => { "only the required fields", { name: "Order Desk", + slug: "order-desk", + entity_id: "dApPmAnIfEsT000ExAmP1", path: "dist/index.js", collection: "dApPcOlLeCtIoN0ExAmP1", + "serdes/meta": [{ model: "DataApp", id: "dApPmAnIfEsT000ExAmP1" }], }, ], [ @@ -78,6 +86,21 @@ describe("data app manifest schema", () => { it.each([ ["no name", without("name")], ["a blank name", { ...MANIFEST, name: " " }], + ["no slug", without("slug")], + ["a slug with a capital letter", { ...MANIFEST, slug: "Order-desk" }], + ["a slug with an underscore", { ...MANIFEST, slug: "order_desk" }], + ["a slug with two dashes in a row", { ...MANIFEST, slug: "order--desk" }], + ["a slug of Metabase's own route", { ...MANIFEST, slug: "repo-status" }], + ["no entity_id", without("entity_id")], + ["an entity_id that isn't a NanoID", { ...MANIFEST, entity_id: "order" }], + ["no serdes/meta", without("serdes/meta")], + [ + "serdes/meta of another model", + { + ...MANIFEST, + "serdes/meta": [{ model: "Collection", id: "dApPmAnIfEsT000ExAmP1" }], + }, + ], ["no path", without("path")], [ "a path leaving the app's directory", diff --git a/src/validate-schema.test.ts b/src/validate-schema.test.ts index 87dee16..e6e5b4a 100644 --- a/src/validate-schema.test.ts +++ b/src/validate-schema.test.ts @@ -57,15 +57,20 @@ describe("validateSchema", () => { }); it.each([ - ["a data app's resources", ["data_apps", "shop", "resources", "cards"]], - ["actions", ["actions"]], - ])("validates %s", (_, dirs) => { + [ + "a data app's resources", + ["data_apps", "shop", "resources", "cards"], + "broken.yaml", + ], + ["a data app's manifest", ["data_apps", "shop"], "data_app.yaml"], + ["actions", ["actions"], "broken.yaml"], + ])("validates %s", (_, dirs, file) => { mkdirSync(join(workdir, ...dirs), { recursive: true }); - writeFileSync(join(workdir, ...dirs, "broken.yaml"), "name: Broken\n"); + writeFileSync(join(workdir, ...dirs, file), "name: Broken\n"); const { failed, results } = validateSchema({ folder: workdir }); expect(failed).toBe(1); - expect(results[0].file).toBe(join(...dirs, "broken.yaml")); + expect(results[0].file).toBe(join(...dirs, file)); }); describe("actions", () => { diff --git a/src/validate-schema.ts b/src/validate-schema.ts index ceec75d..5a4f841 100644 --- a/src/validate-schema.ts +++ b/src/validate-schema.ts @@ -14,6 +14,7 @@ const IMPORT_PATHS = [ "actions/**/*.yaml", "channels/**/*.yaml", "collections/**/*.yaml", + "data_apps/*/data_app.yaml", "data_apps/*/resources/**/*.yaml", "databases/**/segments/**/*.yaml", "databases/**/measures/**/*.yaml",