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
5 changes: 4 additions & 1 deletion packages/cli/skill-data/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,11 @@ 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 <id>` and `mb dashboard subscriptions <id>` 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 <id>` (`--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 <id>` (`--archived` to include archived). Archiving a timeline cascades to its events; `delete` is a **hard** delete of both — prefer `archive`.
- **collection `<ref>`** (`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.
<!-- requires: remoteSync -->
- **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`.
<!-- /requires -->
- **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 <key>` 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`.
<!-- requires: transforms -->
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/skill-data/dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --json` hydrates `parameters`, `dashcards`, and `tabs`; `mb dashboard cards <id>` lists just the dashcards. To start from an existing dashboard, `mb dashboard copy <id> --collection-id <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`.

<!-- requires: remoteSync -->

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": <dash-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").

<!-- /requires -->

## 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.
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/skill-data/git-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,32 @@ mb setting set remote-sync-type '"read-write"' --profile <n>

**Verifying the result.** `mb git-sync status --profile <n> --json` lists the flagged collections under `synced_collections`, and `mb collection get <id> --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 <id> --body '{"collection_id":<synced-id>}'`, then `dashboard update <id> --body '{"collection_id":<synced-id>}'`.
2. **The whole containing collection.** `mb collection update <id> --parent-id <synced-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 <id>`); otherwise try `mb dashboard get <id>` or `mb snippet get <id>`, and match the name against the content you meant to move.

<!-- requires: dependencyGraph -->

To plan the order before the first move, `mb dependency graph dashboard <id> --json` (or `card <id>`) lists everything the entity reads from; every card and snippet node outside the synced collection moves first.

<!-- /requires -->

<!-- requires: library -->

## Published table metadata (Library) and sync scope
Expand All @@ -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 <n> --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 <id>` / `mb git-sync remove-collection <id>` (above), and remember the toggle cascades to descendants.
2 changes: 2 additions & 0 deletions packages/cli/src/output/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ describe("reportError", () => {
fieldErrors: { name: "value must be a non-blank string." },
specificFieldErrors: null,
errorCode: null,
nonRemoteSyncedDependencies: null,
remoteSyncedDependents: null,
},
},
}) + "\n";
Expand Down
2 changes: 2 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 85 additions & 1 deletion packages/client/src/http/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof RemoteSyncedDependent>;

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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -129,6 +150,8 @@ export class HttpError extends MetabaseError {
fieldErrors: fields.fieldErrors,
specificFieldErrors: fields.specificFieldErrors,
errorCode: fields.errorCode,
nonRemoteSyncedDependencies: fields.nonRemoteSyncedDependencies,
remoteSyncedDependents: fields.remoteSyncedDependents,
};
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<T>(schema: z.ZodType<T>, 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.
Expand All @@ -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) {
Expand All @@ -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>): 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;
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ describe("@metabase/client barrel as a consumer surface", () => {
fieldErrors: null,
specificFieldErrors: null,
errorCode: null,
nonRemoteSyncedDependencies: null,
remoteSyncedDependents: null,
});
});

Expand Down
7 changes: 6 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading