Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/import.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 35 additions & 3 deletions core-spec/v1/schemas/data_app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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/<slug>/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: >
Expand Down Expand Up @@ -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
33 changes: 21 additions & 12 deletions core-spec/v1/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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/<slug>/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.

Expand Down Expand Up @@ -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

Expand All @@ -3107,47 +3107,56 @@ 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: <entity_id>, label: <slug>}]` |

### 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

**`data_apps/order-desk/data_app.yaml`:**

```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`:**
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions examples/v1/data_apps/order-desk/data_app.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
23 changes: 23 additions & 0 deletions src/data-app-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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" }],
},
],
[
Expand All @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/validate-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/validate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading