From 8d9ef9e72c286727b473e2549488e04b35c6e82f Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Mon, 5 Oct 2026 11:04:54 -0400 Subject: [PATCH 1/2] Actions: model_id optional and deprecated, collection_id, no HTTP Query actions live in a collection like cards and need no model; model_id and implicit actions are deprecated, HTTP actions are removed, and the examples sit in the collection tree where Metabase now exports actions. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- core-spec/v1/schemas/action.yaml | 59 ++++--- core-spec/v1/spec.md | 158 +++++++++--------- .../main/queries}/apply_discount.yaml | 3 +- .../main/queries}/create_order.yaml | 2 +- .../resources/actions/update_order.yaml | 45 ++++- .../resources/cards/orders_model.yaml | 20 --- src/validate-schema.test.ts | 28 +++- 8 files changed, 180 insertions(+), 137 deletions(-) rename examples/v1/{actions => collections/main/queries}/apply_discount.yaml (96%) rename examples/v1/{actions => collections/main/queries}/create_order.yaml (92%) delete mode 100644 examples/v1/data_apps/order-desk/resources/cards/orders_model.yaml diff --git a/README.md b/README.md index 942aa3d..bbff37d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository contains the specification and examples for the Metabase Represe |--------|-------------| | Collection | Folder-like container for organizing content | | Card | Question, model, or metric — holds an MBQL or native query | -| Action | Writes to a database through a model card — implicit (row create/update/delete), query (native SQL), or HTTP | +| Action | A parameterized native SQL write in a collection; implicit row actions on a model are deprecated | | Dashboard | Grid layout of cards with filter parameters and tabs | | Segment | Saved filter definition scoped to a table | | Measure | Saved aggregation definition scoped to a table | diff --git a/core-spec/v1/schemas/action.yaml b/core-spec/v1/schemas/action.yaml index e2c38c8..dac2c51 100644 --- a/core-spec/v1/schemas/action.yaml +++ b/core-spec/v1/schemas/action.yaml @@ -1,16 +1,16 @@ $schema: https://json-schema.org/draft/2020-12/schema title: Action description: > - An action that writes to a database through a model card: an implicit action - creates, updates, or deletes the model's rows; a query action runs SQL; an - HTTP action calls a URL. Exactly one of `implicit`, `query`, and `http` holds - the action's definition, the one its `type` names. + An action that writes to a database. A query action runs parameterized SQL and + lives in a collection like a card. An implicit action creates, updates, or + deletes a row of a model card's table; it is deprecated, as is attaching any + action to a model. Exactly one of `implicit` and `query` holds the action's + definition, the one its `type` names. type: object required: - name - entity_id - type - - model_id - serdes/meta properties: name: @@ -20,14 +20,26 @@ properties: $ref: "common/id.yaml#/$defs/entity_id" type: type: string - description: Which of `implicit`, `query`, or `http` defines the action + description: Which of `implicit` (deprecated) or `query` defines the action enum: - implicit - query - - http + collection_id: + description: > + Collection FK (entity_id); null for the root collection. An action with a + model is always in its model's collection. + type: [string, "null"] + if: { type: string } + then: { $ref: "common/id.yaml#/$defs/entity_id" } model_id: - description: Card FK (entity_id) of the model the action belongs to - $ref: "common/id.yaml#/$defs/entity_id" + description: > + Deprecated. Card FK (entity_id) of the model the action is attached to, + kept only for actions made before actions had their own collection; an + implicit action needs one. Omit it, or leave it null, for a query action. + deprecated: true + type: [string, "null"] + if: { type: string } + then: { $ref: "common/id.yaml#/$defs/entity_id" } creator_id: description: User FK (email) type: [string, "null"] @@ -40,9 +52,13 @@ properties: type: boolean description: Whether archived default: false + archived_directly: + type: boolean + description: Whether the action itself was archived, rather than archived with its collection or model + default: false implicit: type: array - description: The implicit action's definition, when `type` is `implicit`; otherwise empty + description: The implicit action's definition, when `type` is `implicit` (deprecated); otherwise empty maxItems: 1 items: type: object @@ -72,23 +88,6 @@ properties: dataset_query: description: The native query the action runs, with its template tags $ref: "common/query.yaml" - http: - type: array - description: The HTTP action's definition, when `type` is `http`; otherwise empty - maxItems: 1 - items: - type: object - properties: - template: - type: object - description: The request to send - additionalProperties: true - response_handle: - type: [string, "null"] - description: JSON query applied to the response - error_handle: - type: [string, "null"] - description: JSON query that extracts an error from the response parameters: type: array description: > @@ -193,9 +192,9 @@ properties: const: Action allOf: - if: { properties: { type: { const: implicit } }, required: [type] } - then: { required: [implicit], properties: { implicit: { type: array, minItems: 1 } } } + then: + required: [implicit, model_id] + properties: { implicit: { type: array, minItems: 1 }, model_id: { type: string } } - if: { properties: { type: { const: query } }, required: [type] } then: { required: [query], properties: { query: { type: array, minItems: 1 } } } - - if: { properties: { type: { const: http } }, required: [type] } - then: { required: [http], properties: { http: { type: array, minItems: 1 } } } additionalProperties: true diff --git a/core-spec/v1/spec.md b/core-spec/v1/spec.md index 4c0cf7b..904adde 100644 --- a/core-spec/v1/spec.md +++ b/core-spec/v1/spec.md @@ -94,7 +94,7 @@ Collections are organized by namespace. The `main` namespace holds regular conte ``` export-root/ ├── settings.yaml -├── actions/ # Actions, each on a model card +├── actions/ # Read on import only: older exports kept actions here │ └── {slug}.yaml ├── data_apps/ # Data apps (see Data App) │ └── {app_slug}/ @@ -106,7 +106,7 @@ export-root/ │ │ ├── {slug}.yaml # Entities in root collection │ │ ├── {collection_slug}.yaml # Collection definition (sibling of its folder) │ │ └── {collection_slug}/ # Collection contents -│ │ ├── {card_slug}.yaml # Cards, dashboards, documents, etc. +│ │ ├── {card_slug}.yaml # Cards, dashboards, documents, actions, etc. │ │ ├── {dashboard_slug}.yaml # — all flat in the same folder │ │ ├── {child_slug}.yaml # Child collection definition │ │ └── {child_slug}/ # Child collection contents @@ -2317,9 +2317,9 @@ serdes/meta: ## Action -An action writes to a database through a model card. An **implicit** action creates, updates, or deletes a row of the model's table; a **query** action runs native SQL; an **HTTP** action calls a URL. Its permissions follow its model, so an action is always attached to one (`model_id`). +An action writes to a database. A **query** action runs parameterized native SQL; it lives in a collection like a card (`collection_id`), and its permissions follow that collection. An **implicit** action creates, updates, or deletes a row of a model card's table. Implicit actions, and attaching any action to a model (`model_id`), are **deprecated**: they remain only for actions made before actions had their own collection. Author query actions without a `model_id`. -Actions are stored under the top-level `actions/` directory. Exactly one of the nested `implicit`, `query`, and `http` arrays holds the action's definition, the one its `type` names; the other two are empty. +Actions are exported with their collection, like cards: `collections/main/{collection path}/{slug}.yaml`, or `collections/main/{slug}.yaml` in the root collection. Older exports kept them under a top-level `actions/` directory, which import still reads. Exactly one of the nested `implicit` and `query` arrays holds the action's definition, the one its `type` names; the other is empty. ### Schema @@ -2327,18 +2327,19 @@ Actions are stored under the top-level `actions/` directory. Exactly one of the |-------|------|----------|-------------| | `name` | string | Yes | Action name | | `entity_id` | string | Yes | NanoID identifier | -| `type` | string | Yes | `"implicit"`, `"query"`, or `"http"` | -| `model_id` | string | Yes | Card FK (entity_id) of the model the action belongs to | +| `type` | string | Yes | `"query"`, or `"implicit"` (deprecated) | | `serdes/meta` | array | Yes | Identity path with `model: Action` | -| `implicit` | array | No | `[{kind}]` when `type` is `implicit`, otherwise `[]`. `kind`: `row/create`, `row/update`, `row/delete`, `bulk/create`, `bulk/update`, `bulk/delete` | +| `collection_id` | string | No | Collection FK (entity_id); `null` for the root collection. An action with a model is always in its model's collection | +| `model_id` | string | No | **Deprecated.** Card FK (entity_id) of the model the action is attached to. Omit it, or leave it `null`, for a query action; an implicit action requires one | | `query` | array | No | `[{database_id, dataset_query}]` when `type` is `query`, otherwise `[]`. `database_id` is a Database FK; `dataset_query` is a [Native Query](#native-query) whose template tags the parameters target | -| `http` | array | No | `[{template, response_handle, error_handle}]` when `type` is `http`, otherwise `[]` | +| `implicit` | array | No | `[{kind}]` when `type` is `implicit`, otherwise `[]`. `kind`: `row/create`, `row/update`, `row/delete`, `bulk/create`, `bulk/update`, `bulk/delete` | | `parameters` | array | No | The action's inputs: `id`, `type`, and for a query action a `target` on one of its template tags (see [Parameter Targets](#parameter-targets)), plus optional `name`, `slug`, `required`. An implicit action's parameters are computed from the model's fields, so they are stored empty `[]` | | `parameter_mappings` | array | No | Unused, usually empty `[]` | | `visualization_settings` | map | No | The action's form (see below) | | `creator_id` | string | No | User FK (email) | | `description` | string | No | Description | | `archived` | boolean | No | Whether archived (default: `false`) | +| `archived_directly` | boolean | No | Archived directly vs. inherited | | `public_uuid` | string | No | Public sharing UUID | | `made_public_by_id` | string | No | User FK (email) | | `created_at` | string | No | ISO 8601 timestamp | @@ -2356,42 +2357,14 @@ The rest (`title`, `description`, `placeholder`, `order`, `required`, `inputType ### Example -**Implicit action:** - -```yaml -name: Create order -entity_id: XgqYIEGPfvCklZUxOLp9m -creator_id: admin@example.com -type: implicit -model_id: 4eroqa4ZYl4WkNjP8XTvu -implicit: -- kind: row/create -query: [] -http: [] -parameters: [] -parameter_mappings: [] -visualization_settings: - fields: - DISCOUNT: - id: DISCOUNT - hidden: true - QUANTITY: - id: QUANTITY - defaultValue: 1 -serdes/meta: -- id: XgqYIEGPfvCklZUxOLp9m - label: create_order - model: Action -``` - -**Query action:** +**Query action** (`collections/main/queries/apply_discount.yaml`): ```yaml name: Apply discount entity_id: NJksetfXk2oh530o3RD_5 creator_id: admin@example.com type: query -model_id: 4eroqa4ZYl4WkNjP8XTvu +collection_id: cOlQuErIeS0ExAmPlE2x1 implicit: [] query: - database_id: Sample Database @@ -2412,7 +2385,6 @@ query: name: order_id id: 7d2f1c3e-9b8a-4e6d-8c5b-1a2b3c4d5e6f display-name: Order ID -http: [] parameters: - id: discount name: Discount @@ -2438,6 +2410,34 @@ serdes/meta: model: Action ``` +**Implicit action** (deprecated; `collections/main/queries/create_order.yaml`): + +```yaml +name: Create order +entity_id: XgqYIEGPfvCklZUxOLp9m +creator_id: admin@example.com +type: implicit +collection_id: cOlQuErIeS0ExAmPlE2x1 +model_id: 4eroqa4ZYl4WkNjP8XTvu +implicit: +- kind: row/create +query: [] +parameters: [] +parameter_mappings: [] +visualization_settings: + fields: + DISCOUNT: + id: DISCOUNT + hidden: true + QUANTITY: + id: QUANTITY + defaultValue: 1 +serdes/meta: +- id: XgqYIEGPfvCklZUxOLp9m + label: create_order + model: Action +``` + --- ## Dashboard @@ -3102,9 +3102,9 @@ data_apps/ └── resources/ ├── collection.yaml # The app's collection ├── cards/ - │ └── {slug}.yaml # Saved questions, and copies of the models and metrics they use + │ └── {slug}.yaml # Saved questions, and copies of the metrics they use └── actions/ - └── {slug}.yaml # Copies of the actions the app runs, on the copies of their models + └── {slug}.yaml # Copies of the query actions the app runs ``` `{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. @@ -3129,12 +3129,12 @@ Files under `resources/` are recognized by their location: `collection.yaml`, `c ### 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. 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: +The app's resources are what it reads and runs: a saved question for each query it makes, and copies of the metrics and query 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. 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, 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. +- **Actions** have `collection_id` set to the app's collection and name their `creator_id`; are `query` actions without a `model_id` (a deprecated `implicit` action, or an action with a `model_id`, still loads when its model 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. - **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. 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. @@ -3196,44 +3196,52 @@ serdes/meta: model: Card ``` -**`resources/cards/orders_model.yaml`** — the app's copy of the model its action runs on: - -```yaml -name: Orders model -entity_id: dApPmOdElCoPy0ExAmP12 -collection_id: dApPcOlLeCtIoN0ExAmP1 -creator_id: admin@example.com -type: model -display: table -dataset_query: - "lib/type": mbql/query - database: Sample Database - stages: - - "lib/type": mbql.stage/mbql - source-table: - - Sample Database - - PUBLIC - - ORDERS -visualization_settings: {} -serdes/meta: -- id: dApPmOdElCoPy0ExAmP12 - label: orders_model - model: Card -``` - -**`resources/actions/update_order.yaml`** — the app's copy of an action, on that model copy: +**`resources/actions/update_order.yaml`** — the app's copy of a query action it runs: ```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: [] -parameters: [] +type: query +implicit: [] +query: +- database_id: Sample Database + dataset_query: + "lib/type": mbql/query + database: Sample Database + stages: + - "lib/type": mbql.stage/native + native: "UPDATE ORDERS SET DISCOUNT = {{discount}} WHERE ID = {{order_id}}" + template-tags: + discount: + type: number + name: discount + id: 3c1e6f0a-2b4d-4e8a-9f1c-5d6e7f8a9b0c + display-name: Discount + order_id: + type: number + name: order_id + id: 8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d + display-name: Order ID +parameters: +- id: discount + name: Discount + slug: discount + type: number/= + target: + - variable + - - template-tag + - discount +- id: order_id + name: Order ID + slug: order_id + type: number/= + required: true + target: + - variable + - - template-tag + - order_id parameter_mappings: [] serdes/meta: - id: dApPaCtIoNcOpYExAmP12 diff --git a/examples/v1/actions/apply_discount.yaml b/examples/v1/collections/main/queries/apply_discount.yaml similarity index 96% rename from examples/v1/actions/apply_discount.yaml rename to examples/v1/collections/main/queries/apply_discount.yaml index 82a1b57..e6b0f22 100644 --- a/examples/v1/actions/apply_discount.yaml +++ b/examples/v1/collections/main/queries/apply_discount.yaml @@ -2,7 +2,7 @@ name: Apply discount entity_id: NJksetfXk2oh530o3RD_5 creator_id: admin@example.com type: query -model_id: 4eroqa4ZYl4WkNjP8XTvu +collection_id: cOlQuErIeS0ExAmPlE2x1 implicit: [] query: - database_id: Sample Database @@ -23,7 +23,6 @@ query: name: order_id id: 7d2f1c3e-9b8a-4e6d-8c5b-1a2b3c4d5e6f display-name: Order ID -http: [] parameters: - id: discount name: Discount diff --git a/examples/v1/actions/create_order.yaml b/examples/v1/collections/main/queries/create_order.yaml similarity index 92% rename from examples/v1/actions/create_order.yaml rename to examples/v1/collections/main/queries/create_order.yaml index 9974452..ed33e52 100644 --- a/examples/v1/actions/create_order.yaml +++ b/examples/v1/collections/main/queries/create_order.yaml @@ -2,11 +2,11 @@ name: Create order entity_id: XgqYIEGPfvCklZUxOLp9m creator_id: admin@example.com type: implicit +collection_id: cOlQuErIeS0ExAmPlE2x1 model_id: 4eroqa4ZYl4WkNjP8XTvu implicit: - kind: row/create query: [] -http: [] parameters: [] parameter_mappings: [] visualization_settings: 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 1f1d4e0..c26136f 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 @@ -2,12 +2,45 @@ name: Update order entity_id: dApPaCtIoNcOpYExAmP12 collection_id: dApPcOlLeCtIoN0ExAmP1 creator_id: admin@example.com -type: implicit -model_id: dApPmOdElCoPy0ExAmP12 -implicit: -- kind: row/update -query: [] -parameters: [] +type: query +implicit: [] +query: +- database_id: Sample Database + dataset_query: + "lib/type": mbql/query + database: Sample Database + stages: + - "lib/type": mbql.stage/native + native: "UPDATE ORDERS SET DISCOUNT = {{discount}} WHERE ID = {{order_id}}" + template-tags: + discount: + type: number + name: discount + id: 3c1e6f0a-2b4d-4e8a-9f1c-5d6e7f8a9b0c + display-name: Discount + order_id: + type: number + name: order_id + id: 8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d + display-name: Order ID +parameters: +- id: discount + name: Discount + slug: discount + type: number/= + target: + - variable + - - template-tag + - discount +- id: order_id + name: Order ID + slug: order_id + type: number/= + required: true + target: + - variable + - - template-tag + - order_id parameter_mappings: [] serdes/meta: - id: dApPaCtIoNcOpYExAmP12 diff --git a/examples/v1/data_apps/order-desk/resources/cards/orders_model.yaml b/examples/v1/data_apps/order-desk/resources/cards/orders_model.yaml deleted file mode 100644 index 59a62e5..0000000 --- a/examples/v1/data_apps/order-desk/resources/cards/orders_model.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: Orders model -entity_id: dApPmOdElCoPy0ExAmP12 -collection_id: dApPcOlLeCtIoN0ExAmP1 -creator_id: admin@example.com -type: model -display: table -dataset_query: - "lib/type": mbql/query - database: Sample Database - stages: - - "lib/type": mbql.stage/mbql - source-table: - - Sample Database - - null - - ORDERS -visualization_settings: {} -serdes/meta: -- id: dApPmOdElCoPy0ExAmP12 - label: orders_model - model: Card diff --git a/src/validate-schema.test.ts b/src/validate-schema.test.ts index e6e5b4a..1ce5809 100644 --- a/src/validate-schema.test.ts +++ b/src/validate-schema.test.ts @@ -76,7 +76,10 @@ describe("validateSchema", () => { describe("actions", () => { const example = (file: string): Record => yaml.load( - readFileSync(join(EXAMPLES, "actions", file), "utf8"), + readFileSync( + join(EXAMPLES, "collections", "main", "queries", file), + "utf8", + ), ) as Record; // Example actions are YAML maps. const validateAction = (action: Record) => { @@ -91,7 +94,11 @@ describe("validateSchema", () => { it.each([ ["an unknown type", { ...example("create_order.yaml"), type: "magic" }], [ - "no model", + "the removed http type", + { ...example("apply_discount.yaml"), type: "http" }, + ], + [ + "an implicit type but no model", (({ model_id: _modelId, ...action }) => action)( example("create_order.yaml"), ), @@ -110,6 +117,23 @@ describe("validateSchema", () => { ])("rejects an action with %s", (_, action) => { expect(validateAction(action).failed).toBe(1); }); + + it.each([ + ["a query action without a model", example("apply_discount.yaml")], + [ + "a query action with a null model", + { ...example("apply_discount.yaml"), model_id: null }, + ], + [ + "a query action still on a model", + { + ...example("apply_discount.yaml"), + model_id: "4eroqa4ZYl4WkNjP8XTvu", + }, + ], + ])("accepts %s", (_, action) => { + expect(validateAction(action).failed).toBe(0); + }); }); it("fails files with an unknown model", () => { From 87f946e62fe90c453747262151cc494834fbcf0c Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Mon, 5 Oct 2026 12:21:35 -0400 Subject: [PATCH 2/2] Bump version to 1.4.1 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 17e8282..6f9b5ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/representations", - "version": "1.4.0", + "version": "1.4.1", "description": "Metabase representation format specification and schema validator", "license": "SEE LICENSE IN LICENSE.txt", "repository": {