From 9f847e6629000df278aab01a80948ea42f6e0358 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 20:29:17 -0400 Subject: [PATCH 1/2] make it clearer how to use git sync --- packages/cli/skill-data/core/SKILL.md | 3 + packages/cli/skill-data/dashboard/SKILL.md | 6 ++ packages/cli/skill-data/git-sync/SKILL.md | 27 +++++++ packages/cli/src/output/error.test.ts | 2 + packages/client/README.md | 2 + packages/client/src/http/errors.ts | 86 +++++++++++++++++++++- packages/client/src/index.test.ts | 2 + packages/client/src/index.ts | 7 +- 8 files changed, 133 insertions(+), 2 deletions(-) diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 2e20af60..89e874e5 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -144,6 +144,9 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **segment / measure.** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql`): segment = a filter, measure = exactly one aggregation. - **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. To annotate a question's chart, create the timeline in that question's collection, then add events; an event only draws when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline. There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. - **collection ``** (`get`/`items`): positive int, `root`, `trash`, or a 21-char entity_id; `update`/`archive` take the int only. `collection items` pages the server, pulling only what the cap can show — read `has_more`/`next_offset` to continue. `collection tree` is **JSON-only** and hides the Library without `--include-library`. A transform collection needs `collection create --namespace transforms`. `update` keeps `archived` unless `--archived` is given. + +- **remote-synced collections.** Content in a synced collection may only depend on synced content: creating, moving in, or updating a card/dashboard whose sources sit outside sync scope fails with 400 `Uses content that is not remote synced.`, and archiving or moving out something synced content uses fails with `Used by remote synced content.` Build versioned content in the synced collection from the start; to move existing content in, follow "Moving content into or out of a synced collection" in `git-sync`. + - **setting set** parses the value as **strict JSON**: a string is `'"value"'` (inner quotes), booleans `true`/`false`, numbers bare. Wrong quoting silently errors — confirm with `setting get ` after. `setting get --json` works on every value type (wrapping bare-text responses into `{key, value}`). - **search vs. list.** Enumerate with `… list` verbs; `search` ranks a query or spans resources. `--search-native-query` matches native query text and keeps only query-bearing models; dashboard questions need `--include-dashboard-questions`. diff --git a/packages/cli/skill-data/dashboard/SKILL.md b/packages/cli/skill-data/dashboard/SKILL.md index 39f547f5..cfb217ad 100644 --- a/packages/cli/skill-data/dashboard/SKILL.md +++ b/packages/cli/skill-data/dashboard/SKILL.md @@ -12,6 +12,12 @@ A dashboard starts as cards on a grid; it becomes an **app** when filters drive Inspect before you wire: `mb dashboard get --json` hydrates `parameters`, `dashcards`, and `tabs`; `mb dashboard cards ` lists just the dashcards. To start from an existing dashboard, `mb dashboard copy --collection-id --json` copies it with its tabs and dashcards (the cards stay referenced; `--deep` duplicates the questions and metrics into the target collection, and a dashboard holding dashboard questions needs it). Cards left out of the copy come back by id as `uncopied`. + + +A dashboard bound for a remote-synced collection is built in it directly: create the dashboard there, then its cards. A card only this dashboard shows is best a **dashboard question** — `card create` with `"dashboard_id": ` and the dashboard's `collection_id` — so the dashboard and its questions move as one unit. A card shared across dashboards is a regular card and moves into the synced collection before any dashboard that uses it (`git-sync` → "Moving content into or out of a synced collection"). + + + ## Layout: the grid is 24 columns — not 12 Every dashcard carries `{col, row, size_x, size_y}` in grid units: `col` is 0-indexed from the left edge, `row` grows downward, and `col + size_x ≤ 24`. **Full-width is `size_x: 24` — Metabase's per-chart _default_ width of 12 is half a row.** A layout authored on the usual 12-column web-grid assumption crams the whole dashboard into the left half of the viewport. The server stores whatever geometry you send — overlaps and gaps included, no auto-fix. diff --git a/packages/cli/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md index 9f16094b..b9359961 100644 --- a/packages/cli/skill-data/git-sync/SKILL.md +++ b/packages/cli/skill-data/git-sync/SKILL.md @@ -183,6 +183,32 @@ mb setting set remote-sync-type '"read-write"' --profile **Verifying the result.** `mb git-sync status --profile --json` lists the flagged collections under `synced_collections`, and `mb collection get --json` shows the per-collection `is_remote_synced` flag. +## Moving content into or out of a synced collection + +When remote sync is configured and the content is meant to be versioned, create it in the synced collection from the start. Nothing reaches git until `git-sync export`, and "Clean up before exporting" covers the drafts. + +Synced content may only depend on synced content. Creating a card, dashboard, document, or collection in a synced collection, moving one in, or updating one inside it walks its dependencies, and the server refuses with 400 `Uses content that is not remote synced.` if any of them sits outside sync scope. What counts as a dependency: + +- **Card:** the cards it is built on (source model, question, or metric), cards referenced as `{{#id}}` in native SQL, the card behind a filter's value source (`values_source_config.card_id`), and snippets — a snippet counts as synced only when the Library is. +- **Dashboard:** every dashcard's card, `series` cards, cards in parameter mappings and parameter value sources, actions, and the cards and dashboards a link card points to. +- Warehouse tables never count: a card on a raw table moves freely. + +Three ways through: + +1. **Bottom-up.** Move the sources first — models, then metrics, then the questions built on them — and the dashboard last. `card update --body '{"collection_id":}'`, then `dashboard update --body '{"collection_id":}'`. +2. **The whole containing collection.** `mb collection update --parent-id ` checks once, after the whole subtree has moved, so references inside the subtree pass. +3. **Dashboard questions.** A card created with `dashboard_id` (and the dashboard's `collection_id`) moves with its dashboard in the same transaction, so it never blocks the move. + +The reverse holds too: archiving something, or moving it out of sync scope, is refused with 400 `Used by remote synced content.` while synced content still depends on it. The error names the dependents by model and id (`Used by remote synced content: Card 12, Dashboard 3. …`); move or archive those first, or leave the dependency in place. + +Reading the inbound refusal: the error lists the blocking content as bare ids with no model type (`Uses content that is not remote synced: ids 412, 77. …`) — these are the dependencies to move first. Most are cards (`mb card get `); otherwise try `mb dashboard get ` or `mb snippet get `, and match the name against the content you meant to move. + + + +To plan the order before the first move, `mb dependency graph dashboard --json` (or `card `) lists everything the entity reads from; every card and snippet node outside the synced collection moves first. + + + ## Published table metadata (Library) and sync scope @@ -208,6 +234,7 @@ Flagging the collection records it for the next export, which serializes its cur - Don't run `git-sync import --force` or `git-sync export --force` without explicit user confirmation. Both are lossy — `--force` import discards instance-side work, `--force` export overwrites the remote branch. - Don't drive `git-sync` against a Metabase instance that doesn't have remote-sync configured — every verb returns an error pointing at the missing `remote-sync-*` settings. To check: `mb setting get remote-sync-url --profile --json`. - Don't author content directly via `card create` / `transform create` and then assume `git-sync export` will commit it cleanly — the instance and repo can drift if you mix direct API writes with sync-tracked changes. If you do, follow direct writes immediately with `git-sync export -m "..."` to keep them in step. +- Don't move a dashboard or question into a synced collection before what it depends on, and don't retry a `Uses content that is not remote synced.` refusal unchanged. Move the listed dependencies first, move the whole containing collection, or build the content in the synced collection to begin with (see "Moving content into or out of a synced collection"). - Don't omit `-m` on `export` if the user wants a meaningful commit message — the default server-generated message is generic. - Don't `git-sync export` to `main`/`master` without explicit user confirmation — sync work is conventionally on a feature branch. See "Branch guard" above. - Don't reach for `mb setting set` to mark a collection as remote-synced — that endpoint writes single-key settings, not the bulk `collections` map. Use `mb git-sync add-collection ` / `mb git-sync remove-collection ` (above), and remember the toggle cascades to descendants. diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index 5c02f3af..304c79d3 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -340,6 +340,8 @@ describe("reportError", () => { fieldErrors: { name: "value must be a non-blank string." }, specificFieldErrors: null, errorCode: null, + nonRemoteSyncedDependencies: null, + remoteSyncedDependents: null, }, }, }) + "\n"; diff --git a/packages/client/README.md b/packages/client/README.md index 61869800..6ae681f8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -247,6 +247,8 @@ tests for a Node `ENOENT`, while an HTTP 404 arrives as an `HttpError` carrying `HttpError` also carries a `kind` separating a route this Metabase does not serve from a row that is gone, and `fieldErrors` for a 400 the server attributed to named request fields. +A 400 refusing a remote-sync move carries the content behind it: `nonRemoteSyncedDependencies` lists the ids of the content outside remote sync that must enter it first, and `remoteSyncedDependents` lists the synced content, as model name to id, that uses what the request tried to archive or move out of sync. Both are `null` on any other failure, and the error's message names the same content. + ### Cancellation The client registers no signal handler and reads no process state, so cancellation reaches it as an diff --git a/packages/client/src/http/errors.ts b/packages/client/src/http/errors.ts index 26997d30..a8370ec6 100644 --- a/packages/client/src/http/errors.ts +++ b/packages/client/src/http/errors.ts @@ -47,9 +47,28 @@ const ErrorEnvelope = z "specific-errors": z.unknown().optional(), errors: z.unknown().optional(), "error-code": z.string().optional(), + "non-remote-synced-models": z.unknown().optional(), + "remote-synced-models": z.unknown().optional(), }) .loose(); +// The server names the content blocking a move into remote sync by bare id, without its model. +const NonRemoteSyncedDependencies = z.array(z.number().int()).min(1); + +// Model name to id. A dependent reached through a dashboard card names the card and its dashboard +// in one entry (`{"DashboardCard": 7, "Dashboard": 3}`). +const RemoteSyncedDependent = z.record(z.string(), z.number().int()); +export type RemoteSyncedDependent = z.infer; + +const RemoteSyncedDependents = z.array(RemoteSyncedDependent).min(1); + +const MAX_LISTED_REMOTE_SYNC_ENTRIES = 20; +const REMOTE_SYNC_ENTRY_SEPARATOR = ", "; +const NON_REMOTE_SYNCED_REMEDY = + "Move that content into a synced collection first, or move the collection that holds it."; +const REMOTE_SYNCED_REMEDY = + "Update that content so it no longer uses this first, or move it out of sync along with this."; + interface FieldErrorBranch { [field: string]: FieldErrorNode; } @@ -88,6 +107,8 @@ export interface HttpErrorDetail { fieldErrors: FieldErrors | null; specificFieldErrors: FieldErrors | null; errorCode: string | null; + nonRemoteSyncedDependencies: number[] | null; + remoteSyncedDependents: RemoteSyncedDependent[] | null; } export interface HttpErrorInput { @@ -129,6 +150,8 @@ export class HttpError extends MetabaseError { fieldErrors: fields.fieldErrors, specificFieldErrors: fields.specificFieldErrors, errorCode: fields.errorCode, + nonRemoteSyncedDependencies: fields.nonRemoteSyncedDependencies, + remoteSyncedDependents: fields.remoteSyncedDependents, }; } @@ -151,6 +174,17 @@ export class HttpError extends MetabaseError { get errorCode(): string | null { return this.developerDetail.errorCode; } + + // Ids of the cards, snippets, and actions that must enter remote sync before the content this + // request moved or created into a synced collection can. + get nonRemoteSyncedDependencies(): number[] | null { + return this.developerDetail.nonRemoteSyncedDependencies; + } + + // The synced content that uses what this request tried to archive or move out of remote sync. + get remoteSyncedDependents(): RemoteSyncedDependent[] | null { + return this.developerDetail.remoteSyncedDependents; + } } // Answers the status alone. `HttpError.kind` splits the same 404 into `route-missing` (this @@ -314,12 +348,16 @@ interface EnvelopeViews { fieldErrors: FieldErrors | null; specificFieldErrors: FieldErrors | null; errorCode: string | null; + nonRemoteSyncedDependencies: number[] | null; + remoteSyncedDependents: RemoteSyncedDependent[] | null; } const NO_ENVELOPE_VIEWS: EnvelopeViews = { fieldErrors: null, specificFieldErrors: null, errorCode: null, + nonRemoteSyncedDependencies: null, + remoteSyncedDependents: null, }; // Read off the sanitized body rather than the raw one, so a secret quoted back inside a field @@ -333,9 +371,20 @@ function extractEnvelopeViews(sanitizedBody: string | null): EnvelopeViews { fieldErrors: parseFieldErrors(envelope.errors), specificFieldErrors: parseFieldErrors(envelope["specific-errors"]), errorCode: envelope["error-code"] ?? null, + nonRemoteSyncedDependencies: parseOrNull( + NonRemoteSyncedDependencies, + envelope["non-remote-synced-models"], + ), + remoteSyncedDependents: parseOrNull(RemoteSyncedDependents, envelope["remote-synced-models"]), }; } +// Like the field errors, an unrecognised value costs only its own structured view. +function parseOrNull(schema: z.ZodType, value: unknown): T | null { + const parsed = schema.safeParse(value); + return parsed.success ? parsed.data : null; +} + // An HTTP failure is already the error path, so a value the field-error shape does not recognise // costs the caller the structured view and nothing else — the status and message it came for // survive untouched. @@ -358,7 +407,7 @@ function parseEnvelopeMessage(sanitizedBody: string | null): string | null { } const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"]; if (topLevel) { - return condenseMessage(topLevel); + return appendRemoteSyncConflict(condenseMessage(topLevel), envelope); } const viaMessage = envelope.via?.find((entry) => entry.message)?.message; if (viaMessage) { @@ -375,6 +424,41 @@ function parseEnvelopeMessage(sanitizedBody: string | null): string | null { return null; } +// The server's sentence says a remote-sync move was refused but not over which content, which the +// envelope carries alongside it. Appended after condensing so the list and remedy are never cut. +function appendRemoteSyncConflict(message: string, envelope: ErrorEnvelope): string { + const dependencies = parseOrNull( + NonRemoteSyncedDependencies, + envelope["non-remote-synced-models"], + ); + if (dependencies !== null) { + const ids = listRemoteSyncEntries(dependencies.map(String)); + return `${withoutTrailingPeriod(message)}: ids ${ids}. ${NON_REMOTE_SYNCED_REMEDY}`; + } + const dependents = parseOrNull(RemoteSyncedDependents, envelope["remote-synced-models"]); + if (dependents !== null) { + const named = listRemoteSyncEntries([...new Set(dependents.map(formatRemoteSyncedDependent))]); + return `${withoutTrailingPeriod(message)}: ${named}. ${REMOTE_SYNCED_REMEDY}`; + } + return message; +} + +function formatRemoteSyncedDependent(dependent: RemoteSyncedDependent): string { + return Object.entries(dependent) + .map(([model, id]) => `${model} ${id}`) + .join(" / "); +} + +function listRemoteSyncEntries(entries: ReadonlyArray): string { + const listed = entries.slice(0, MAX_LISTED_REMOTE_SYNC_ENTRIES).join(REMOTE_SYNC_ENTRY_SEPARATOR); + const unlisted = entries.length - MAX_LISTED_REMOTE_SYNC_ENTRIES; + return unlisted > 0 ? `${listed}${REMOTE_SYNC_ENTRY_SEPARATOR}and ${unlisted} more` : listed; +} + +function withoutTrailingPeriod(message: string): string { + return message.endsWith(".") ? message.slice(0, -1) : message; +} + interface LeafEntry { path: string; message: string; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 608146fe..516f11ea 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -285,6 +285,8 @@ describe("@metabase/client barrel as a consumer surface", () => { fieldErrors: null, specificFieldErrors: null, errorCode: null, + nonRemoteSyncedDependencies: null, + remoteSyncedDependents: null, }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 36be6859..c57f463b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,7 +15,12 @@ export type { export type { ListResult } from "./list"; export { HttpError, isHttpNotFound } from "./http/errors"; -export type { FieldErrors, HttpErrorDetail, HttpErrorKind } from "./http/errors"; +export type { + FieldErrors, + HttpErrorDetail, + HttpErrorKind, + RemoteSyncedDependent, +} from "./http/errors"; export { AbortError, ChainedRequestError, From 2120d8120231665903c7439feb6abbb0386ad232 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 21:07:56 -0400 Subject: [PATCH 2/2] fix --- packages/cli/skill-data/core/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 89e874e5..552bb9ef 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -142,10 +142,10 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **alert / subscription are two unrelated systems.** `alert` watches one **card** and fires on a send condition (`/api/notification`: a cron string, `channel/email`-prefixed handlers, typed recipients); `subscription` delivers one **dashboard** on a schedule (`/api/pulse`: structured `schedule_type` + hour/day/frame, bare `email` channels, `{id}|{email}` recipients). The bodies are not interchangeable. Both silently deliver nowhere if the server has no SMTP / Slack app — check `mb setting get 'email-configured?'` (quote it; the `?` is a shell glob) before creating either. Their list-valued fields (`handlers`/`subscriptions`, `channels`/`cards`) **replace wholesale** on update, so adding one recipient is a read-modify-write. `mb card alerts ` and `mb dashboard subscriptions ` list what's already attached to a card/dashboard; `archive` deactivates rather than deletes. Load the `notification` skill before authoring either body. - **snippet `--archived` is a swap, not a union** — list returns _either_ active _or_ archived rows, never both. (Same for `--filter archived` on dashboard/collection.) - **segment / measure.** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql`): segment = a filter, measure = exactly one aggregation. -- **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. To annotate a question's chart, create the timeline in that question's collection, then add events; an event only draws when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline. There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. +- **timeline / timeline-event.** A timeline's events draw only on time-series questions saved in the **same collection** (`collection_id`; null = root) — not sub-collections, never on dashboard cards — and only when the event's `timestamp` falls inside the chart's range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (false = date-only), and `timeline_id` — no default timeline is auto-created. There is no `timeline-event list`; use `timeline events ` (`--archived` to include archived). Archiving a timeline cascades to its events; `delete` is a **hard** delete of both — prefer `archive`. - **collection ``** (`get`/`items`): positive int, `root`, `trash`, or a 21-char entity_id; `update`/`archive` take the int only. `collection items` pages the server, pulling only what the cap can show — read `has_more`/`next_offset` to continue. `collection tree` is **JSON-only** and hides the Library without `--include-library`. A transform collection needs `collection create --namespace transforms`. `update` keeps `archived` unless `--archived` is given. -- **remote-synced collections.** Content in a synced collection may only depend on synced content: creating, moving in, or updating a card/dashboard whose sources sit outside sync scope fails with 400 `Uses content that is not remote synced.`, and archiving or moving out something synced content uses fails with `Used by remote synced content.` Build versioned content in the synced collection from the start; to move existing content in, follow "Moving content into or out of a synced collection" in `git-sync`. +- **remote-synced collections.** Synced content may depend only on synced content: a write that breaks this fails with 400 `Uses content that is not remote synced.` (a source outside sync scope) or `Used by remote synced content.` (archiving or moving out a dependency). Build versioned content inside the synced collection; to move existing content in, see `git-sync`. - **setting set** parses the value as **strict JSON**: a string is `'"value"'` (inner quotes), booleans `true`/`false`, numbers bare. Wrong quoting silently errors — confirm with `setting get ` after. `setting get --json` works on every value type (wrapping bare-text responses into `{key, value}`). - **search vs. list.** Enumerate with `… list` verbs; `search` ranks a query or spans resources. `--search-native-query` matches native query text and keeps only query-bearing models; dashboard questions need `--include-dashboard-questions`.