From 3eba6f0b5ee2b476edde8083c4a69c22e451065a Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 18:04:21 -0400 Subject: [PATCH 01/11] rely on server state to guard features --- .claude/skills/add-e2e-test/SKILL.md | 2 +- .claude/skills/add-resource-command/SKILL.md | 13 +- .claude/skills/review/SKILL.md | 2 +- CLAUDE.md | 6 +- README.md | 55 +- docs/architecture.md | 16 +- packages/cli/skill-data/git-sync/SKILL.md | 20 +- packages/cli/src/commands/context.test.ts | 3 - packages/cli/src/commands/context.ts | 2 - .../cli/src/commands/delete-runtime.test.ts | 1 - packages/cli/src/commands/flags.ts | 5 - packages/cli/src/commands/runtime.test.ts | 555 ++++++------------ packages/cli/src/commands/runtime.ts | 121 ++-- packages/cli/src/commands/search.ts | 3 +- packages/cli/src/commands/skills/get.test.ts | 5 +- packages/cli/src/commands/skills/list.test.ts | 10 +- .../cli/src/core/auth/cached-server.test.ts | 6 +- packages/cli/src/core/auth/cached-server.ts | 3 +- .../cli/src/core/auth/server-summary.test.ts | 2 +- packages/cli/src/core/auth/server-summary.ts | 7 +- .../cli/src/core/auth/temp-config-home.ts | 11 +- packages/cli/src/core/auth/verify.test.ts | 6 +- packages/cli/src/core/config.ts | 8 +- packages/cli/src/core/env.ts | 1 - .../cli/src/core/profile-refreshed-error.ts | 2 +- packages/cli/src/core/skills.test.ts | 2 +- packages/cli/src/output/help.test.ts | 3 - packages/cli/src/output/skill-list.test.ts | 2 + packages/cli/src/output/skill-list.ts | 12 +- packages/client/README.md | 12 +- packages/client/src/client.ts | 1 + packages/client/src/domain/dataset.ts | 20 +- packages/client/src/domain/table.ts | 12 +- packages/client/src/http/oauth.ts | 11 +- packages/client/src/http/transport.test.ts | 394 ++++++++++--- packages/client/src/http/transport.ts | 201 +++++-- packages/client/src/paginate.test.ts | 36 +- packages/client/src/paginate.ts | 21 +- packages/client/src/resources/card.ts | 8 - packages/client/src/resources/collection.ts | 7 - .../src/resources/content-translation.ts | 10 +- packages/client/src/resources/dashboard.ts | 10 - packages/client/src/resources/database.ts | 6 - packages/client/src/resources/dataset.ts | 9 +- .../client/src/resources/dependency.test.ts | 30 +- packages/client/src/resources/dependency.ts | 24 +- .../client/src/resources/document.test.ts | 22 +- packages/client/src/resources/document.ts | 11 +- .../client/src/resources/eid-translation.ts | 1 - packages/client/src/resources/erd.test.ts | 41 +- packages/client/src/resources/erd.ts | 6 +- packages/client/src/resources/field.test.ts | 37 +- packages/client/src/resources/field.ts | 23 +- .../client/src/resources/git-sync.test.ts | 261 +++++++- packages/client/src/resources/git-sync.ts | 138 +++-- packages/client/src/resources/glossary.ts | 4 - packages/client/src/resources/library.ts | 16 +- packages/client/src/resources/measure.ts | 16 +- packages/client/src/resources/metric.test.ts | 34 +- packages/client/src/resources/metric.ts | 12 +- .../src/resources/moderation-review.test.ts | 22 +- .../client/src/resources/moderation-review.ts | 6 +- packages/client/src/resources/notification.ts | 6 - packages/client/src/resources/permission.ts | 3 - packages/client/src/resources/pulse.test.ts | 91 ++- packages/client/src/resources/pulse.ts | 29 +- .../client/src/resources/replacement.test.ts | 28 +- packages/client/src/resources/replacement.ts | 21 +- .../client/src/resources/revision.test.ts | 49 +- packages/client/src/resources/revision.ts | 16 +- packages/client/src/resources/search.test.ts | 65 +- packages/client/src/resources/search.ts | 8 +- packages/client/src/resources/segment.ts | 5 - packages/client/src/resources/setting.ts | 3 - packages/client/src/resources/setup.ts | 1 - packages/client/src/resources/snippet.ts | 5 - packages/client/src/resources/table.test.ts | 168 +++++- packages/client/src/resources/table.ts | 87 +-- .../client/src/resources/timeline-event.ts | 5 - packages/client/src/resources/timeline.ts | 7 - .../src/resources/transform-dag-run.test.ts | 15 +- .../client/src/resources/transform-dag-run.ts | 10 +- .../src/resources/transform-inspector.test.ts | 28 +- .../src/resources/transform-inspector.ts | 12 +- .../client/src/resources/transform-job.ts | 22 +- .../src/resources/transform-python.test.ts | 30 +- .../client/src/resources/transform-python.ts | 12 +- .../client/src/resources/transform-tag.ts | 14 +- .../src/resources/transform-test.test.ts | 30 +- .../client/src/resources/transform-test.ts | 18 +- .../client/src/resources/transform.test.ts | 21 +- packages/client/src/resources/transform.ts | 51 +- packages/client/src/resources/upload.ts | 1 - packages/client/src/resources/user.ts | 1 - .../client/src/testing/fake-client.test.ts | 15 - packages/client/src/testing/fake-client.ts | 41 +- packages/client/src/testing/fetch-capture.ts | 30 + .../client/src/version/explain-guard.test.ts | 85 +++ packages/client/src/version/features.ts | 6 +- packages/client/src/version/probe.ts | 24 +- packages/client/src/version/refusal.test.ts | 128 ++++ packages/client/src/version/refusal.ts | 70 +++ .../src/version/requirement-check.test.ts | 4 +- .../client/src/version/requirements.test.ts | 450 +------------- packages/client/src/version/requirements.ts | 4 +- tests/e2e/cli-error.ts | 2 +- tests/e2e/dependency.e2e.test.ts | 22 +- tests/e2e/document.e2e.test.ts | 10 +- tests/e2e/field.e2e.test.ts | 28 +- tests/e2e/git-sync.e2e.test.ts | 145 +++-- tests/e2e/oauth.e2e.test.ts | 8 +- tests/e2e/query.e2e.test.ts | 10 +- tests/e2e/search.e2e.test.ts | 36 ++ tests/e2e/seed-profile.ts | 18 +- tests/e2e/server-gate.ts | 22 +- tests/e2e/skills.e2e.test.ts | 11 +- tests/e2e/subscription.e2e.test.ts | 45 +- tests/e2e/table.e2e.test.ts | 64 +- tests/e2e/transform.e2e.test.ts | 19 - tests/e2e/verify.e2e.test.ts | 19 +- tests/e2e/version.e2e.test.ts | 108 ++-- 121 files changed, 2801 insertions(+), 1831 deletions(-) create mode 100644 packages/client/src/version/explain-guard.test.ts create mode 100644 packages/client/src/version/refusal.test.ts create mode 100644 packages/client/src/version/refusal.ts diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md index 49b4319f..3bc67650 100644 --- a/.claude/skills/add-e2e-test/SKILL.md +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -100,7 +100,7 @@ const skipReason = requireServer("transform › transform e2e", ["transforms"]); describe.skipIf(skipReason !== null)("transform e2e", () => { ``` -- If any method the command under test calls needs a feature — look the command's `requires` up in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), or read `help --json`'s `requires.features` — the suite must gate itself on those feature names. `requireServer(lane, features)` feeds the persisted server probe through `createServerProfile` and the production `checkFeatures` — the same check the CLI's preflight and the client's `require()` run — and returns the skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one. +- If any method the command under test calls needs a feature — look the command's `requires` up in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), or read `help --json`'s `requires.features` — the suite must gate itself on those feature names. `requireServer(lane, features)` feeds the persisted server probe through `createServerProfile` and the production `checkFeatures` — the same check the client asks of a fresh probe to explain a refusal — and returns the skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one. A lane that pins how a server _without_ a feature answers gates on `requireServerWithout(lane, features)`, which skips — and reports — on a stack that has them all. - The point is that a lane **passes or skips**, never fails, on a server that cannot satisfy the command. A suite that hits HTTP 402 or 404 because nobody gated it is a broken lane, not a real failure. - Where every stack answers but the answer differs by generation — a drift site whose canonical JSON carries `null` on the servers that cannot report a field, a status code that changed, a run id that is numeric on newer servers — do not gate. Branch with `serverHas("")` and assert the exact outcome each generation produces, so the lane runs everywhere and pins both shapes: diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index bd212be9..7365e0a9 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -28,7 +28,7 @@ For a typical list/get pair on a new resource: 1. `packages/client/src/domain/.ts` — the schema pair (`` + `Compact`). No fixture, no schema-parse unit test. 2. Updated `packages/client/src/index.ts` — re-export every value the domain file exports from the public barrel. -3. `packages/client/src/resources/.ts` — `Resource(transport)`, one method per endpoint, each opening with `await transport.require(".")`. +3. `packages/client/src/resources/.ts` — `Resource(transport)`, one method per endpoint. 4. Updated `packages/client/src/version/requirements.ts` — one `"."` entry per method in `METHOD_REQUIREMENTS`, and a rule in `packages/client/src/version/features.ts` if the route is gated by a major or a token feature. 5. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `: Resource(transport)`. 6. `packages/client/src/resources/.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`. @@ -159,7 +159,6 @@ export function cardResource(transport: Transport) { params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("card.list"); const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, query: { f: params.f, model_id: params.model_id }, @@ -169,13 +168,11 @@ export function cardResource(transport: Transport) { /** Get one card by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.get"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); } /** Create a card — a question, a model, or a metric — from a full card body. */ async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { - await transport.require("card.create"); return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", body: params }); } @@ -185,7 +182,6 @@ export function cardResource(transport: Transport) { params: CardUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("card.update"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options, method: "PUT", @@ -195,7 +191,6 @@ export function cardResource(transport: Transport) { /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.archive"); return update(id, { archived: true }, options); } @@ -219,7 +214,7 @@ The nine conventions every method follows: `ListResult` = `{ data, total }`. 7. A string path parameter always goes through `encodeURIComponent`. 8. Every method carries the endpoint's description as a doc comment. -9. Every method opens with `await transport.require(".")`, keyed exactly as the method is reached on the client, and that key has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`): `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. `requirements.test.ts` fails a key the table lacks, a table key no method requires, and a method requiring a key from another resource's namespace. A method whose wire shape is selected by generation then reads `const { features } = await transport.server()` and parses with the reader from Step 1a. +9. Every method has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), keyed exactly as the method is reached on the client: `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported through `explain("", method)`, where `const explain = explainer(transport, "")` from `packages/client/src/version/refusal.ts` opens the factory (a `Pages` method instead passes `features: methodRequirements(".")` to `paginatePages`), so the server's rejection reads as the feature it lacks; nothing refuses on the table's word. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is named through `explain`'s `parameterFeatures` argument and sent; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire with `await transport.requireFeatures([...], options)`, with a comment saying what the server would do. `requirements.test.ts` fails a client method the table lacks and a table key no method answers to. Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten. @@ -351,7 +346,7 @@ renderList(envelope, collectionItemView, ctx); `collectForOutput` pulls only as far as the output byte budget can display, so an unbounded listing over a large collection costs a page or two rather than a full drain the cap then discards. It sizes each request through the `PageRequest` it hands the source — forward `max` and `pageSize` verbatim, or the budget cannot bound the walk. -Every command declares `requires`: the list of `"."` keys its `run` body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never touches a Metabase server. `packages/cli/src/commands/requires-guard.test.ts` reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The preflight derives the features from `METHOD_REQUIREMENTS`, runs once per command against the profile's cached probe (or one live probe), and refuses with the client's own message and exit code 2; a command whose methods need nothing never preflights. `help --json` reports the derived `requires: { methods, features }`. Nothing on the command names a version. +Every command declares `requires`: the list of `"."` keys its `run` body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never touches a Metabase server. `packages/cli/src/commands/requires-guard.test.ts` reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The CLI runs no check of its own: the client sends each call and explains a refusal by the method's features, with exit code 2. `help --json` reports the features derived from `METHOD_REQUIREMENTS` as `requires: { methods, features }`. Nothing on the command names a version. The `ListEnvelope` export is **mandatory**. It is consumed by JSON help (`--help --json`, via `outputSchema`) and by the matching e2e test (which imports it back to parse `--json` output). Do **not** redeclare the envelope shape inline anywhere. It is the _CLI's_ envelope — `{ data, returned, offset, limit?, total?, has_more, next_offset?, truncated? }`, declared in `packages/cli/src/output/types.ts` — and has nothing to do with the server's wire envelope, which stayed module-private in Step 2. @@ -514,7 +509,7 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, `await transport.require(".")` first in every method. +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and every gated method exported through the resource's `explain` (`explainer(transport, "")`). - [ ] **Requirements** — every method has its `"."` entry in `METHOD_REQUIREMENTS`; a gated route has a behaviour-named rule in `FEATURE_RULES` verified against the Metabase release branches; a shape that differs by generation has its `WireV` + converter + exported reader, with converter unit tests per generation. - [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. - [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index c77ee55e..c076e91a 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -110,7 +110,7 @@ A new or changed method comes with its wire test in `packages/client/src/resourc `packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison or a literal major used as a decision anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. -Every method in `resources/*.ts` opens with `await transport.require(".")` under its own namespace and has a matching `METHOD_REQUIREMENTS` entry, strictest feature first (`requirements.test.ts`). A method without one, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. +Every method in `resources/*.ts` has a matching `METHOD_REQUIREMENTS` entry under its own namespace, strictest feature first (`requirements.test.ts`). A gated method not exported through the resource's `explain` (`explainer(transport, "")`) is FAIL. A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through `transport.requireFeatures` or a value check on `verifiedServer`, with a comment saying what the server would do; a parameter the server rejects belongs in `explain`'s `parameterFeatures`. A method that chooses what to send from `transport.server()`'s `features` is FAIL — that profile may be a stale cache; send what every server accepts, or decide against `verifiedServer`. A method without an entry, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. Version-driven drift is a `WireV` schema plus a converter, selected by a feature and handed to `requestParsed` through an exported `Schema(features): z.ZodType` reader. The canonical schema is the newest server's shape; a field an older server cannot report is `.nullable()`. FAIL: `.optional()` meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a `z.union` of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in `packages/cli/skill-data/` where a `requires:` frontmatter key or a `` … `` fence belongs (markers on their own lines, never inside a table). diff --git a/CLAUDE.md b/CLAUDE.md index 8cb82635..25e21d2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ This file is rules only. Architecture and rationale live in `docs/architecture.m - Use `defineMetabaseCommand({ meta, args, requires, run })` from `commands/runtime.ts`, never citty's `defineCommand`. Spread the shared flag sets from `commands/flags.ts` (`outputFlags`, `profileFlag`, `connectionFlags`, plus `listFlags` for a list command). A new global flag must also be added to `GLOBAL_FLAG_ARGS` in `commands/global-flags.ts` or it will not survive hoisting. - Commands call the client, never the wire. An `/api/` path literal or a `requestParsed` / `requestRaw` / `requestStream` / `paginatePages` call inside `src/commands/` belongs in `resources/`. Both rules are absolute and carry no allowlist. The one sanctioned request a command issues by name is `tryDiscoverMetadata` in `auth/login.ts`, which runs before there is a transport to hang it on. -- Every command declares `requires`: the `MethodKey` list of every `client..(` its body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never reaches a server. The field is required, so an omission is a compile error. `commands/requires-guard.test.ts` asserts the declaration equals the calls in the body, so a list is never guessed. The preflight derives the features from `METHOD_REQUIREMENTS`, checks them through the client's own `checkFeatures` and throws its `RequirementFailure`; a command whose methods need nothing, and a `null` command, never preflight. +- Every command declares `requires`: the `MethodKey` list of every `client..(` its body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never reaches a server. The field is required, so an omission is a compile error. `commands/requires-guard.test.ts` asserts the declaration equals the calls in the body, so a list is never guessed. `help --json` reports the features the methods need, derived from `METHOD_REQUIREMENTS`. The CLI never refuses on a cached profile: the server decides, and the client explains its refusal. - Import prompts from `output/prompt.ts`, never `@clack/prompts` directly — that is where cancel becomes `AbortError`. - Anything that can block takes `interruptSignal` explicitly: client construction, every `WaitSchedule`, any long-running fetch. @@ -65,7 +65,7 @@ Every resource lives in `packages/client/src/domain/.ts` and - `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". - Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. - Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE.min … max + 1` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. -- Every resource method declares its features in `version/requirements.ts` (strictest first) and opens with `await transport.require(".")`, which refuses with `CapabilityError` before any request; `requirements.test.ts` asserts the literal set equals the table. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. +- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameterFeatures?)` from `const explain = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace, and when the server rejects the call `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError`. A parameter an older server rejects names its feature through `parameterFeatures`. Only a parameter an unsupporting server would drop or rewrite without a word is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. - List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. @@ -94,7 +94,7 @@ E2E invariants — the full contract is in `.claude/skills/add-e2e-test`: - `runCli` is the only way to invoke the CLI. Never call `execa` / `child_process`, never `fetch` Metabase directly. - Never hard-code an entity id or an API key. Read ids from `SEEDED`, credentials via `readBootstrap()`. - State does not persist between tests — each opens on the restored snapshot. Capturing a snapshot is the bootstrap's alone. -- A gated suite passes a `lane` label to `requireServer(lane, {...})` / `requireOAuthServer(lane)`, so a skipped lane is reported rather than counted as passing. +- A gated suite passes a `lane` label to `requireServer(lane, {...})` / `requireOAuthServer(lane)`, or `requireServerWithout(lane, {...})` for a lane pinning how a server lacking a feature answers, so a skipped lane is reported rather than counted as passing. - Never read or print the EE license token (`MB_PREMIUM_EMBEDDING_TOKEN`). To check whether a token-gated lane runs, test the env var for `undefined` — never its value. ## Gate diff --git a/README.md b/README.md index a3ef7562..b2eb3ac5 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # metabase-cli -Command-line client for Metabase. Logs in to an instance in your browser (OAuth, Metabase v63+) or with an API key, and stores credentials securely on your machine. +Command-line client for Metabase. Logs in to an instance in your browser (OAuth, Metabase v62.3.3+) or with an API key, and stores credentials securely on your machine. ## Supported Metabase versions The CLI is built against Metabase majors **58 through 64** (the client's `KNOWN_RANGE`), the latest patch of each; a newer release runs as the major after the newest known one, with one stderr notice per run; a development build (master or a local build, reporting a tag such as `vUNKNOWN`) is treated as newer than every release, with every version-gated command available and no notice; and an older one keeps its real major, gets one stderr notice per run pointing at a Metabase upgrade, and is refused command by command with the version it needs. -Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For a command whose methods need a feature, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: +Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server decides whether a request is allowed: every command sends its requests, and when the server refuses one (an endpoint it does not serve, or a premium feature it does not grant), the CLI probes the server once and reports what is missing with an actionable message (exit code `2`): - the server is older than the command's minimum version, or - the command needs a premium feature (e.g. `remote_sync`, `content_translation`, `library`) that isn't enabled. -Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs without a cached probe, the CLI asks the server for its version once and decides on the answer; a server that cannot be reached fails the command with that network error. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `MB_CLI_SKIP_PREFLIGHT=1`. Both switch off the client's own check too, so every request goes to the wire and the server answers for itself — footguns, only for servers you know are patched. +A license activated or a server upgraded after `mb auth login` takes effect on the next command; nothing has to be refreshed first. The one refusal issued before a request is for a parameter a server without the feature would drop without a word, answering as if it had never been sent — `table list --unused-only` without the `dependencies` feature lists every table. Such a parameter is judged against a probe taken in the same run, never the cached one, so a command passing it costs one request more than it would otherwise, even when the cache grants the feature: a cache can be wrong in that direction too, after a license lapses. -`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A release above the window is read as the major after the newest known one: its additions pass through, and one stderr notice per run points at `mb upgrade`. A development build is `development`, ahead of every release, and prints no notice; a server below the window is `older-than-known`, still evaluated at its own major, with a notice naming the oldest major the CLI supports. A response the CLI cannot parse, or a refusal it issues, under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. +`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A release above the window is read as the major after the newest known one: its additions pass through, and one stderr notice per run points at `mb upgrade`. A development build is `development`, ahead of every release, and prints no notice; a server below the window is `older-than-known`, still evaluated at its own major, with a notice naming the oldest major the CLI supports. A response the CLI cannot parse under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. ## Install @@ -99,10 +99,10 @@ Credentials are stored per-profile. The default profile is named `default`. Use Log in to a Metabase instance and save the credential to a profile. Interactive login offers two methods: -- **In your browser** (recommended; requires Metabase v63 or newer) — the CLI opens Metabase, you sign in with your password or SSO and approve the CLI, and a short-lived access token plus a rotating refresh token are stored. Tokens refresh automatically; you never paste a secret. +- **In your browser** (recommended; requires Metabase v62.3.3 or newer) — the CLI opens Metabase, you sign in with your password or SSO and approve the CLI, and a short-lived access token plus a rotating refresh token are stored. Tokens refresh automatically; you never paste a secret. - **With an API key** — paste a key from Admin settings → Authentication → API keys. -Against a server older than v63 the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before. +Against an older server the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before. On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), Metabase version and skew (`--json` adds `edition`, `knownRange` and `features`), and the probe is cached in `/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry. @@ -124,7 +124,7 @@ mb auth login --url https://m.example.com < key.txt ### `mb auth status` -Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, server version and skew (`supported`, `older than this CLI supports (vN min)`, `newer than this CLI knows (vN max)`, or `unknown version`). `--json` adds what the CLI derives from the cached probe: `edition`, `skew`, `knownRange` and the `features` map the preflight checks. +Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, server version and skew (`supported`, `older than this CLI supports (vN min)`, `newer than this CLI knows (vN max)`, or `unknown version`). `--json` adds what the CLI derives from the cached probe: `edition`, `skew`, `knownRange` and the `features` map an explained refusal names. ```sh mb auth status @@ -525,7 +525,7 @@ mb table fks 42 --json ### `mb table update ` -Patch a table (`PUT /api/table/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type`, `field_order`, `show_in_getting_started`. Pass the body via `--body`, `--file`, or stdin (exactly one). +Patch a table (`PUT /api/table/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type`, `field_order`, `show_in_getting_started`, `data_authority`, `data_source`, `data_layer`, `owner_email`, `owner_user_id`, `collection_id`. Pass the body via `--body`, `--file`, or stdin (exactly one). `display_name`, `entity_type`, `show_in_getting_started`, `field_order` and `collection_id` take no `null`: the server would drop it and leave the value as it was. Two values a server would take without doing what they ask are refused before the request, judged against a probe of the server taken in the same run: `data_authority: null` before Metabase 64, which the server writes into a column that takes no `null` and fails with a bare 500 or stores something other than a withdrawal; and a medallion `data_layer` (`gold`, `silver`, `bronze`, `copper`) on a server that names its layers `final` / `internal` / `hidden`, which maps it onto one of those. A `collection_id` is checked against the table the server answers instead, because whether an update moves a table depends on the server's patch release: one that drops it leaves the table where it was, and the command fails (exit 2) after the rest of the update has been applied. ```sh mb table update 42 --body '{"display_name":"Customers"}' @@ -812,7 +812,7 @@ mb card update 1 --file patch.json --skip-validate ### `mb card verify ` -Mark a card verified, or withdraw the mark with `--remove` (`POST /api/moderation-review` with `moderated_item_type: "card"`). Admins only. Verified content carries a check mark in the product and ranks higher in search (`search --verified`). Each call adds a review and makes it the card's most recent one; `--remove` records a review with no status, which withdraws the verification. Changing the card's query (`card update` with a new `dataset_query`, or a revision revert) withdraws it too: the server records a review with no status and a note saying the edit unverified it. `card get --fields moderation_reviews --json` reads the card's reviews, newest first; the first one's `status` is the current state. Prints the review: `id`, `moderated_item_id`, `moderated_item_type`, `status` (`verified` or `null`), `text`, `most_recent`; `--full` adds `moderator_id` and the timestamps. Needs the `content_verification` premium feature (Pro/Enterprise); the command is refused by name before any request without it. +Mark a card verified, or withdraw the mark with `--remove` (`POST /api/moderation-review` with `moderated_item_type: "card"`). Admins only. Verified content carries a check mark in the product and ranks higher in search (`search --verified`). Each call adds a review and makes it the card's most recent one; `--remove` records a review with no status, which withdraws the verification. Changing the card's query (`card update` with a new `dataset_query`, or a revision revert) withdraws it too: the server records a review with no status and a note saying the edit unverified it. `card get --fields moderation_reviews --json` reads the card's reviews, newest first; the first one's `status` is the current state. Prints the review: `id`, `moderated_item_id`, `moderated_item_type`, `status` (`verified` or `null`), `text`, `most_recent`; `--full` adds `moderator_id` and the timestamps. Needs the `content_verification` premium feature (Pro/Enterprise); without it the server refuses the request, and the error names the feature. ```sh mb card verify 1 @@ -971,7 +971,7 @@ mb dashboard copy 1 --deep --json ### `mb dashboard verify ` -Mark a dashboard verified, or withdraw the mark with `--remove` (`POST /api/moderation-review` with `moderated_item_type: "dashboard"`). Admins only. Verified content carries a check mark in the product and ranks higher in search (`search --verified`). Each call adds a review and makes it the dashboard's most recent one; `--remove` records a review with no status, which withdraws the verification. `dashboard get --fields moderation_reviews --json` reads the dashboard's reviews, newest first; the first one's `status` is the current state. Prints the review: `id`, `moderated_item_id`, `moderated_item_type`, `status` (`verified` or `null`), `text`, `most_recent`; `--full` adds `moderator_id` and the timestamps. Needs the `content_verification` premium feature (Pro/Enterprise); the command is refused by name before any request without it. +Mark a dashboard verified, or withdraw the mark with `--remove` (`POST /api/moderation-review` with `moderated_item_type: "dashboard"`). Admins only. Verified content carries a check mark in the product and ranks higher in search (`search --verified`). Each call adds a review and makes it the dashboard's most recent one; `--remove` records a review with no status, which withdraws the verification. `dashboard get --fields moderation_reviews --json` reads the dashboard's reviews, newest first; the first one's `status` is the current state. Prints the review: `id`, `moderated_item_id`, `moderated_item_type`, `status` (`verified` or `null`), `text`, `most_recent`; `--full` adds `moderator_id` and the timestamps. Needs the `content_verification` premium feature (Pro/Enterprise); without it the server refuses the request, and the error names the feature. ```sh mb dashboard verify 1 @@ -1186,7 +1186,7 @@ mb measure archive 1 --revision-message "deprecated" ## Dependencies -What Metabase content depends on and what depends on it, from `/api/ee/dependencies`. Metabase records an edge from every card, dashboard, document, snippet, transform, sandbox, segment or measure to what its query reads (a table, a card, a snippet, a transform's output), and analyses each dependent's query for errors traced back to what it reads. Every verb needs the `dependencies` premium feature (Pro/Enterprise) and is refused by name before any request without it; `graph` works on every supported server (a measure as its starting entity from Metabase v59), the other four need Metabase v59 or newer. The graph is recomputed by a background job shortly after content changes, so a freshly saved query can take a few seconds to appear. +What Metabase content depends on and what depends on it, from `/api/ee/dependencies`. Metabase records an edge from every card, dashboard, document, snippet, transform, sandbox, segment or measure to what its query reads (a table, a card, a snippet, a transform's output), and analyses each dependent's query for errors traced back to what it reads. Every verb needs the `dependencies` premium feature (Pro/Enterprise), and without it the server's refusal is reported as that missing feature; `graph` works on every supported server (a measure as its starting entity from Metabase v59), the other four need Metabase v59 or newer. The graph is recomputed by a background job shortly after content changes, so a freshly saved query can take a few seconds to appear. Every row is `{ id, type, data }` plus, where the verb reports it, `dependents_count`, a map from usage kind (`question`, `model`, `metric`, `dashboard`, `document`, `transform`, …) to how many depend on the row directly, or `null` when nothing does. `data` names and places the entity by kind: a table carries `name`, `display_name`, `db_id`, `schema` and its `db` (`{ id, name }`); a card `name`, `type` (`question` | `model` | `metric`), `database_id`, `view_count` and its `collection` (plus the `dashboard` or `document` it lives inside, when it does); a dashboard or document its `view_count` and `collection`, a snippet its `collection`; a segment, measure or sandbox its `table` (`{ id, name, display_name }`); a transform carries its `name` and `description` only, with `table` always `null`. The compact projection keeps those; `--full` adds the heavier hydrations (a table's `fields`, a card's `result_metadata`, creators, last-edit info). An entity's location, which `--query` matches and `--sort-column location` orders by and the text table shows, is the dashboard, document or collection holding a card, a table's database, a segment's or measure's table, and the collection of a snippet, dashboard or document; a sandbox has none. A transform's row carries no collection: `unreferenced` and `breaking`, which match and sort on the server, use its collection's name (`Transforms` for one at the root), while `dependents`, `broken` and the text table give it no location. @@ -1446,7 +1446,7 @@ The compact view returns `id`, `name`, `dashboard_id`, `collection_id`, `archive ### `mb subscription create` -The body needs `name`, `dashboard_id`, `cards`, and `channels`. +The body needs `name`, `dashboard_id`, `cards`, and `channels`. Non-empty `parameters` (filter values for this subscription only) need the `dashboard_subscription_filters` premium feature and are refused before any request without it (exit 2), because a server without it stores them but sends the dashboard's own filter values; the same holds on `update`. Each channel names a `channel_type` (`email`, `slack`, `http`) and a `schedule_type` (`hourly`, `daily`, `weekly`, `monthly`) plus the fields that schedule needs: `daily` needs `schedule_hour` (0–23); `weekly` also needs `schedule_day` (`mon`…`sun`); `monthly` also needs `schedule_frame` (`first`, `mid`, `last`). Email recipients are `{"email":"a@b.com"}` or `{"id":}`; Slack targets a channel with `"details":{"channel":"#general"}`. A channel is `enabled` unless you say otherwise. @@ -1928,7 +1928,7 @@ mb search revenue --search-native-query --include-metadata --full --json | `--limit` | Max results to return (default `20` — `search` is the one list verb with its own default). | | `--offset` | Where the window starts, applied by the server (default `0`). | | `--db-id` | Restrict to items on a given database id. | -| `--verified` | Only verified content. | +| `--verified` | Only verified content. Needs the `content_verification` premium feature; refused before any request without it, since a server without it ignores the filter. | | `--collection` | Restrict to one collection by id: the collection's own row, its subcollections and the content filed under them. Segments, measures and transforms never match, tables only once published to the Library, and a transforms collection is not searchable at all. Questions saved into a dashboard match only with `--include-dashboard-questions`. | | `--created-by` | Comma-separated user ids; matches items created by any of them. Only kinds that record a creator can match — cards, models, metrics, dashboards, actions, documents, and measures from Metabase 60 — so collections, tables, databases, segments, transforms and indexed entities drop out. | | `--search-native-query` | Also match native query text. Narrows the result to the models that carry a query — cards, models, metrics, actions, transforms — so dashboards, collections and tables drop out. | @@ -1937,7 +1937,7 @@ mb search revenue --search-native-query --include-metadata --full --json ## Git Sync -Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require Metabase v60 or newer, the `remote_sync` premium feature on an active EE token, and superuser credentials. +Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require the `remote_sync` premium feature on an active EE token and superuser credentials. ### `mb git-sync status` @@ -1958,7 +1958,7 @@ mb git-sync is-dirty --json ### `mb git-sync has-remote-changes` -Compare the latest version on the remote branch against the version Metabase last imported. Cached for a short TTL server-side; pass `--force-refresh` to bypass. +Compare the latest version on the remote branch against the version Metabase last imported. Cached for a short TTL server-side; pass `--force-refresh` to bypass. Requires Metabase v59 or newer. ```sh mb git-sync has-remote-changes @@ -2029,7 +2029,7 @@ mb git-sync import --merge | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -On Metabase v63 and newer the import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included). When no branch is tracked the command refuses with exit 2; when the caller is not an admin, so the setting is not readable, it refuses with exit 2 rather than reading it as unset. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). +The import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included); Metabase v63 and newer reject an import whose assertion disagrees, and older servers ignore it. On a server that grants remote sync, the command refuses with exit 2 when no branch is tracked, and when the caller is not an admin, so the setting is not readable, rather than reading it as unset. On a server without remote sync the request is sent without the assertion and the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). ### `mb git-sync export` @@ -2044,7 +2044,7 @@ mb git-sync export --no-wait | Flag | Description | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the command refuses with exit 2 when no branch is tracked or the setting is not readable. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given. A blank name is refused with exit 2. | +| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given. A blank name is refused with exit 2. | | `--message `, `-m` | Commit message for the export. | | `--force` | Force-push / overwrite the remote branch. | | `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | @@ -2349,17 +2349,16 @@ Exit codes: `0` success (a skill the server cannot use is reported, not refused) ## Environment variables -| Variable | Effect | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MB_URL` | Default URL for `auth login` and config resolution. | -| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | -| `MB_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | -| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | -| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | - -The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_SKIP_PREFLIGHT`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. +| Variable | Effect | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `MB_URL` | Default URL for `auth login` and config resolution. | +| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | + +The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. ## Agent integration diff --git a/docs/architecture.md b/docs/architecture.md index 3ac208da..1cbe7c71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,21 +56,23 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/known-range.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so every feature it lacks is refused by name; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a record written under older placement rules reads the way the tag places today. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so every feature it lacks is named when the server refuses it; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a record written under older placement rules reads the way the tag places today. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. -**Every method declares what it needs and refuses before the wire.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the refusal names, and naming the lowest floor sends a user through one upgrade only to be refused again). Each resource method opens with `await transport.require(key)`; a method whose list is empty returns at once and never probes, so a baseline command with no cached probe stays socket-free. Otherwise the transport reads the profile and throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — before any request. `requirements.test.ts` asserts the literal `require("…")` set in `resources/` equals the table. A parameter only some supported servers honour (an older listing drops a filter it does not know without a word) is a floor the method table cannot express, so the method maps that parameter to a feature and asks `transport.requireFeatures` for it beside its own key; both surfaces share one check and one refusal. `ClientOptions.enforceRequirements: false` is the escape hatch; the CLI wires `--skip-preflight` / `MB_CLI_SKIP_PREFLIGHT=1` to it, and a version-selected reader then still costs one lazy probe because the wire shape is chosen from the profile. The module imports only `FeatureName`, so the CLI's preflight and `--help` reach it without dragging `domain/` onto the startup chunk. +**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const explain = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameterFeatures?)` (a method that answers pages hands `paginatePages` its `features` instead), visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. When the call fails the way a server rejects what it lacks — a 402 premium refusal, an unrouted 404, a 400 on an enum or on a path that lands on an older route, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion`. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails or outlasts the call's own `timeoutMs`; the probe waits on the call's `signal`, and an interrupt during it surfaces as the interrupt. The call's features are the method's plus whatever `parameterFeatures` derives from its arguments, parameters first: a revision of a measure, a measure's dependency graph and a tier-named `data_layer` are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, so a baseline command with no cached probe stays socket-free. `requirements.test.ts` asserts the table names exactly the methods the client exposes, and `explain-guard.test.ts` fails a gated method a resource exports unwrapped. -**The CLI's preflight asks the client.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features, and `createPreflightEnforcer` runs once per command: `client.server()` (cached or one live probe), `checkFeatures`, and the same `CapabilityError` (exit 2) the client would raise moments later. A probe that fails ends the command with that network error rather than warning and proceeding, which would only buy a second connection attempt and the same error. `help --json` reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the check. +**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent: `GET /api/table` below the floor of `can-query`, `can-write`, `include-transform-targets` or `published-only`, or without the `dependencies` token for `unused-only`, lists every table (each filter applies only when `true`, so a `false` one is sent anywhere); an import or export before `remoteSyncMerge` ignores `merge`; a CSV export before `exportCsvByteOrderMark` writes no byte order mark; a field update before `fieldDataSensitivity` drops the label; a search without the `content_verification` token drops `verified` and answers unverified content as if filtered; and a dashboard subscription's `parameters` are stored without the `dashboard_subscription_filters` token but ignored at send time, so the audience gets the dashboard's own filter values. A server that names tiers rewrites a medallion `data_layer` on update (and leaves a `copper` table visible while storing it hidden), and a server without the user-value table fails a `data_authority: null` withdrawal, on a table update or a bulk edit, as a bare 500 or coerces it. Sending those would succeed wrongly or fail without a cause, so the method refuses first through `transport.requireFeatures` or a value check — against `verifiedServer`, so a stale cache can neither block a parameter the server honours nor pass one it would drop. Each such check says in a comment what the server would do. A drop that depends on the patch release rather than the major is judged from the answer instead: a table update drops `collection_id` on early patches of every major before 62 and writes it on later ones, so `table.update` compares the table the server answers with the collection asked for and fails after the request when they differ; and a `null` the update drops on every server (`display_name`, `entity_type`, `show_in_getting_started`, `field_order`, `collection_id`) is no value of `TableUpdateInput`. These checks still judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read: a git-sync import always carries `expected_branch` and an export `branch`, the tracked branch when the caller names none, which a server before `remoteSyncBranchGuard` ignores or takes as its own default. The tracked branch is read from the server's session properties through `transport.probe`, which takes that read as the client's verified profile, so the merge check and the explanation of a refusal that follow it cost no further request. Where they grant no remote sync the request goes without it and the server's refusal is explained; where they do, an unset setting, or one hidden from a caller who is not an admin, is refused before the request. The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. -**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the server tag and the expected fields. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `await transport.require(key)`, `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`. +**The CLI declares what a command reaches.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features for `help --json`, which reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the table. The CLI runs no check of its own: the client's explanation reaches the user as the same `CapabilityError` (exit 2). + +**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the server tag and the expected fields. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`, exported through `explain`. A verifying probe in flight is awaited by `server()`, and its failure falls back to the profile handed in, so a shape read never fails on a probe it did not need. Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade while its commands are refused one feature at a time. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` or a `CapabilityError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written — an upgrade that changed a shape, or an upgrade or new licence that clears a floor the refusal still names — has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade while each command needing a newer feature is sent and the server's refusal named. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, and one whose version tag or granted token features differ from the cached record rewrites it (a write that fails warns without failing the command), so a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices. A `ResponseShapeError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): when the server changed since the cache was written the error says so and the record is rewritten by the same hook; the command is not retried, because its request may have been a write. `skills list` / `skills get` filter by the cached probe without a request, and name the time of that probe and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). -**Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. +**Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. Content values an older server stores without complaint — a `boxplot` display, a `table` template tag, a measure smart link — are guarded by these markers only: the client sends them, and nothing refuses them. **Adding a version-conditional feature** is: a rule in `FEATURE_RULES` named after the behaviour, verified against the reference Metabase checkout's release branches (route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`; token-feature keys in `src/metabase/premium_features/settings.clj`; trust the source over a branch's committed `openapi.json`, which can lag its routes); the method's entry in `METHOD_REQUIREMENTS`; `requires` on the command; and, where a shape differs per generation, the wire variant and converter. The e2e suite gates with `requireServer(lane, [])` where a server may lack the route and branches with `serverHas(feature)` where every stack answers and the answers differ. @@ -82,7 +84,7 @@ The suite drives the built binary against a real Metabase in docker compose, wit The bootstrap is idempotent and reuses its artifact while the stored key still authenticates, so iteration cost is test execution rather than setup. It refuses to seed a server that already holds seed content, because seeding twice duplicates every entity. The artifact and snapshot are working-tree state while the app-db is a docker volume, so a fresh worktree or a `git clean -x` puts them out of step; `bun run e2e:down && bun run e2e:up && bun run e2e:bootstrap` is the way back. -A green summary reports the lanes that ran. The closing coverage block reports the ones that did not and why — locally, absent an EE license token, that is every `remote_sync`-, `library`- and `content_translation`-gated lane. A gate is `requireServer(lane, [])`, the same `checkFeatures` the CLI's preflight runs over the bootstrap's probe, so a lane skips exactly where the command would refuse; `serverHas(feature)` is for a suite that runs on every stack and asserts the outcome its generation produces. When pasting a run as evidence, paste that block too. +A green summary reports the lanes that ran. The closing coverage block reports the ones that did not and why — locally, absent an EE license token, that is every `remote_sync`-, `library`- and `content_translation`-gated lane. A gate is `requireServer(lane, [])`, the same `checkFeatures` the client's explanation runs over the bootstrap's probe, so a lane skips exactly where the server would refuse; `serverHas(feature)` is for a suite that runs on every stack and asserts the outcome its generation produces. When pasting a run as evidence, paste that block too. ## Generated and vendored files diff --git a/packages/cli/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md index e37efc44..e27d11e3 100644 --- a/packages/cli/skill-data/git-sync/SKILL.md +++ b/packages/cli/skill-data/git-sync/SKILL.md @@ -15,7 +15,7 @@ This skill covers the import/export workflow. Flag conventions and auth setup li ## Precondition: read state before mutating -Always run `status` (or `is-dirty` + `has-remote-changes`) before `import` or `export`. Importing on a dirty instance silently rejects unless you pass `--force`. +Always run `status` (or the individual checks below) before `import` or `export`. Importing on a dirty instance silently rejects unless you pass `--force`. @@ -26,11 +26,18 @@ Exporting after the remote moved on ends in a `conflict` task unless the export ```bash mb git-sync status --profile --json # → branch, dirty, current task mb git-sync is-dirty --profile --json # → {is_dirty: bool}; instance has unexported changes -mb git-sync has-remote-changes --profile --json # → {has_changes: bool, remote_version, local_version, cached}; remote has unimported commits mb git-sync dirty --profile --json # → list the dirty objects mb git-sync current-task --profile --json # → in-flight task (or idle) ``` + + +```bash +mb git-sync has-remote-changes --profile --json # → {has_changes: bool, remote_version, local_version, cached}; remote has unimported commits +``` + + + **Clean up before exporting.** If you've created entities you intend to delete (a failed transform you're going to retry, a card you authored to test a body shape, a draft dashboard) — do the deletes _before_ the first `git-sync export`. Once committed, the cleanup needs a second commit, and the failed entity stays visible in `git log` forever. For transforms, prefer `transform update ` over delete + create (see the `transform` skill). ## Import (remote → instance) @@ -53,8 +60,13 @@ Pulls the configured branch and applies it to the instance. Polls until the task Workflow: 1. Read state (above) — confirm `is_dirty: false` (or `--force` is intended). -2. Confirm `has-remote-changes` reports `has_changes: true` — there's actually something to import. -3. `git-sync import --branch ` — runs to terminal status by default. +2. `git-sync import --branch ` — runs to terminal status by default. + + + +Between the two, confirm `has-remote-changes` reports `has_changes: true` — there's actually something to import. + + diff --git a/packages/cli/src/commands/context.test.ts b/packages/cli/src/commands/context.test.ts index 8951270a..e28d6082 100644 --- a/packages/cli/src/commands/context.test.ts +++ b/packages/cli/src/commands/context.test.ts @@ -151,7 +151,6 @@ describe("resolveCommonFlags — full result shape", () => { url: undefined, apiKey: undefined, profile: undefined, - skipPreflight: false, }); }); @@ -166,7 +165,6 @@ describe("resolveCommonFlags — full result shape", () => { maxBytes: "1024", limit: "25", offset: "50", - skipPreflight: true, }, { isTty: false }, ); @@ -180,7 +178,6 @@ describe("resolveCommonFlags — full result shape", () => { url: "https://m.example.com", apiKey: "secret", profile: "prod", - skipPreflight: true, }); }); }); diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index ae9bd8ae..d894bb17 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -32,7 +32,6 @@ export interface CommonContext { url: string | undefined; apiKey: string | undefined; profile: string | undefined; - skipPreflight: boolean; } interface ResolveOptions { @@ -66,7 +65,6 @@ export function resolveCommonFlags(args: CommonArgs, options: ResolveOptions = { url: args.url, apiKey: args.apiKey, profile: args.profile, - skipPreflight: args.skipPreflight === true, }; } diff --git a/packages/cli/src/commands/delete-runtime.test.ts b/packages/cli/src/commands/delete-runtime.test.ts index 0186c7cf..82c1dcb0 100644 --- a/packages/cli/src/commands/delete-runtime.test.ts +++ b/packages/cli/src/commands/delete-runtime.test.ts @@ -20,7 +20,6 @@ const CTX: CommonContext = { url: undefined, apiKey: undefined, profile: undefined, - skipPreflight: false, }; describe("confirmAndDelete", () => { diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index 1e88a4e7..c6926eef 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -72,9 +72,4 @@ export const profileFlag = { export const connectionFlags = { url: { type: "string", description: "Metabase URL" }, apiKey: { type: "string", description: "API key", alias: "api-key" }, - skipPreflight: { - type: "boolean", - description: "Skip the server version capability check for this command", - alias: "skip-preflight", - }, } as const; diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index c83a5fe6..aaaf66b0 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -1,9 +1,17 @@ +import { chmod } from "node:fs/promises"; +import { dirname } from "node:path"; + import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { AbortError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; -import { captureFetch, jsonResponse } from "@metabase/client/testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + premiumRefusalResponse, +} from "@metabase/client/testing/fetch-capture"; import type { ServerInfo } from "@metabase/client/version/probe"; import { KNOWN_RANGE } from "@metabase/client/version/known-range"; import { createServerProfile } from "@metabase/client/version/profile"; @@ -14,6 +22,7 @@ import { probeAt, setupTempConfigHome, type TempConfigHome } from "../core/auth/ const hoisted = vi.hoisted(() => ({ store: new Map(), controls: { broken: false }, + interrupt: { controller: new AbortController() }, })); vi.mock("@napi-rs/keyring", async () => { @@ -21,11 +30,26 @@ vi.mock("@napi-rs/keyring", async () => { return createKeyringMockModule(hoisted); }); -const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime"); +// The process interrupt is one signal that stays aborted once it fires, so each test gets its own. +vi.mock("../runtime/interrupt", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get interruptSignal() { + return hoisted.interrupt.controller.signal; + }, + }; +}); + +const { defineMetabaseCommand } = await import("./runtime"); +const { INTERRUPT_EXIT_CODE } = await import("../runtime/interrupt"); const { connectionFlags, listFlags, outputFlags, profileFlag } = await import("./flags"); -const { readProfileRecord, writeProbeResult, writeProfile } = await import("../core/auth/storage"); +const { profilesFilePath, readProfileRecord, writeProbeResult, writeProfile } = + await import("../core/auth/storage"); const BEYOND_KNOWN = KNOWN_RANGE.max + 5; +const READ_ONLY_DIR_MODE = 0o500; +const WRITABLE_DIR_MODE = 0o700; const PROBED_AT = "2026-03-04T05:06:07.000Z"; const REPROBED_AT = "2026-03-04T06:00:00.000Z"; @@ -35,10 +59,6 @@ function shapeErrorEnvelope(message: string): unknown { return { ok: false, error: { category: "response-shape", message, exitCode: 1 } }; } -function capabilityErrorEnvelope(message: string): unknown { - return { ok: false, error: { category: "capability", message, exitCode: 2 } }; -} - function errorEnvelopeOf(stderr: string[]): unknown { return parseJson(stderr.join(""), z.unknown(), { source: "stderr" }); } @@ -62,10 +82,6 @@ function reprobedRecord(probe: ProfileLastProbe): ProfileRecord { }; } -function sameServerProbe(major: number): Response { - return jsonResponse({ version: { tag: `v0.${major}.0` }, "token-features": {} }); -} - function captureStderr(): string[] { const captured: string[] = []; vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { @@ -81,12 +97,12 @@ describe("defineMetabaseCommand", () => { beforeEach(() => { hoisted.store.clear(); + hoisted.interrupt.controller = new AbortController(); home = setupTempConfigHome(); for (const name of ["URL", "API_KEY", "PROFILE"]) { delete process.env[`MB_${name}`]; delete process.env[`METABASE_${name}`]; } - delete process.env[SKIP_PREFLIGHT_ENV]; previousExitCode = process.exitCode; process.exitCode = 0; vi.stubGlobal("fetch", captureFetch([]).fetch); @@ -99,7 +115,6 @@ describe("defineMetabaseCommand", () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); home.cleanup(); - delete process.env[SKIP_PREFLIGHT_ENV]; process.exitCode = previousExitCode; }); @@ -283,81 +298,6 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(2); }); - it("refuses with CapabilityError exit code 2 before run does any work when the cached server lacks a required feature", async () => { - await seedProbedProfile("default", probeAt(58)); - vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "needs-activation", description: "wants job activation" }, - args: {}, - requires: ["transformJob.setActive"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toContain( - "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.", - ); - expect(process.exitCode).toBe(2); - expect(ran).not.toHaveBeenCalled(); - }); - - it("refuses with CapabilityError exit code 2 when the required premium token-feature is absent", async () => { - await seedProbedProfile("default", probeAt(61)); - vi.stubGlobal("fetch", captureFetch([sameServerProbe(61)]).fetch); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "needs-library", description: "wants the library" }, - args: {}, - requires: ["library.get"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toContain( - "This operation requires the 'library' premium feature (not enabled on this server).", - ); - expect(process.exitCode).toBe(2); - expect(ran).not.toHaveBeenCalled(); - }); - - it("refuses on a second declared method's feature when the first is satisfied", async () => { - await seedProbedProfile("default", probeAt(59)); - vi.stubGlobal("fetch", captureFetch([sameServerProbe(59)]).fetch); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "two-methods", description: "calls two gated methods" }, - args: {}, - requires: ["measure.list", "transformJob.setActive"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toContain( - "This operation requires Metabase v61+ (this server is v0.59.0). Upgrade Metabase to use it.", - ); - expect(process.exitCode).toBe(2); - expect(ran).not.toHaveBeenCalled(); - }); - it("never asks the client for a profile when every declared method is baseline", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); const capture = captureFetch([]); @@ -447,72 +387,6 @@ describe("defineMetabaseCommand", () => { expect(seen.mock.calls).toEqual([[createServerProfile(info)]]); }); - it("probes the server once when the profile has no cached probe and refuses on what it learns", async () => { - await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); - const capture = captureFetch([ - jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), - ]); - vi.stubGlobal("fetch", capture.fetch); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "probes-lazily", description: "wants measures" }, - args: {}, - requires: ["measure.list"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(capture.calls.map((call) => call.url)).toEqual([ - "https://m.example.com/api/session/properties", - ]); - expect(stderr.join("")).toContain( - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", - ); - expect(process.exitCode).toBe(2); - expect(ran).not.toHaveBeenCalled(); - }); - - it("ends the command with the probe's own error when there is no cached probe and the server cannot be reached", async () => { - await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); - const capture = captureFetch([new TypeError("fetch failed")]); - vi.stubGlobal("fetch", capture.fetch); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "probe-fails", description: "wants measures" }, - args: {}, - requires: ["measure.list"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toBe( - JSON.stringify({ - ok: false, - error: { - category: "network", - message: "Could not reach Metabase: fetch failed", - exitCode: 1, - }, - }) + "\n", - ); - expect(capture.calls.map((call) => call.url)).toEqual([ - "https://m.example.com/api/session/properties", - ]); - expect(ran).not.toHaveBeenCalled(); - }); - it("runs a development build's newest-only method without a notice", async () => { await seedProbedProfile("default", { edition: null, @@ -579,35 +453,6 @@ describe("defineMetabaseCommand", () => { expect(stderr.join("")).toBe(""); }); - it("prints the newer-server notice off the live probe when a gated command runs without a cached probe", async () => { - await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); - const capture = captureFetch([ - jsonResponse({ version: { tag: `v0.${BEYOND_KNOWN}.0` }, "token-features": {} }), - jsonResponse([]), - ]); - vi.stubGlobal("fetch", capture.fetch); - - const cmd = defineMetabaseCommand({ - meta: { name: "probes-newer", description: "wants measures on a newer server" }, - args: {}, - requires: ["measure.list"], - async run({ getClient }) { - const client = await getClient(); - await client.measure.list(); - }, - }); - const stderr = captureStderr(); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toBe(NEWER_NOTICE); - expect(capture.calls.map((call) => call.url)).toEqual([ - "https://m.example.com/api/session/properties", - "https://m.example.com/api/measure", - ]); - expect(process.exitCode).toBe(0); - }); - it("ignores the cached probe when --url points the profile at another server", async () => { await seedProbedProfile("default", probeAt(58)); const capture = captureFetch([ @@ -623,6 +468,7 @@ describe("defineMetabaseCommand", () => { requires: ["measure.list"], async run({ getClient }) { const client = await getClient(); + await client.server(); seen(await client.measure.list()); }, }); @@ -636,6 +482,154 @@ describe("defineMetabaseCommand", () => { expect(seen.mock.calls).toEqual([[{ data: [], total: null }]]); }); + describe("write-back of the probes the client runs", () => { + function verifiedSearchCommand() { + return defineMetabaseCommand({ + meta: { name: "searches-verified", description: "searches verified content" }, + args: {}, + requires: ["search.query"], + async run({ getClient }) { + const client = await getClient(); + await client.search.query({ q: "orders", verified: true }); + }, + }); + } + + function branchesCommand() { + return defineMetabaseCommand({ + meta: { name: "lists-branches", description: "lists branches" }, + args: {}, + requires: ["gitSync.branches"], + async run({ getClient }) { + const client = await getClient(); + await client.gitSync.branches(); + }, + }); + } + + it("writes back a probe that finds a license activated since the cache was written", async () => { + await seedProbedProfile("default", probeAt(63, {})); + vi.setSystemTime(new Date(REPROBED_AT)); + const capture = captureFetch([ + jsonResponse({ + version: { tag: "v0.63.0" }, + "token-features": { content_verification: true }, + }), + jsonResponse({ data: [], total: 0 }), + ]); + vi.stubGlobal("fetch", capture.fetch); + + await runCommand(verifiedSearchCommand(), { rawArgs: [] }); + + expect(process.exitCode).toBe(0); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + "https://m.example.com/api/search?q=orders&verified=true", + ]); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + version: { kind: "release", tag: "v0.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { content_verification: true }, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); + }); + + it("writes back the probe that explains a refusal, so the next run reads the server as it is", async () => { + await seedProbedProfile("default", probeAt(63, { remote_sync: true })); + vi.setSystemTime(new Date(REPROBED_AT)); + const capture = captureFetch([ + premiumRefusalResponse("Remote Sync"), + jsonResponse({ version: { tag: "v0.63.0" }, "token-features": { remote_sync: false } }), + ]); + vi.stubGlobal("fetch", capture.fetch); + captureStderr(); + + await runCommand(branchesCommand(), { rawArgs: [] }); + + expect(process.exitCode).toBe(2); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + version: { kind: "release", tag: "v0.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: false }, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); + }); + + it("warns rather than failing the command when the new probe cannot be saved", async () => { + await seedProbedProfile("default", probeAt(63, {})); + const configDir = dirname(profilesFilePath()); + await chmod(configDir, READ_ONLY_DIR_MODE); + const capture = captureFetch([ + jsonResponse({ + version: { tag: "v0.63.0" }, + "token-features": { content_verification: true }, + }), + jsonResponse({ data: [], total: 0 }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + try { + await runCommand(verifiedSearchCommand(), { rawArgs: [] }); + } finally { + await chmod(configDir, WRITABLE_DIR_MODE); + } + + expect(process.exitCode).toBe(0); + expect(stderr.join("")).toContain( + `Could not save the server's new probe to profile "default": EACCES: permission denied`, + ); + expect(capture.calls).toHaveLength(2); + }); + + it("names the skew of a server it learns only by probing, once", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([ + jsonResponse({ + version: { tag: `v0.${BEYOND_KNOWN}.0` }, + "token-features": { content_verification: true }, + }), + jsonResponse({ data: [], total: 0 }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(verifiedSearchCommand(), { rawArgs: [] }); + + expect(process.exitCode).toBe(0); + expect(stderr).toEqual([NEWER_NOTICE]); + }); + + it("leaves the record alone when the probe agrees with it", async () => { + await seedProbedProfile("default", probeAt(63, { content_verification: true })); + const before = await readProfileRecord("default"); + vi.setSystemTime(new Date(REPROBED_AT)); + const capture = captureFetch([ + jsonResponse({ + version: { tag: "v0.63.0" }, + "token-features": { content_verification: true }, + }), + jsonResponse({ data: [], total: 0 }), + ]); + vi.stubGlobal("fetch", capture.fetch); + + await runCommand(verifiedSearchCommand(), { rawArgs: [] }); + + expect(process.exitCode).toBe(0); + expect(await readProfileRecord("default")).toEqual(before); + }); + }); + describe("re-probe on a shape error", () => { const SHAPE_LEAD = "On Metabase v0.59.0 the response shape was unexpected:\n" + @@ -758,11 +752,14 @@ describe("defineMetabaseCommand", () => { expect(await readProfileRecord("default")).toEqual(before); }); - it("never re-probes when the profile the client ran on was not the cached one", async () => { - await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + it("ends as the interrupt when one lands during the re-probe", async () => { + await seedProbedProfile("default", probeAt(59)); const capture = captureFetch([ - jsonResponse({ version: { tag: "v0.59.0" }, "token-features": {} }), jsonResponse({}), + () => { + hoisted.interrupt.controller.abort(new AbortError("interrupted")); + throw new TypeError("fetch failed"); + }, ]); vi.stubGlobal("fetch", capture.fetch); const stderr = captureStderr(); @@ -770,192 +767,30 @@ describe("defineMetabaseCommand", () => { await runCommand(measureListCommand(), { rawArgs: [] }); expect(capture.calls.map((call) => call.url)).toEqual([ - "https://m.example.com/api/session/properties", "https://m.example.com/api/measure", + "https://m.example.com/api/session/properties", ]); - expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); + expect(errorEnvelopeOf(stderr)).toEqual({ + ok: false, + error: { category: "abort", message: "interrupted", exitCode: INTERRUPT_EXIT_CODE }, + }); + expect(process.exitCode).toBe(INTERRUPT_EXIT_CODE); }); - it("re-probes on a shape error even when the preflight was skipped", async () => { - await seedProbedProfile("default", probeAt(58)); - const capture = captureFetch([ - jsonResponse({}), - jsonResponse({ version: { tag: "v0.63.0" }, "token-features": {} }), - ]); + it("never re-probes when the profile the client ran on was not the cached one", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([jsonResponse({})]); vi.stubGlobal("fetch", capture.fetch); const stderr = captureStderr(); - const cmd = defineMetabaseCommand({ - meta: { name: "skips-then-drifts", description: "skips preflight" }, - args: { ...connectionFlags }, - requires: ["measure.list"], - async run({ getClient }) { - const client = await getClient(); - await client.measure.list(); - }, - }); - await runCommand(cmd, { rawArgs: ["--skip-preflight"] }); + await runCommand(measureListCommand(), { rawArgs: [] }); + expect(capture.calls.map((call) => call.url)).toEqual(["https://m.example.com/api/measure"]); expect(errorEnvelopeOf(stderr)).toEqual( shapeErrorEnvelope( - "On Metabase v0.58.0 the response shape was unexpected:\n" + - " Invalid input: expected array, received object\n" + - "The server's version changed since the last probe (was v0.58.0, now v0.63.0); the profile was refreshed — retry the command.", + "Metabase returned unexpected response shape:\n Invalid input: expected array, received object", ), ); }); }); - - describe("re-probe on a refusal", () => { - const ACTIVATION_REFUSAL = - "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it."; - const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; - - function activationCommand() { - return defineMetabaseCommand({ - meta: { name: "needs-activation", description: "wants job activation" }, - args: {}, - requires: ["transformJob.setActive"], - async run({ getClient }) { - await getClient(); - }, - }); - } - - it("re-probes once, writes an upgraded server back, and tells the user to retry", async () => { - await seedProbedProfile("default", probeAt(58)); - vi.setSystemTime(new Date(REPROBED_AT)); - const capture = captureFetch([ - jsonResponse({ version: { tag: "v0.61.3" }, "token-features": {} }), - ]); - vi.stubGlobal("fetch", capture.fetch); - const stderr = captureStderr(); - - await runCommand(activationCommand(), { rawArgs: [] }); - - expect(capture.calls.map((call) => call.url)).toEqual([ - "https://m.example.com/api/session/properties", - ]); - expect(errorEnvelopeOf(stderr)).toEqual( - capabilityErrorEnvelope( - `${ACTIVATION_REFUSAL}\n` + - "The server's version changed since the last probe (was v0.58.0, now v0.61.3); the profile was refreshed — retry the command.", - ), - ); - expect(process.exitCode).toBe(2); - expect(await readProfileRecord("default")).toEqual( - reprobedRecord({ - at: REPROBED_AT, - edition: "oss", - version: { kind: "release", tag: "v0.61.3", major: 61, patch: 3 }, - date: null, - hash: null, - tokenFeatures: {}, - user: { id: 1, name: "Tester", isAdmin: true }, - }), - ); - }); - - it("reports the refusal unchanged and leaves the profile alone when the server has not changed", async () => { - await seedProbedProfile("default", probeAt(58)); - const before = await readProfileRecord("default"); - const capture = captureFetch([ - jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), - ]); - vi.stubGlobal("fetch", capture.fetch); - const stderr = captureStderr(); - - await runCommand(activationCommand(), { rawArgs: [] }); - - expect(errorEnvelopeOf(stderr)).toEqual( - capabilityErrorEnvelope(`${ACTIVATION_REFUSAL}\n${DOWNGRADE_REMEDY}`), - ); - expect(await readProfileRecord("default")).toEqual(before); - }); - }); - - it("bypasses the preflight check when --skip-preflight is passed", async () => { - await seedProbedProfile("default", probeAt(58)); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "skip-preflight-flag", description: "skip via flag" }, - args: { ...connectionFlags }, - requires: ["transformJob.setActive"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - - await runCommand(cmd, { rawArgs: ["--skip-preflight"] }); - expect(ran).toHaveBeenCalledOnce(); - }); - - it("lets the client refuse a gated method the command's own declaration does not cover", async () => { - await seedProbedProfile("default", probeAt(58)); - vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); - const stderr = captureStderr(); - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "under-declared", description: "declares less than it calls" }, - args: {}, - requires: [], - async run({ getClient }) { - const client = await getClient(); - await client.measure.list(); - ran(); - }, - }); - - await runCommand(cmd, { rawArgs: [] }); - - expect(stderr.join("")).toContain( - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", - ); - expect(process.exitCode).toBe(2); - expect(ran).not.toHaveBeenCalled(); - }); - - it("switches the client's own check off too when --skip-preflight is passed", async () => { - await seedProbedProfile("default", probeAt(58)); - const capture = captureFetch([jsonResponse([])]); - vi.stubGlobal("fetch", capture.fetch); - - const seen = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "skip-reaches-wire", description: "skip reaches the wire" }, - args: { ...connectionFlags }, - requires: ["measure.list"], - async run({ getClient }) { - const client = await getClient(); - seen(await client.measure.list()); - }, - }); - - await runCommand(cmd, { rawArgs: ["--skip-preflight"] }); - - expect(capture.calls.map((call) => call.url)).toEqual(["https://m.example.com/api/measure"]); - expect(seen.mock.calls).toEqual([[{ data: [], total: null }]]); - }); - - it("bypasses the preflight check when MB_CLI_SKIP_PREFLIGHT=1 is set", async () => { - await seedProbedProfile("default", probeAt(58)); - process.env[SKIP_PREFLIGHT_ENV] = "1"; - - const ran = vi.fn(); - const cmd = defineMetabaseCommand({ - meta: { name: "skip-preflight", description: "skip" }, - args: {}, - requires: ["transformJob.setActive"], - async run({ getClient }) { - await getClient(); - ran(); - }, - }); - - await runCommand(cmd, { rawArgs: [] }); - expect(ran).toHaveBeenCalledOnce(); - }); }); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index c072a73c..a70a1890 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -3,12 +3,14 @@ import type { ArgsDef, CommandDef, CommandMeta, ParsedArgs } from "citty"; import type { ZodType } from "zod"; import type { MetabaseClient } from "@metabase/client/client"; -import { MetabaseError, ResponseShapeError } from "@metabase/client/errors"; -import type { FeatureName } from "@metabase/client/version/features"; -import { CapabilityError } from "@metabase/client/version/preflight-error"; -import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; +import { + AbortError, + errorMessage, + MetabaseError, + ResponseShapeError, +} from "@metabase/client/errors"; +import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; -import { checkFeatures } from "@metabase/client/version/requirement-check"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import type { ProfileLastProbe } from "../core/auth/profile-record"; @@ -22,9 +24,7 @@ import { } from "../core/auth/storage"; import { createCredentialRefresher, - isPreflightSkipped, resolveConfig, - SKIP_PREFLIGHT_ENV, type ConfigFlags, type ResolvedConfig, } from "../core/config"; @@ -46,8 +46,6 @@ import { } from "./context"; import { assertArgv, givenFlagKeys } from "./argv"; -export { SKIP_PREFLIGHT_ENV }; - interface MetabaseCommandContext { args: ParsedArgs; ctx: CommonContext; @@ -90,13 +88,13 @@ export function defineMetabaseCommand( } return cachedConfig; }; - const preflightSkipped = ctx.skipPreflight || isPreflightSkipped(); let cachedServer: CachedServer | null = null; const noticeSkew = createSkewNotifier(); - // Imported here rather than at the top of the file so the resource namespaces `createClient` - // composes — and the whole `domain/` layer behind them — stay off the chunk every command - // loads, including `--help`, a flag error, and the commands that open no socket at all. - const rawGetClient = async (): Promise => { + // Imported here rather than at the top of the file so the resource namespaces + // `createClient` composes — and the whole `domain/` layer behind them — stay off the chunk + // every command loads, including `--help`, a flag error, and the commands that open no + // socket at all. + const getClient = async (): Promise => { if (cachedClient === null) { const resolved = await getResolvedConfig(); const probe = await readCachedProbe(resolved.profile, resolved.url); @@ -108,27 +106,22 @@ export function defineMetabaseCommand( userAgent: USER_AGENT, ...(server !== null && { server }), refreshCredential: createCredentialRefresher(resolved.profile), + onServerProbed: async (fresh) => { + noticeSkew(createServerProfile(fresh)); + if (probe !== null) { + await persistChangedProbe(resolved.profile, probe, fresh); + } + }, signal: interruptSignal, - enforceRequirements: !preflightSkipped, }, ); if (probe !== null && server !== null) { - cachedServer = { client: cachedClient, profileName: resolved.profile, probe }; + cachedServer = { client: cachedClient, probe }; noticeSkew(server); } } return cachedClient; }; - const enforcePreflight = createPreflightEnforcer( - requirements === null ? null : requirements.features, - preflightSkipped, - noticeSkew, - ); - const getClient = async (): Promise => { - const client = await rawGetClient(); - await enforcePreflight(client); - return client; - }; try { await def.run({ args, @@ -178,8 +171,8 @@ function emitPendingWarnings(): void { type SkewNotifier = (profile: ServerProfile) => void; -// The notice is about the server, not the command, so the first profile a command resolves — -// cached or freshly probed — is the one that speaks, and only once. +// The notice is about the server, not the command, so the first profile a run learns — the cached +// one when the client is built, or else the first probe — speaks, and only once. function createSkewNotifier(): SkewNotifier { let noticed = false; return (profile) => { @@ -194,45 +187,36 @@ function createSkewNotifier(): SkewNotifier { }; } -type PreflightEnforcer = (client: MetabaseClient) => Promise; - -const NO_OP_ENFORCER: PreflightEnforcer = async () => {}; - -// Raises the refusal the client's own `require()` would raise on the first gated call, so a -// command fails before it has done any work. -function createPreflightEnforcer( - features: readonly FeatureName[] | null, - skip: boolean, - noticeSkew: SkewNotifier, -): PreflightEnforcer { - if (features === null || skip || features.length === 0) { - return NO_OP_ENFORCER; +// Every probe the client runs is the server's own word, so one that disagrees with the cache +// replaces it: a refusal explained today must not leave the skills filter and the shape choices of +// the next run on yesterday's server. The cache is a convenience, so a write that fails warns +// rather than failing the command the probe served. +async function persistChangedProbe( + profileName: string, + cached: ProfileLastProbe, + fresh: ServerInfo, +): Promise { + if (serverChangeNote(cached, fresh) === null) { + return; + } + try { + await writeProbeResult(profileName, { user: cached.user, server: fresh }); + } catch (error) { + warn( + `Could not save the server's new probe to profile "${profileName}": ${errorMessage(error)}`, + ); } - let done = false; - return async (client) => { - if (done) { - return; - } - done = true; - const profile = await client.server(); - noticeSkew(profile); - const failure = checkFeatures(features, profile); - if (failure !== null) { - throw new CapabilityError(failure); - } - }; } // The client and the cached probe it was built on, kept for the one re-probe a shape error earns. interface CachedServer { client: MetabaseClient; - profileName: string; probe: ProfileLastProbe; } -// A shape error or a refusal under a cached profile may mean the server changed since the probe. -// One fresh probe settles it: a changed server is written back and the error says so. The command -// is not retried — its request may have been a write. +// A shape error under a cached profile may mean the server changed since the probe, and the shape +// was chosen for the old one. One fresh probe settles it: the client writes a changed server back +// and the error says so. The command is not retried — its request may have been a write. async function refreshProfileOnStaleError( error: unknown, cached: CachedServer | null, @@ -240,7 +224,7 @@ async function refreshProfileOnStaleError( if (cached === null) { return error; } - if (!(error instanceof ResponseShapeError) && !(error instanceof CapabilityError)) { + if (!(error instanceof ResponseShapeError)) { return error; } const note = await refreshChangedProbe(cached); @@ -248,23 +232,19 @@ async function refreshProfileOnStaleError( } async function refreshChangedProbe(cached: CachedServer): Promise { - let fresh: ServerInfo; + let fresh: ServerProfile; try { - fresh = await probeServer(cached.client); + fresh = await cached.client.verifiedServer(); } catch (error) { // A diagnosis on the way out: a server that cannot be reached or answered now must not - // displace the shape error the user is here for. Anything else is a bug and surfaces. - if (error instanceof MetabaseError) { + // displace the shape error the user is here for. An interrupt is the user's own and ends the + // command as one; anything else is a bug and surfaces. + if (error instanceof MetabaseError && !(error instanceof AbortError)) { return null; } throw error; } - const note = serverChangeNote(cached.probe, fresh); - if (note === null) { - return null; - } - await writeProbeResult(cached.profileName, { user: cached.probe.user, server: fresh }); - return note; + return serverChangeNote(cached.probe, fresh); } // The error report takes the format the flags ask for, so it is resolved before argv is checked. @@ -319,9 +299,6 @@ function pickCommonArgs( if (typeof args["apiKey"] === "string") { out.apiKey = args["apiKey"]; } - if (typeof args["skipPreflight"] === "boolean") { - out.skipPreflight = args["skipPreflight"]; - } return out; } diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 5dd2ce4a..8bb3219a 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -63,7 +63,8 @@ export default defineMetabaseCommand({ }, verified: { type: "boolean", - description: "Only verified content", + description: + "Only verified content. Needs the content_verification premium feature, and is refused without it: a server without it ignores the filter", }, collection: { type: "string", diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts index 13444b5a..fbbc001a 100644 --- a/packages/cli/src/commands/skills/get.test.ts +++ b/packages/cli/src/commands/skills/get.test.ts @@ -154,7 +154,7 @@ describe("skills get command", () => { }); it("prints nothing but the skipped note in text mode for a withheld skill", async () => { - await seedProbedProfile("default", probeAt(58)); + const probedAt = await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); const stderr = capture(process.stderr); @@ -162,7 +162,8 @@ describe("skills get command", () => { expect(stdout.chunks).toEqual([]); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + + `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index 50e26ae1..403260ac 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -125,7 +125,7 @@ describe("skills list command", () => { }); it("names each skipped skill and the way to read it anyway in text mode", async () => { - await seedProbedProfile("default", probeAt(58)); + const probedAt = await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); const stderr = capture(process.stderr); @@ -135,12 +135,13 @@ describe("skills list command", () => { "alpha\n The first skill.\n\nbeta\n The second skill.\n\n", ); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + + `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); it("names each skipped skill in a projected text listing too", async () => { - await seedProbedProfile("default", probeAt(58)); + const probedAt = await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); const stderr = capture(process.stderr); @@ -159,7 +160,8 @@ describe("skills list command", () => { ].join("\n"), ); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + + `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); diff --git a/packages/cli/src/core/auth/cached-server.test.ts b/packages/cli/src/core/auth/cached-server.test.ts index dbb1373b..29a26e7f 100644 --- a/packages/cli/src/core/auth/cached-server.test.ts +++ b/packages/cli/src/core/auth/cached-server.test.ts @@ -45,11 +45,12 @@ describe("readCachedServerProfile", () => { }); it("derives the profile from the record's cached probe", async () => { - await seedProbedProfile("staging", probeAt(61, { library: true })); + const probedAt = await seedProbedProfile("staging", probeAt(61, { library: true })); expect(await readCachedServerProfile("staging")).toEqual({ kind: "found", profile: createServerProfile(probeAt(61, { library: true })), + probedAt, }); }); @@ -64,12 +65,13 @@ describe("readCachedServerProfile", () => { }); it("keeps the record's probe when the environment names the record's own server", async () => { - await seedProbedProfile("staging", probeAt(61)); + const probedAt = await seedProbedProfile("staging", probeAt(61)); process.env["MB_URL"] = "http://127.0.0.1:1/"; expect(await readCachedServerProfile("staging")).toEqual({ kind: "found", profile: createServerProfile(probeAt(61)), + probedAt, }); }); }); diff --git a/packages/cli/src/core/auth/cached-server.ts b/packages/cli/src/core/auth/cached-server.ts index d842ec50..ded4ca2d 100644 --- a/packages/cli/src/core/auth/cached-server.ts +++ b/packages/cli/src/core/auth/cached-server.ts @@ -32,6 +32,7 @@ type CachedProbeLookup = CachedProbeFound | CachedProbeMiss; interface CachedServerFound { kind: "found"; profile: ServerProfile; + probedAt: string; } export type CachedServerProfile = CachedServerFound | CachedProbeMiss; @@ -70,5 +71,5 @@ export async function readCachedServerProfile(profileName: string): Promise { it("points an older server at upgrading Metabase and names the oldest major it supports", () => { const below = KNOWN_RANGE.min - 1; expect(skewNotice(createServerProfile(serverAt(below)))).toBe( - `Metabase v0.${below}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`, + `Metabase v0.${below}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`, ); }); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index 0734bc10..454d313a 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -57,7 +57,7 @@ export function skewNotice(profile: ServerProfile): string | null { return null; } case "older-than-known": { - return `Metabase ${describeVersion(profile.version)} is older than this CLI supports (v${min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${min} or later.`; + return `Metabase ${describeVersion(profile.version)} is older than this CLI supports (v${min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${min} or later.`; } case "newer-than-known": { return `Metabase ${describeVersion(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were v${max + 1}. Run \`mb upgrade\` for a newer CLI.`; @@ -67,9 +67,12 @@ export function skewNotice(profile: ServerProfile): string | null { const PROFILE_REFRESHED_REMEDY = "the profile was refreshed — retry the command."; +// What a feature switch reads off a probe. +type ServerIdentity = Pick; + // What a fresh probe says that the cached one did not, or `null` when the two agree on everything // a feature switch reads: the version tag and the premium features. -export function serverChangeNote(cached: ServerInfo, fresh: ServerInfo): string | null { +export function serverChangeNote(cached: ServerIdentity, fresh: ServerIdentity): string | null { const before = describeVersion(cached.version); const after = describeVersion(fresh.version); if (before !== after) { diff --git a/packages/cli/src/core/auth/temp-config-home.ts b/packages/cli/src/core/auth/temp-config-home.ts index 27649f06..0d8dda4a 100644 --- a/packages/cli/src/core/auth/temp-config-home.ts +++ b/packages/cli/src/core/auth/temp-config-home.ts @@ -39,14 +39,19 @@ export function setupTempConfigHome(): TempConfigHome { }; } -// A profile record as `auth login` would have left it: the key stored, the server probed. +// A profile record as `auth login` would have left it: the key stored, the server probed. Answers +// when the probe was taken, which a notice dated by it names. export async function seedProbedProfile( name: string, server: ServerInfo, target: SeedTarget = UNREACHABLE_TARGET, -): Promise { +): Promise { await writeProfile(target, name); - await writeProbeResult(name, { user: SEED_USER, server }); + const probe = await writeProbeResult(name, { user: SEED_USER, server }); + if (probe === null) { + throw new Error(`profile "${name}" vanished before its probe was written`); + } + return probe.at; } // A released server at `major`, as `probeServer` would report it: the edition is what the tag stamps. diff --git a/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts index dd6cbbe1..bedf150b 100644 --- a/packages/cli/src/core/auth/verify.test.ts +++ b/packages/cli/src/core/auth/verify.test.ts @@ -54,7 +54,7 @@ describe("verifyAndProbe", () => { await answered; return jsonResponse(body); }; - const capture = captureFetch([held(SESSION_PROPERTIES), held(CURRENT_USER)]); + const capture = captureFetch([held(CURRENT_USER), held(SESSION_PROPERTIES)]); vi.stubGlobal("fetch", capture.fetch); const pending = verifyAndProbe(BASE_URL, CREDENTIAL); @@ -62,13 +62,13 @@ describe("verifyAndProbe", () => { await flushPending(); expect(capture.calls).toEqual([ { - url: `${BASE_URL}/api/session/properties`, + url: `${BASE_URL}/api/user/current`, method: "GET", headers: READ_HEADERS, body: null, }, { - url: `${BASE_URL}/api/user/current`, + url: `${BASE_URL}/api/session/properties`, method: "GET", headers: READ_HEADERS, body: null, diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index 7e5e04f7..1b58f3a4 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -15,13 +15,7 @@ import { readProfileRecord, writeOAuthProfile, } from "./auth/storage"; -import { ENV_API_KEY, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, readEnv } from "./env"; - -export const SKIP_PREFLIGHT_ENV = ENV_SKIP_PREFLIGHT; - -export function isPreflightSkipped(): boolean { - return readEnv(ENV_SKIP_PREFLIGHT) === "1"; -} +import { ENV_API_KEY, ENV_PROFILE, ENV_URL, readEnv } from "./env"; type ConfigSource = "flag" | "env" | "stored" | "mixed"; diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts index 81b2437b..043ed5d0 100644 --- a/packages/cli/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -5,7 +5,6 @@ export const ENV_URL = "MB_URL"; export const ENV_API_KEY = "MB_API_KEY"; export const ENV_PROFILE = "MB_PROFILE"; export const ENV_VERBOSE = "MB_VERBOSE"; -export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; diff --git a/packages/cli/src/core/profile-refreshed-error.ts b/packages/cli/src/core/profile-refreshed-error.ts index 2bea8499..64311717 100644 --- a/packages/cli/src/core/profile-refreshed-error.ts +++ b/packages/cli/src/core/profile-refreshed-error.ts @@ -1,6 +1,6 @@ import { type ErrorCategory, MetabaseError } from "@metabase/client/errors"; -// A refusal or shape error raised under a cached profile that a fresh probe has since rewritten. +// A shape error raised under a cached profile that a fresh probe has since rewritten. // The note is the CLI's own, so it rides on the message and leaves the client's detail as it was. export class ProfileRefreshedError extends MetabaseError { readonly category: ErrorCategory; diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 83ccde55..dc7ca731 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -512,7 +512,7 @@ describe("selectForProfile", () => { detail: "This operation requires the 'remote_sync' premium feature (not enabled on this server).", feature: "remoteSync", - since: 60, + since: 58, tokenFeature: "remote_sync", serverVersion: "v0.61.0", }, diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 9bbc72c3..8b5fb834 100644 --- a/packages/cli/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -228,12 +228,9 @@ describe("showUsage", () => { const out = chunks.join(""); expect(out).toContain("--max-bytes="); expect(out).toContain("--api-key="); - expect(out).toContain("--skip-preflight"); expect(out).not.toContain("--maxBytes"); expect(out).not.toContain("--apiKey"); - expect(out).not.toContain("--skipPreflight"); expect(out).not.toMatch(/(? { diff --git a/packages/cli/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts index 5c9cbb4c..4cba999f 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -101,6 +101,7 @@ describe("skillFilterNotices", () => { hash: null, tokenFeatures: null, }), + probedAt: "2026-01-02T03:04:05.000Z", }; it("says nothing when the filter was bypassed", () => { @@ -132,6 +133,7 @@ describe("skillFilterNotices", () => { it("names each skipped skill with the client's reason and the way around it", () => { expect(skillFilterNotices(skipped, { profileName: "default", cached: probed })).toEqual([ 'Skipped skill "transform": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.', + 'Skills are filtered by profile "default"\'s server as probed at 2026-01-02T03:04:05.000Z (run `mb auth list` to probe it again).', ]); expect(skillFilterNotices([], { profileName: "default", cached: probed })).toEqual([]); }); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index 7b8de5c2..777ee8cf 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -45,16 +45,26 @@ export function skillFilterNotices( return []; } if (unavailable !== null) { - return unavailable.map( + const skipped = unavailable.map( (skill) => `Skipped skill "${skill.name}": ${skill.failure.detail} Pass --unfiltered to print it anyway.`, ); + if (skipped.length === 0 || context.cached.kind !== "found") { + return skipped; + } + return [...skipped, probeAgeNotice(context.profileName, context.cached.probedAt)]; } return context.cached.kind === "found" ? [] : [unfilteredNotice(context.profileName, context.cached)]; } +// The filter reads the server as it was at the last probe, and a license activated since would still +// read as missing, so the notice says when that was and how to look again. +function probeAgeNotice(profileName: string, probedAt: string): string { + return `Skills are filtered by profile "${profileName}"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).`; +} + function unfilteredNotice(profileName: string, miss: CachedProbeMiss): string { switch (miss.kind) { case "no-profile": { diff --git a/packages/client/README.md b/packages/client/README.md index 156d9e6f..d26e8fc8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -284,7 +284,7 @@ cancellation is never reported back as a polling `TimeoutError`. ### Servers of different versions -The client supports a window of Metabase majors, `KNOWN_RANGE`, and resolves a `ServerProfile` for the server it talks to — from `/api/session/properties` on the first call that needs it, or from a profile the host cached earlier and passes as `ClientOptions.server`. Every resource method declares the features it needs and refuses before any request leaves when the profile lacks one, so a consumer never sends a request the server cannot answer. +The client supports a window of Metabase majors, `KNOWN_RANGE`, and resolves a `ServerProfile` for the server it talks to — from `/api/session/properties` on the first call that needs it, or from a profile the host cached earlier and passes as `ClientOptions.server`. Every resource method declares the features it needs. The server decides whether a request is allowed; when it refuses one for lacking a feature, the client reads a profile it probed itself and throws a `CapabilityError` naming the feature, so a profile cached before an upgrade or a license change never refuses what the server would serve. ```ts import { CapabilityError, createClient } from "@metabase/client"; @@ -306,7 +306,7 @@ try { } ``` -A parameter only some servers honour is refused the same way when it is given: `mb.table.list({ "can-query": true })` asks for the access filters below 59, and `mb.field.update(id, { data_sensitivity })` for the sensitivity column below 64, each with the `CapabilityError` naming the feature; without the parameter neither method consults the profile. A vocabulary the server spells differently by generation is refused by value: `data_layer` is `final`, `internal` or `hidden` from 59 on (`TableDataLayerTier`) and `gold`, `silver`, `bronze` or `copper` on 58 (`TableDataLayerMedallion`), and `mb.table.update` refuses a tier name below 59 by feature and a medallion name from 59 on as a `ConfigError`. That second refusal reads the profile directly, so `enforceRequirements: false` does not switch it off; a requirement can only say a server is too old. +A parameter an older server rejects is sent, and the rejection is explained the same way: `mb.revision.list("measure", 1)` on a server without measures, `mb.dependency.graph("measure", 1)` below 59, or a tier-named `data_layer` (`final`, `internal`, `hidden`, `TableDataLayerTier`) on a server that still speaks medallions (`gold`, `silver`, `bronze`, `copper`, `TableDataLayerMedallion`) throws the `CapabilityError` naming the feature. A parameter a server without it would drop or rewrite without a word is refused before any request leaves, judged against a profile the client probed itself rather than one handed in: `mb.table.list({ "can-query": true })` asks for the access filters below 59 (a filter set to `false` asks for nothing), `{ "unused-only": true }` for the `dependencies` token, `mb.table.update` and `mb.table.bulkEdit` with `data_authority: null` for `tableUserValueWithdrawal` below 64, `mb.field.update(id, { data_sensitivity })` and `mb.field.setDataSensitivity` for the sensitivity column below 64, `mb.search.query({ verified: true })` for the `content_verification` token, and `mb.pulse.create` / `mb.pulse.update` with non-empty `parameters` for the `dashboard_subscription_filters` token, each with the `CapabilityError` naming the feature; without the parameter neither method consults the profile. `mb.table.update` refuses a medallion name on a server that names tiers as a `ConfigError`, because that server would map it onto a tier rather than reject it, and throws a `ConfigError` after the request when the table the server answers is not in the `collection_id` asked for, because some patch releases of every major before 62 drop the key without a word. A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape. @@ -412,19 +412,19 @@ an `OAuthCredential`. `refreshOAuthCredential` exchanges the rotating refresh to revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredential` union), `OAuthLoginInput`, `OAuthLoginDeps`. -Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the same check, `oauthLogin` checks that the server grants the full-access scope the REST API needs: a discovery document that lists it in `scopes_supported` settles it, and otherwise the server's version does (`oauthFullAccessScope`), because newer servers grant the scope without advertising it; a server that cannot is refused with a `ConfigError`. +Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the same check, `oauthLogin` checks that the server grants the full-access scope the REST API needs: a discovery document that lists it in `scopes_supported` settles it (Metabase v62 from the patch that introduced the scope, and v63), and otherwise the server's version does (`oauthFullAccessScopeUnadvertised`), because v64 and newer grant the scope without advertising it; a server that cannot is refused with a `ConfigError`. ### Versions and requirements `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo`: the version, the edition the tag stamps, the build date and hash, and the token-feature map, each but the version `null` when the server does not report it. `parseTag(tag)` reads a tag into a `ServerVersion`, a `ReleaseVersion` (`{ kind: "release", tag, major, patch }`) or a `DevelopmentBuild` (`{ kind: "development", tag }`); `describeVersion(version)` words it for a message (the tag, or `development build `); `StoredServerVersion` reads a persisted version back by re-deriving it from its tag; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and is refused feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info)` is called with every `ServerInfo` the client probes, so a consumer that passes a cached profile can keep its cache current: the calls waiting on the probe resume once the hook returns, and the profile is settled before the hook is called, so a call the hook itself makes, or one that starts while it runs, reads that profile without waiting — except a git-sync branch read, which reads the session properties afresh and so calls the hook again — and a hook that throws fails the calls waiting on the probe while the profile stays settled; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(major, tokenFeatures)` evaluates it for a `RuleMajor` (a release major, or `"development"` for a build ahead of every release), `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. -`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. Every resource method starts by asking `transport.require(key)`, which throws a `CapabilityError` before any request leaves when the connected profile lacks a feature — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can refuse it. `ClientOptions.enforceRequirements: false` sends every method to the wire and leaves the server to answer for itself; a method whose wire shape is chosen by generation still reads the profile. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that anticipates the refusal — a CLI preflight, say — throws the same `CapabilityError` from the same `RequirementFailure`. +`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on an enum or on a path that lands on an older route), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check. -A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `fieldDetailSchema(features)` reads a `FieldDetail`, which is a `FieldWithDataSensitivity` (its `data_sensitivity` `null` for an unlabelled field) on a generation with the label and a plain `Field` without the key elsewhere, told apart by `hasDataSensitivity`, `compiledQuerySchema(features)` reads a `CompiledQuery` whose `collection` is `null` on a generation that drops it from every answer, `fieldRemappedValueSchema(features, sameField)` reads a `FieldRemappedValue` from the first cells of a row a generation pads with display columns, `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. +A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `fieldDetailSchema(features)` reads a `FieldDetail`, which is a `FieldWithDataSensitivity` (its `data_sensitivity` `null` for an unlabelled field) on a generation with the label and a plain `Field` without the key elsewhere, told apart by `hasDataSensitivity`, `fieldRemappedValueSchema(features, sameField)` reads a `FieldRemappedValue` from the first cells of a row a generation pads with display columns, `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. Types: `ServerInfo`, `ServerProfile`, `ServerVersion`, `ReleaseVersion`, `DevelopmentBuild`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `RuleMajor`, `Features`, `FeatureGap`, `VersionGap`, `TokenGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`. diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2b01acb3..691d9bdf 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -85,6 +85,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) upload: uploadResource(transport), user: userResource(transport), server: transport.server, + verifiedServer: transport.verifiedServer, // The escape hatch: an endpoint the client never models still has to cost a consumer nothing. requestParsed: transport.requestParsed, requestRaw: transport.requestRaw, diff --git a/packages/client/src/domain/dataset.ts b/packages/client/src/domain/dataset.ts index 58868be1..8684c491 100644 --- a/packages/client/src/domain/dataset.ts +++ b/packages/client/src/domain/dataset.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import type { Features } from "../version/features"; import { Database, DatabaseCompact, @@ -17,28 +16,13 @@ export const CompiledQuery = z // and a stage list otherwise. query: z.union([z.string(), z.array(z.unknown())]), params: z.array(z.unknown()).nullable().optional(), - // A document driver's source collection, absent for a query that reads none. `null` is a server - // that drops the key from every answer, so it cannot say. + // A document driver's source collection, absent for a query that reads none. Some patch + // releases drop the key from every answer, so its absence on a document query says nothing. collection: z.string().nullable().optional(), }) .loose(); export type CompiledQuery = z.infer; -const CompiledQueryWireWithoutCollection = CompiledQuery.omit({ collection: true }); - -function withUnknownCollection( - wire: z.infer, -): CompiledQuery { - return { ...wire, collection: null }; -} - -/** The shape `POST /api/dataset/native` answers on a server with `features`, read as `CompiledQuery`. */ -export function compiledQuerySchema(features: Features): z.ZodType { - return features.compiledQueryOmitsCollection - ? CompiledQueryWireWithoutCollection.transform(withUnknownCollection) - : CompiledQuery; -} - export const CompiledQueryCompact = CompiledQuery.pick({ query: true, params: true, diff --git a/packages/client/src/domain/table.ts b/packages/client/src/domain/table.ts index 4ab1ffa0..ccec4b71 100644 --- a/packages/client/src/domain/table.ts +++ b/packages/client/src/domain/table.ts @@ -90,22 +90,24 @@ export const TableDataSource = z.enum([ ]); export type TableDataSource = z.infer; +// The update writes `display_name`, `entity_type`, `show_in_getting_started`, `field_order` and +// `collection_id` only when non-null and drops a `null` without a word, so those take no `null`. export const TableUpdateInput = z .object({ - display_name: z.string().min(1).nullable().optional(), - entity_type: TableEntityType.nullable().optional(), + display_name: z.string().min(1).optional(), + entity_type: TableEntityType.optional(), visibility_type: TableVisibilityType.nullable().optional(), description: z.string().nullable().optional(), caveats: z.string().nullable().optional(), points_of_interest: z.string().nullable().optional(), - show_in_getting_started: z.boolean().nullable().optional(), - field_order: TableFieldOrder.nullable().optional(), + show_in_getting_started: z.boolean().optional(), + field_order: TableFieldOrder.optional(), data_authority: TableDataAuthority.nullable().optional(), data_source: TableDataSource.nullable().optional(), data_layer: TableDataLayer.nullable().optional(), owner_email: z.string().nullable().optional(), owner_user_id: z.number().int().nullable().optional(), - collection_id: z.number().int().positive().nullable().optional(), + collection_id: z.number().int().positive().optional(), }) .loose(); export type TableUpdateInput = z.infer; diff --git a/packages/client/src/http/oauth.ts b/packages/client/src/http/oauth.ts index 01724179..5d0bb4b9 100644 --- a/packages/client/src/http/oauth.ts +++ b/packages/client/src/http/oauth.ts @@ -218,10 +218,11 @@ function pinEndpoints(metadata: OAuthServerMetadata, baseUrl: string): OAuthServ } // Discovery advertises the full-access scope from the v62 patch that introduced it through v63; -// v60, v61 and the first v62 patches serve an OAuth server for the agent API only, and newer -// servers keep the scope out of `scopes_supported` on purpose while still granting it. So a -// discovery document that names the scope settles it, and otherwise the version does, read from -// the properties endpoint, which answers without a credential. +// v60, v61 and the earlier v62 patches serve an OAuth server for the agent API only, and v64 keeps +// the scope out of `scopes_supported` on purpose while still granting it. A v62 major cannot say +// which side of that patch it is on, so a discovery document that names the scope settles it, and +// only one that does not falls back to the version, read from the properties endpoint, which +// answers without a credential. async function grantsFullAccessScope( metadata: OAuthServerMetadata, baseUrl: string, @@ -242,7 +243,7 @@ async function grantsFullAccessScope( } const properties = await readJson(response, SessionProperties, "Server properties"); const profile = createServerProfile(serverInfoFromProperties(properties)); - return profile.features.oauthFullAccessScope; + return profile.features.oauthFullAccessScopeUnadvertised; } // The discovery document when the server can log a client in to the whole REST API, else null. diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 934d9748..6e39f386 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -14,10 +14,16 @@ import { } from "./transport"; import { HttpError } from "./errors"; import { deferred } from "../testing/deferred"; -import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + premiumRefusalResponse, + probeResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; import { SessionProperties } from "../domain/session-properties"; -import { PROBE_PATH } from "../version/probe"; +import { PROBE_PATH, type ServerInfo } from "../version/probe"; import { KNOWN_RANGE } from "../version/known-range"; import { createServerProfile } from "../version/profile"; @@ -41,6 +47,11 @@ function transportFailure(code: string, detail: string): TypeError { return failure; } +// A fetch answering every request with a fresh `response()`. +function answering(response: () => Response): typeof fetch { + return async () => response(); +} + // Bun rejects with one error carrying the syscall code itself, where Node nests it under `cause`. function bunTransportFailure(code: string, detail: string): Error { return Object.assign(new Error(detail), { code }); @@ -480,8 +491,8 @@ describe("createTransport.server", () => { }); it("ends one caller's wait on its own signal while the shared probe settles for the rest", async () => { - const probeResponse = deferred(); - const fakeFetch = captureFetch([() => probeResponse.promise]); + const heldProbe = deferred(); + const fakeFetch = captureFetch([() => heldProbe.promise]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, @@ -496,7 +507,7 @@ describe("createTransport.server", () => { assert(error instanceof AbortError, "expected AbortError"); expect(error.message).toBe("caller moved on"); - probeResponse.resolve(jsonResponse(PROBE_BODY)); + heldProbe.resolve(jsonResponse(PROBE_BODY)); expect(await patient).toEqual(PROBED_PROFILE); expect(await client.server()).toBe(await patient); expect(fakeFetch.calls).toHaveLength(1); @@ -617,7 +628,7 @@ describe("createTransport.server", () => { }); }); -describe("createTransport.require", () => { +describe("createTransport.requireFeatures", () => { const OSS_58 = createServerProfile({ edition: "oss", version: { kind: "release", tag: "v0.58.0", major: 58, patch: 0 }, @@ -625,162 +636,365 @@ describe("createTransport.require", () => { hash: null, tokenFeatures: null, }); - const VERSION_TOO_OLD = { - reason: "version-too-old", - detail: - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", - feature: "measures", - since: 59, - tokenFeature: null, - serverVersion: "v0.58.0", - }; - it("resolves when the supplied profile satisfies the method, without a request", async () => { - const fakeFetch = captureFetch([]); + it("throws CapabilityError naming the first feature the profile lacks", async () => { + const fakeFetch = captureFetch([ + jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), + ]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, server: OSS_58, }); - await expect(client.require("card.list")).resolves.toBeUndefined(); + const error = await client + .requireFeatures(["transforms", "measures"]) + .catch((caught: unknown) => caught); - expect(fakeFetch.calls).toEqual([]); + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); - it("throws CapabilityError carrying the check's failure before any request leaves", async () => { + it("never probes for an empty list", async () => { const fakeFetch = captureFetch([]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, - server: OSS_58, }); - const error = await client.require("measure.list").catch((caught: unknown) => caught); + await expect(client.requireFeatures([])).resolves.toBeUndefined(); - assert(error instanceof CapabilityError, "expected CapabilityError"); - expect(error.userMessage).toBe(VERSION_TOO_OLD.detail); - expect(error.developerDetail).toEqual(VERSION_TOO_OLD); expect(fakeFetch.calls).toEqual([]); }); +}); - it("refuses with an already-aborted signal before probing for a gated method", async () => { - const fakeFetch = captureFetch([]); +describe("createTransport.verifiedServer", () => { + const CACHED_58 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: {}, + }); + const UPGRADED_63 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: true }, + }); + + it("probes rather than trusting the profile handed in, once for every caller", async () => { + const fakeFetch = captureFetch([probeResponse(UPGRADED_63)]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, + server: CACHED_58, }); - const error = await client - .require("measure.list", { signal: AbortSignal.abort(new Error("already gone")) }) - .catch((caught: unknown) => caught); + const [first, second] = await Promise.all([client.verifiedServer(), client.verifiedServer()]); - assert(error instanceof AbortError, "expected AbortError"); - expect(error.message).toBe("already gone"); - expect(fakeFetch.calls).toEqual([]); + expect(first).toEqual(UPGRADED_63); + expect(second).toBe(first); + expect(await client.verifiedServer()).toBe(first); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); - it("never probes for a method that needs nothing", async () => { - const fakeFetch = captureFetch([]); + it("hands the server's answer to the shape reads that follow it", async () => { const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, - fetchImpl: fakeFetch.fetch, + fetchImpl: captureFetch([probeResponse(UPGRADED_63)]).fetch, + server: CACHED_58, }); - await expect(client.require("card.list")).resolves.toBeUndefined(); + const [verified, read] = await Promise.all([client.verifiedServer(), client.server()]); - expect(fakeFetch.calls).toEqual([]); + expect(read).toBe(verified); }); - it("probes lazily for a gated method when no profile was supplied", async () => { - const fakeFetch = captureFetch([ - jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), - ]); + it("leaves the shape reads on the profile handed in when the verifying probe fails", async () => { + const fakeFetch = captureFetch([new Response("boom", { status: 500 })]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, + server: CACHED_58, }); - const error = await client.require("measure.list").catch((caught: unknown) => caught); + const [verified, read] = await Promise.all([ + client.verifiedServer().catch((caught: unknown) => caught), + client.server(), + ]); - assert(error instanceof CapabilityError, "expected CapabilityError"); - expect(error.developerDetail).toEqual(VERSION_TOO_OLD); + assert(verified instanceof HttpError, "expected HttpError"); + expect(verified.status).toBe(500); + expect(read).toBe(CACHED_58); expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); - it("resolves without checking when enforcement is switched off", async () => { - const fakeFetch = captureFetch([]); + it("fails the shape reads with an interrupt rather than falling back past it", async () => { + const controller = new AbortController(); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, - fetchImpl: fakeFetch.fetch, - server: OSS_58, - enforceRequirements: false, + fetchImpl: HANGING_FETCH, + server: CACHED_58, + signal: controller.signal, }); - await expect(client.require("measure.list")).resolves.toBeUndefined(); + const verifying = client.verifiedServer().catch((caught: unknown) => caught); + const reading = client.server().catch((caught: unknown) => caught); + controller.abort(new Error("operator interrupt")); - expect(fakeFetch.calls).toEqual([]); + const verifyError = await verifying; + assert(verifyError instanceof AbortError, "expected AbortError"); + expect(verifyError.message).toBe("operator interrupt"); + const error = await reading; + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("operator interrupt"); }); -}); -describe("createTransport.requireFeatures", () => { - const OSS_58 = createServerProfile({ - edition: "oss", - version: { kind: "release", tag: "v0.58.0", major: 58, patch: 0 }, - date: null, - hash: null, - tokenFeatures: null, + it("hands every probe to onServerProbed before any caller reads it", async () => { + const seen: ServerInfo[] = []; + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: captureFetch([probeResponse(UPGRADED_63)]).fetch, + server: CACHED_58, + onServerProbed: async (info) => { + await delay(1); + seen.push(info); + }, + }); + + await client.verifiedServer(); + + expect(seen).toEqual([ + { + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + edition: "ee", + date: null, + hash: null, + tokenFeatures: { remote_sync: true }, + }, + ]); }); - it("throws CapabilityError naming the first feature the profile lacks", async () => { - const fakeFetch = captureFetch([]); - const client = createTransport(CONFIG, { + it("settles the profile before the hook, so a hook calling back into the client does not wait on itself", async () => { + const fakeFetch = captureFetch([probeResponse(UPGRADED_63)]); + const readByHook: string[] = []; + const client: Transport = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, - server: OSS_58, + server: CACHED_58, + onServerProbed: async () => { + const [verified, read] = await Promise.all([client.verifiedServer(), client.server()]); + readByHook.push(verified.version.tag, read.version.tag); + }, }); - const error = await client - .requireFeatures(["transforms", "measures"]) - .catch((caught: unknown) => caught); + const verified = await client.verifiedServer(); - assert(error instanceof CapabilityError, "expected CapabilityError"); - expect(error.developerDetail).toEqual({ - reason: "version-too-old", - detail: - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", - feature: "transforms", - since: 59, - tokenFeature: null, - serverVersion: "v0.58.0", - }); - expect(fakeFetch.calls).toEqual([]); + expect(verified).toEqual(UPGRADED_63); + expect(readByHook).toEqual(["v1.63.0", "v1.63.0"]); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); - it("never probes for an empty list", async () => { - const fakeFetch = captureFetch([]); + it("takes a read of the session properties as the verified profile, and answers its settings", async () => { + const BranchProperties = SessionProperties.extend({ "remote-sync-branch": z.string() }); + const seen: ServerInfo[] = []; + const fakeFetch = captureFetch([ + jsonResponse({ + version: { tag: "v1.63.0" }, + "token-features": { remote_sync: true }, + "remote-sync-branch": "main", + }), + ]); const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, + server: CACHED_58, + onServerProbed: (info) => { + seen.push(info); + }, }); - await expect(client.requireFeatures([])).resolves.toBeUndefined(); + const properties = await client.probe(BranchProperties); - expect(fakeFetch.calls).toEqual([]); + expect(properties["remote-sync-branch"]).toBe("main"); + expect(await client.verifiedServer()).toEqual(UPGRADED_63); + expect(seen.map((info) => info.version.tag)).toEqual(["v1.63.0"]); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); +}); - it("resolves without checking when enforcement is switched off", async () => { - const fakeFetch = captureFetch([]); - const client = createTransport(CONFIG, { +describe("createTransport.explainRefusal", () => { + const LICENSED_63 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: true }, + }); + const UNLICENSED_63 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: false }, + }); + + // A transport whose first request fails as `failure` and whose later ones are `rest`, handed the + // licensed profile a cache would hold. + function transportOver(failure: Response, rest: typeof fetch) { + let first = true; + const urls: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + if (first) { + first = false; + return failure; + } + return rest(input, init); + }; + const controller = new AbortController(); + const transport = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, - fetchImpl: fakeFetch.fetch, - server: OSS_58, - enforceRequirements: false, + fetchImpl, + server: LICENSED_63, + signal: controller.signal, }); + return { transport, urls, stop: () => controller.abort(new Error("test over")) }; + } - await expect(client.requireFeatures(["measures"])).resolves.toBeUndefined(); + async function failedRequest(transport: Transport): Promise { + return transport + .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { retries: 0 }) + .catch((caught: unknown) => caught); + } - expect(fakeFetch.calls).toEqual([]); + it("turns a premium refusal into the feature a fresh probe finds missing, whatever the cache said", async () => { + const { transport, urls } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(UNLICENSED_63)), + ); + const refusal = await failedRequest(transport); + + const explained = await transport.explainRefusal(["remoteSync"], refusal); + + assert(explained instanceof CapabilityError, "expected CapabilityError"); + expect(explained.developerDetail).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 58, + tokenFeature: "remote_sync", + serverVersion: "v1.63.0", + }); + expect(urls).toEqual([ + "https://m.example.com/api/ee/remote-sync/branches", + `https://m.example.com${PROBE_PATH}`, + ]); + }); + + it("lets the server's error stand when a fresh probe finds every feature", async () => { + const { transport } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(LICENSED_63)), + ); + const refusal = await failedRequest(transport); + + expect(await transport.explainRefusal(["remoteSync"], refusal)).toBe(refusal); + }); + + it("never blames a missing row on a feature, and asks the server nothing", async () => { + const { transport, urls } = transportOver( + new Response("Not found.", { status: 404, headers: { "content-type": "text/plain" } }), + answering(() => probeResponse(UNLICENSED_63)), + ); + const missing = await failedRequest(transport); + + expect(await transport.explainRefusal(["remoteSync"], missing)).toBe(missing); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); + }); + + it("leaves a failure that is no refusal unexplained, and asks the server nothing", async () => { + const { transport, urls } = transportOver( + new Response("boom", { status: 500 }), + answering(() => probeResponse(UNLICENSED_63)), + ); + const failure = await failedRequest(transport); + + expect(await transport.explainRefusal(["remoteSync"], failure)).toBe(failure); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); + }); + + it("lets the refusal stand when the explaining probe fails", async () => { + const { transport } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => new Response("boom", { status: 500 })), + ); + const refusal = await failedRequest(transport); + + expect(await transport.explainRefusal(["remoteSync"], refusal)).toBe(refusal); + }); + + it("lets the refusal stand once the explaining probe outlasts the call's own timeout", async () => { + const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); + const refusal = await failedRequest(transport); + + expect(await transport.explainRefusal(["remoteSync"], refusal, { timeoutMs: 20 })).toBe( + refusal, + ); + stop(); + }); + + it("surfaces the caller's interrupt during the explaining probe rather than the refusal", async () => { + const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); + const refusal = await failedRequest(transport); + const controller = new AbortController(); + + const pending = transport + .explainRefusal(["remoteSync"], refusal, { signal: controller.signal }) + .catch((caught: unknown) => caught); + controller.abort(new Error("caller interrupt")); + + const error = await pending; + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("caller interrupt"); + stop(); + }); + + it("surfaces the client-wide interrupt during the explaining probe rather than the refusal", async () => { + const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); + const refusal = await failedRequest(transport); + + const pending = transport + .explainRefusal(["remoteSync"], refusal) + .catch((caught: unknown) => caught); + stop(); + + const error = await pending; + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("test over"); + }); + + it("explains nothing for a call that needs no feature", async () => { + const { transport, urls } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(UNLICENSED_63)), + ); + const refusal = await failedRequest(transport); + + expect(await transport.explainRefusal([], refusal)).toBe(refusal); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); }); }); diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index a2aa54d7..5f13caf4 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -1,15 +1,15 @@ import type { ZodType } from "zod"; -import { errorMessage, NetworkError, TimeoutError } from "../errors"; +import { SessionProperties } from "../domain/session-properties"; +import { AbortError, errorMessage, MetabaseError, NetworkError, TimeoutError } from "../errors"; import { JSON_CONTENT_TYPE } from "../json"; import { combineAborts, throwIfAborted, untilAborted } from "../signal"; import { normalizeUrl } from "../url"; import type { FeatureName } from "../version/features"; import { CapabilityError } from "../version/preflight-error"; -import { probeServer } from "../version/probe"; +import { probeProperties, serverInfoFromProperties, type ServerInfo } from "../version/probe"; import { createServerProfile, type ServerProfile, type Skew } from "../version/profile"; import { checkFeatures } from "../version/requirement-check"; -import { type MethodKey, methodRequirements } from "../version/requirements"; import { assertCredentialHeaderSafe, @@ -27,6 +27,11 @@ import type { RedactionContext } from "./sanitize"; const UNAUTHORIZED_STATUS = 401; +// What a server answers for a route it does not serve, or serves only under a token feature it +// lacks: unrouted, a verb the route lacks included (404), a path that lands on an older route's +// typed id (400), or the premium refusal (402). +const REFUSAL_STATUSES: ReadonlySet = new Set([400, 402, 404]); + export type HttpMethod = "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ExpectedContentType = "json" | "text" | "binary"; @@ -64,18 +69,39 @@ export interface Transport { requestRaw(path: string, opts?: TransportRequestOptions): Promise; requestStream(path: string, opts?: TransportRequestOptions): Promise>; // The profile handed in at construction, or else the one the first call probes and every later - // call shares. `signal` ends this caller's wait; the probe itself is the client's and is cancelled - // only by the client's own signal, so a later call still finds it settled. + // call shares. `signal` ends this caller's wait; the probe itself is the client's and is + // cancelled only by the client's own signal, so a later call still finds it settled. server(options?: WaitOptions): Promise; - // Throws `CapabilityError` when the server lacks a feature the method needs, before any request - // leaves. A method that needs nothing resolves without consulting the server. - require(key: MethodKey, options?: WaitOptions): Promise; - // The same refusal for features a method needs only because of the parameters it was handed, - // which the method table cannot express. An empty list resolves without consulting the server. + // A profile this client probed itself, never one handed in, for a verdict the server cannot give: + // a profile cached before an upgrade or a license change would refuse what the server allows. + verifiedServer(options?: WaitOptions): Promise; + // The session properties read afresh through `reader` and taken as this client's verified + // profile, so a resource needing a setting they carry pays one request for both. The read is the + // caller's, so it runs under the caller's `timeoutMs` and `retries` as any request does; `signal` + // ends only this caller's wait, because the profile the read settles is the client's. + probe(reader: ZodType, options?: ProbeReadOptions): Promise; + // Throws `CapabilityError` before the wire for features a method needs only because of the + // parameters it was handed, where a server without them would drop the parameter and answer as + // if it had never been sent. An empty list resolves without consulting the server. requireFeatures(features: readonly FeatureName[], options?: WaitOptions): Promise; + // The error a request failed with, as a `CapabilityError` when the server refused it for lacking + // one of `features`, and unchanged otherwise. The explaining probe waits no longer than the + // failed call's own `timeoutMs`, and the error stands when it runs out. + explainRefusal( + features: readonly FeatureName[], + error: unknown, + options?: ExplainOptions, + ): Promise; } export type WaitOptions = Pick; +export type ExplainOptions = Pick; +export type ProbeReadOptions = Pick; + +interface Probed { + properties: T; + profile: ServerProfile; +} export interface ClientCredentials { url: string; @@ -115,20 +141,25 @@ export interface ClientOptions { // has settled on; a caller that resolves the tag some other way passes its own. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; + // Called with every probe this client runs, so a caller that handed in a cached profile can keep + // its cache as current as the server's last answer. The calls waiting on the probe resume once + // the hook returns; the profile is settled before it is called, so a call the hook makes, or one + // that starts while it runs, reads that profile without waiting. A call that reads the session + // properties afresh (git-sync's tracked branch) is a probe of its own and calls the hook again, + // so the hook makes none. A hook that throws fails the calls waiting on the probe and leaves its + // profile settled, so one doing best-effort work catches its own failures. + onServerProbed?: (info: ServerInfo) => Promise | void; // Cancels every request this client makes; composed with the per-request `signal` and the // timeout. A process-level interrupt is the caller's to own and to hand over here. signal?: AbortSignal; - // `false` sends every method to the wire whatever the profile says, leaving the server to answer - // for itself. Defaults to `true`. - enforceRequirements?: boolean; } export function createTransport(config: ClientCredentials, options: ClientOptions): Transport { const baseUrl = normalizeUrl(config.url); assertCredentialHeaderSafe(config.credential); const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); - // The server every error names: the profile handed in, or else the one a probe has settled on. A - // pending probe is never awaited here — a shape error inside the probe would wait on its own parse. + // The server every error names: the profile handed in, or else the one a probe has settled on. + // A pending probe is never awaited here: a shape error inside the probe would wait on itself. let settledProfile: ServerProfile | null = options.server ?? null; const getServerTag = options.getServerTag ?? (async () => tagOf(settledProfile)); const serverSkew = (): Skew | null => (settledProfile === null ? null : settledProfile.skew); @@ -299,49 +330,130 @@ export function createTransport(config: ClientCredentials, options: ClientOption }; } - let serverProfile: Promise | null = - options.server === undefined ? null : Promise.resolve(options.server); + const handedIn = options.server === undefined ? null : Promise.resolve(options.server); + // The profile the newest settled probe read, and the newest probe a caller is waiting on until + // one has. Probes are numbered as they start, so one that settles after a later one has cannot + // put the older answer back. + let verified: ServerProfile | null = null; + let probing: Promise | null = null; + let startedProbes = 0; + let settledProbe = 0; + + // The profile settles before the hook runs, so a call the hook makes reads it instead of waiting + // on the hook, while a call already waiting on the probe resumes only once the hook has returned. + // A probe whose request fails is not memoized: the next call asks again rather than replaying + // one transient failure for the life of the client. A hook that throws has seen the profile, so + // the profile stays settled and the hook is not called for it again. + function startProbe( + reader: ZodType, + budget: Pick = {}, + ): Promise> { + startedProbes += 1; + const sequence = startedProbes; + const probed = probeProperties(transport, reader, budget).then(async (properties) => { + const info = serverInfoFromProperties(properties); + const profile = createServerProfile(info); + if (sequence > settledProbe) { + settledProbe = sequence; + verified = profile; + settledProfile = profile; + } + await options.onServerProbed?.(info); + return { properties, profile }; + }); + const profile = probed.then((settled) => settled.profile); + const release = (): void => { + if (probing === profile) { + probing = null; + } + }; + profile.then(release, release); + probing = profile; + return probed; + } + + function verifyingProbe(): Promise { + if (verified !== null) { + return Promise.resolve(verified); + } + return probing ?? startProbe(SessionProperties).then((probed) => probed.profile); + } - // A failed probe is not memoized: the next call asks again rather than replaying one transient - // failure for the life of the client. + // A verifying probe in flight is awaited so a shape read sees the newest server, but its failure + // falls back to the profile handed in rather than failing a read that profile can serve. async function server(wait: WaitOptions = {}): Promise { throwIfAborted(wait.signal); - if (serverProfile === null) { - const probe = probeServer(transport).then((info) => { - settledProfile = createServerProfile(info); - return settledProfile; - }); - probe.catch(() => { - serverProfile = null; - }); - serverProfile = probe; + if (handedIn === null || verified !== null) { + return untilAborted(verifyingProbe(), wait.signal); } - return untilAborted(serverProfile, wait.signal); + const settled = probing === null ? handedIn : probing.catch(fallBackToHandedIn(handedIn)); + return untilAborted(settled, wait.signal); } - const enforceRequirements = options.enforceRequirements ?? true; + async function verifiedServer(wait: WaitOptions = {}): Promise { + throwIfAborted(wait.signal); + return untilAborted(verifyingProbe(), wait.signal); + } + + async function probe( + reader: ZodType, + read: ProbeReadOptions = {}, + ): Promise { + throwIfAborted(read.signal); + const budget = { + timeoutMs: read.timeoutMs ?? DEFAULT_TIMEOUT_MS, + retries: read.retries ?? DEFAULT_MAX_RETRIES, + }; + const probed = await untilAborted(startProbe(reader, budget), read.signal); + return probed.properties; + } async function requireFeatures( features: readonly FeatureName[], wait: WaitOptions = {}, ): Promise { - if (!enforceRequirements || features.length === 0) { + if (features.length === 0) { return; } - const failure = checkFeatures(features, await server(wait)); + const failure = checkFeatures(features, await verifiedServer(wait)); if (failure !== null) { throw new CapabilityError(failure); } } - async function requireMethod(key: MethodKey, wait: WaitOptions = {}): Promise { - return requireFeatures(methodRequirements(key), wait); + // The server's own refusal is the verdict; the profile only names what was missing. A server that + // turns out to have every feature failed for some other reason, and its error stands. + async function explainRefusal( + features: readonly FeatureName[], + error: unknown, + explain: ExplainOptions = {}, + ): Promise { + if (features.length === 0 || !isRefusal(error)) { + return error; + } + const timeout = + explain.timeoutMs === undefined ? undefined : AbortSignal.timeout(explain.timeoutMs); + let profile: ServerProfile; + try { + profile = await verifiedServer({ signal: combineAborts(explain.signal, timeout) }); + } catch (probeError) { + // A probe that cannot answer leaves the refusal unexplained; an interrupt is the caller's. + const isInterrupt = probeError instanceof AbortError && timeout?.aborted !== true; + if (probeError instanceof MetabaseError && !isInterrupt) { + return error; + } + throw probeError; + } + const failure = checkFeatures(features, profile); + return failure === null ? error : new CapabilityError(failure); } const transport: Transport = { server, - require: requireMethod, + verifiedServer, + probe, requireFeatures, + explainRefusal, async requestRaw(path, opts) { return (await executeWithAuthRefresh(path, opts ?? {})).response; }, @@ -377,6 +489,25 @@ export function createTransport(config: ClientCredentials, options: ClientOption return transport; } +// A 404 for a missing row comes from a route the server serves, so no feature can be what it lacks. +function isRefusal(error: unknown): boolean { + if (!(error instanceof HttpError) || error.kind === "resource-missing") { + return false; + } + return REFUSAL_STATUSES.has(error.status); +} + +function fallBackToHandedIn( + handedIn: Promise, +): (error: unknown) => Promise { + return (error) => { + if (error instanceof MetabaseError && !(error instanceof AbortError)) { + return handedIn; + } + throw error; + }; +} + function tagOf(profile: ServerProfile | null): string | null { return profile === null ? null : profile.version.tag; } diff --git a/packages/client/src/paginate.test.ts b/packages/client/src/paginate.test.ts index fe4f27b6..7f82bd19 100644 --- a/packages/client/src/paginate.test.ts +++ b/packages/client/src/paginate.test.ts @@ -2,8 +2,14 @@ import { assert, describe, expect, it } from "vitest"; import { z, type ZodType } from "zod"; import { AbortError, ConfigError, errorMessage, InternalError } from "./errors"; -import { type Transport, type ClientCredentials, createTransport } from "./http/transport"; -import { TEST_USER_AGENT } from "./testing/fetch-capture"; +import { + type ClientCredentials, + createTransport, + type ExplainOptions, + type Transport, +} from "./http/transport"; +import { captureFetch, premiumRefusalResponse, TEST_USER_AGENT } from "./testing/fetch-capture"; +import type { FeatureName } from "./version/features"; import { type Page, type PaginateOptions, paginatePages } from "./paginate"; const CONFIG: ClientCredentials = { @@ -424,6 +430,32 @@ describe("paginate", () => { ]); }); + it("explains a refused page by the walk's features, waiting on the caller's own signal", async () => { + const transport = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: captureFetch([premiumRefusalResponse("Transforms")]).fetch, + }); + const asked: Array<{ features: readonly FeatureName[]; options: ExplainOptions | undefined }> = + []; + const explained = new Error("explained"); + const client: Transport = { + ...transport, + async explainRefusal(features, _error, options) { + asked.push({ features, options }); + return explained; + }, + }; + const controller = new AbortController(); + + const error = await collect(client, "/api/transform/run", Card, { + signal: controller.signal, + features: ["transforms"], + }).catch((caught: unknown) => caught); + + expect(error).toBe(explained); + expect(asked).toEqual([{ features: ["transforms"], options: { signal: controller.signal } }]); + }); + it("reports the server total on every page it yields", async () => { const handle = makeFakeFetch([ { body: { data: makeItems(4).slice(0, 2), total: 42 } }, diff --git a/packages/client/src/paginate.ts b/packages/client/src/paginate.ts index 926d3bde..9b436fa9 100644 --- a/packages/client/src/paginate.ts +++ b/packages/client/src/paginate.ts @@ -2,6 +2,7 @@ import { z, type ZodType } from "zod"; import { ConfigError, InternalError } from "./errors"; import type { Transport, QueryValue } from "./http/transport"; +import type { FeatureName } from "./version/features"; export const DEFAULT_PAGE_SIZE = 50; @@ -11,6 +12,9 @@ export interface PaginateOptions { offset?: number; max?: number; signal?: AbortSignal; + // What the listing needs from the server, so a page the server rejects for lacking one of them + // reads as that feature. + features?: readonly FeatureName[]; } export interface Page { @@ -45,10 +49,19 @@ export async function* paginatePages( const remaining = max - taken; const limit = Math.min(pageSize, remaining); const offset = start + taken; - const envelope = await client.requestParsed(envelopeSchema, path, { - query: { ...opts.query, limit, offset }, - ...(opts.signal !== undefined && { signal: opts.signal }), - }); + const wait = opts.signal === undefined ? {} : { signal: opts.signal }; + let envelope: PaginatedEnvelope; + try { + envelope = await client.requestParsed(envelopeSchema, path, { + query: { ...opts.query, limit, offset }, + ...wait, + }); + } catch (error) { + if (opts.features === undefined) { + throw error; + } + throw await client.explainRefusal(opts.features, error, wait); + } const items = envelope.data.length > remaining ? envelope.data.slice(0, remaining) : envelope.data; diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts index fa4179d8..35a60d68 100644 --- a/packages/client/src/resources/card.ts +++ b/packages/client/src/resources/card.ts @@ -39,7 +39,6 @@ export function cardResource(transport: Transport) { params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("card.list", options); const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, query: { f: params.f, model_id: params.model_id }, @@ -49,13 +48,11 @@ export function cardResource(transport: Transport) { /** Get one card by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.get", options); return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); } /** Create a card — a question, a model, or a metric — from a full card body. */ async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { - await transport.require("card.create", options); return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", @@ -69,7 +66,6 @@ export function cardResource(transport: Transport) { params: CardUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("card.update", options); return transport.requestParsed(Card, `/api/card/${id}`, { ...options, method: "PUT", @@ -79,7 +75,6 @@ export function cardResource(transport: Transport) { /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.archive", options); return update(id, { archived: true }, options); } @@ -89,7 +84,6 @@ export function cardResource(transport: Transport) { params: CardQueryParams, options: RequestOptions = {}, ): Promise { - await transport.require("card.query", options); return transport.requestParsed(CardQueryResult, `/api/card/${id}/query`, { ...options, method: "POST", @@ -111,7 +105,6 @@ export function cardResource(transport: Transport) { params: CardExportParams, options: RequestOptions = {}, ): Promise> { - await transport.require("card.exportQuery", options); await transport.requireFeatures( params.csv_include_bom ? ["exportCsvByteOrderMark"] : [], options, @@ -137,7 +130,6 @@ export function cardResource(transport: Transport) { * snippets its query references, plus the card's own metadata for a model or a native query. */ async function queryMetadata(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.queryMetadata", options); return transport.requestParsed(QueryMetadata, `/api/card/${id}/query_metadata`, { ...options, }); diff --git a/packages/client/src/resources/collection.ts b/packages/client/src/resources/collection.ts index a40d8b85..94d3627d 100644 --- a/packages/client/src/resources/collection.ts +++ b/packages/client/src/resources/collection.ts @@ -79,7 +79,6 @@ export function collectionResource(transport: Transport) { params: CollectionListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("collection.list", options); const data = await transport.requestParsed(CollectionApiList, "/api/collection", { ...options, query: COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER], @@ -89,7 +88,6 @@ export function collectionResource(transport: Transport) { /** Get one collection by id, entity id, or the `root`/`trash` alias. */ async function get(ref: CollectionId, options: RequestOptions = {}): Promise { - await transport.require("collection.get", options); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options }); } @@ -98,7 +96,6 @@ export function collectionResource(transport: Transport) { params: CollectionCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("collection.create", options); return transport.requestParsed(Collection, "/api/collection", { ...options, method: "POST", @@ -116,7 +113,6 @@ export function collectionResource(transport: Transport) { params: CollectionUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("collection.update", options); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options, method: "PUT", @@ -126,7 +122,6 @@ export function collectionResource(transport: Transport) { /** Archive (soft-delete) a collection. Metabase models this as an update, not its own endpoint. */ async function archive(ref: CollectionId, options: RequestOptions = {}): Promise { - await transport.require("collection.archive", options); return update(ref, { archived: true }, options); } @@ -139,7 +134,6 @@ export function collectionResource(transport: Transport) { params: CollectionItemListParams = {}, options: CollectionItemPageOptions = {}, ): AsyncIterable> { - await transport.require("collection.itemPages", options); yield* paginatePages(transport, `/api/collection/${refPath(ref)}/items`, CollectionItem, { query: { models: params.models, @@ -158,7 +152,6 @@ export function collectionResource(transport: Transport) { params: CollectionTreeParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("collection.tree", options); const data = await transport.requestParsed(CollectionTreeApiList, "/api/collection/tree", { ...options, query: { "include-library": params["include-library"] }, diff --git a/packages/client/src/resources/content-translation.ts b/packages/client/src/resources/content-translation.ts index cfde836b..4190891d 100644 --- a/packages/client/src/resources/content-translation.ts +++ b/packages/client/src/resources/content-translation.ts @@ -1,14 +1,16 @@ import { ContentTranslationUploadResult } from "../domain/content-translation"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; import { buildCsvFormData, type CsvFile } from "./csv-upload"; const CONTENT_TRANSLATION_PATH = "/api/ee/content-translation"; export function contentTranslationResource(transport: Transport) { + const explain = explainer(transport, "contentTranslation"); + /** Download the complete content translation dictionary as CSV. */ async function download(options: RequestOptions = {}): Promise> { - await transport.require("contentTranslation.download", options); return transport.requestStream(`${CONTENT_TRANSLATION_PATH}/csv`, options); } @@ -17,7 +19,6 @@ export function contentTranslationResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("contentTranslation.upload", options); const form = buildCsvFormData(file); return transport.requestParsed( ContentTranslationUploadResult, @@ -30,5 +31,8 @@ export function contentTranslationResource(transport: Transport) { ); } - return { download, upload }; + return { + download: explain("download", download), + upload: explain("upload", upload), + }; } diff --git a/packages/client/src/resources/dashboard.ts b/packages/client/src/resources/dashboard.ts index da53dc33..24c64678 100644 --- a/packages/client/src/resources/dashboard.ts +++ b/packages/client/src/resources/dashboard.ts @@ -77,7 +77,6 @@ export function dashboardResource(transport: Transport) { params: DashboardListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("dashboard.list", options); const data = await transport.requestParsed(DashboardApiList, "/api/dashboard", { ...options, query: { f: params.f }, @@ -87,7 +86,6 @@ export function dashboardResource(transport: Transport) { /** Get one dashboard by id, with its dashcards, tabs and parameters. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("dashboard.get", options); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options }); } @@ -99,7 +97,6 @@ export function dashboardResource(transport: Transport) { params: DashboardCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.create", options); return transport.requestParsed(Dashboard, "/api/dashboard", { ...options, method: "POST", @@ -116,7 +113,6 @@ export function dashboardResource(transport: Transport) { params: DashboardUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.update", options); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -139,7 +135,6 @@ export function dashboardResource(transport: Transport) { params: DashboardCopyInput = {}, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.copy", options); return transport.requestParsed(DashboardCopy, `/api/dashboard/${id}/copy`, { ...options, method: "POST", @@ -153,7 +148,6 @@ export function dashboardResource(transport: Transport) { * an archive is a state change on the dashboard itself, and its dashcards are not part of it. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("dashboard.archive", options); return transport.requestParsed(Dashboard, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -172,7 +166,6 @@ export function dashboardResource(transport: Transport) { params: DashcardPatchInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.updateDashcard", options); const dashboard = await get(dashboardId, options); const target = dashboard.dashcards.find((dashcard) => dashcard.id === dashcardId); if (target === undefined) { @@ -202,7 +195,6 @@ export function dashboardResource(transport: Transport) { dashcards: ReadonlyArray | null | undefined, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.checkCardReferences", options); const grouped = groupByCardId(collectCardReferences(dashcards)); const checks = await Promise.all( Array.from(grouped.entries()).map( @@ -252,7 +244,6 @@ export function dashboardResource(transport: Transport) { parameterId: string, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.parameterValues", options); return transport.requestParsed( ParameterValues, `/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}/values`, @@ -270,7 +261,6 @@ export function dashboardResource(transport: Transport) { query: string, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.searchParameterValues", options); const parameter = encodeURIComponent(parameterId); return transport.requestParsed( ParameterValues, diff --git a/packages/client/src/resources/database.ts b/packages/client/src/resources/database.ts index 1c17eec6..43ec4372 100644 --- a/packages/client/src/resources/database.ts +++ b/packages/client/src/resources/database.ts @@ -52,7 +52,6 @@ export function databaseResource(transport: Transport) { params: DatabaseListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("database.list", options); const response = await transport.requestParsed(DatabaseApiList, "/api/database", { ...options, query: { include: params.include, saved: params.saved }, @@ -66,7 +65,6 @@ export function databaseResource(transport: Transport) { params: DatabaseGetParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("database.get", options); return transport.requestParsed(Database, `/api/database/${id}`, { ...options, query: { include: params.include }, @@ -75,7 +73,6 @@ export function databaseResource(transport: Transport) { /** List the schema names in a database. */ async function schemas(id: number, options: RequestOptions = {}): Promise> { - await transport.require("database.schemas", options); const data = await transport.requestParsed( DatabaseApiSchemaList, `/api/database/${id}/schemas`, @@ -90,7 +87,6 @@ export function databaseResource(transport: Transport) { schema: string, options: RequestOptions = {}, ): Promise> { - await transport.require("database.schemaTables", options); const data = await transport.requestParsed( DatabaseApiSchemaTableList, `/api/database/${id}/schema/${encodeURIComponent(schema)}`, @@ -108,7 +104,6 @@ export function databaseResource(transport: Transport) { params: DatabaseSyncSchemaParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("database.syncSchema", options); const ack = await transport.requestParsed(DatabaseTaskAck, `/api/database/${id}/sync_schema`, { ...options, method: "POST", @@ -131,7 +126,6 @@ export function databaseResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("database.rescanValues", options); const ack = await transport.requestParsed( DatabaseTaskAck, `/api/database/${id}/rescan_values`, diff --git a/packages/client/src/resources/dataset.ts b/packages/client/src/resources/dataset.ts index cac15f2e..548a9649 100644 --- a/packages/client/src/resources/dataset.ts +++ b/packages/client/src/resources/dataset.ts @@ -1,5 +1,5 @@ import { CardQueryResult } from "../domain/card"; -import { type CompiledQuery, compiledQuerySchema, QueryMetadata } from "../domain/dataset"; +import { CompiledQuery, QueryMetadata } from "../domain/dataset"; import type { DatasetQuery, ExportFormat, VisualizationSettings } from "../domain/query"; import type { RequestOptions, Transport } from "../http/transport"; import { assertPivotedExport } from "./pivot-export"; @@ -22,7 +22,6 @@ export function datasetResource(transport: Transport) { * MBQL, or native — rather than a reference to a saved one, so nothing here is a card. */ async function query(body: unknown, options: RequestOptions = {}): Promise { - await transport.require("dataset.query", options); return transport.requestParsed(CardQueryResult, "/api/dataset", { ...options, method: "POST", @@ -39,9 +38,7 @@ export function datasetResource(transport: Transport) { params: DatasetNativeParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("dataset.native", options); - const { features } = await transport.server(options); - return transport.requestParsed(compiledQuerySchema(features), "/api/dataset/native", { + return transport.requestParsed(CompiledQuery, "/api/dataset/native", { ...options, method: "POST", body: { ...datasetQuery, pretty: params.pretty }, @@ -57,7 +54,6 @@ export function datasetResource(transport: Transport) { datasetQuery: DatasetQuery, options: RequestOptions = {}, ): Promise { - await transport.require("dataset.queryMetadata", options); return transport.requestParsed(QueryMetadata, "/api/dataset/query_metadata", { ...options, method: "POST", @@ -81,7 +77,6 @@ export function datasetResource(transport: Transport) { params: DatasetExportParams, options: RequestOptions = {}, ): Promise> { - await transport.require("dataset.exportQuery", options); await transport.requireFeatures( params.csv_include_bom ? ["exportCsvByteOrderMark"] : [], options, diff --git a/packages/client/src/resources/dependency.test.ts b/packages/client/src/resources/dependency.test.ts index 4731b871..a74e7afb 100644 --- a/packages/client/src/resources/dependency.test.ts +++ b/packages/client/src/resources/dependency.test.ts @@ -5,12 +5,16 @@ import type { ClientCredentials } from "../http/transport"; import type { Page } from "../paginate"; import { captureFetch, + type FetchScript, jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, thrownBy, - type FetchScript, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -257,8 +261,11 @@ describe("dependency resource wire requests", () => { ]); }); - it("refuses a server without the token feature before any request leaves", async () => { - const { mb, capture } = clientOver([], UNLICENSED_SERVER); + it("explains a premium refusal by a fresh probe as the missing dependencies token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Dependencies"), probeResponse(UNLICENSED_SERVER)], + UNLICENSED_SERVER, + ); const error = await thrownBy(() => mb.dependency.graph("card", 7)); @@ -275,11 +282,17 @@ describe("dependency resource wire requests", () => { tokenFeature: "dependencies", serverVersion: "v1.64.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/dependencies/graph?type=card&id=7", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses the item listings on a licensed server below their floor, naming the feature", async () => { - const { mb, capture } = clientOver([], V58_SERVER); + it("explains an unrouted item listing on a licensed server below its floor, naming the feature", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(V58_SERVER)], + V58_SERVER, + ); const error = await thrownBy(() => mb.dependency.dependents("table", 3)); @@ -296,7 +309,10 @@ describe("dependency resource wire requests", () => { tokenFeature: "dependencies", serverVersion: "v1.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/dependencies/graph/dependents?type=table&id=3", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); it("lists dependents on the first generation with the item listings", async () => { diff --git a/packages/client/src/resources/dependency.ts b/packages/client/src/resources/dependency.ts index a54d11f7..96865024 100644 --- a/packages/client/src/resources/dependency.ts +++ b/packages/client/src/resources/dependency.ts @@ -14,6 +14,8 @@ import type { SortDirection } from "../domain/query"; import type { QueryValue, RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; +import { methodRequirements } from "../version/requirements"; +import { explainer } from "../version/refusal"; const DependencyNodeApiList = z.array(DependencyNode); const DependencyEntityApiList = z.array(DependencyEntity); @@ -42,6 +44,8 @@ export interface DependencyItemListParams { export type DependencyItemPageOptions = Omit; export function dependencyResource(transport: Transport) { + const explain = explainer(transport, "dependency"); + /** * The upstream dependency graph of one entity. `nodes` holds the starting entity plus every * entity it depends on, directly or transitively; each edge runs from the dependent to what it @@ -52,10 +56,6 @@ export function dependencyResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("dependency.graph", options); - if (type === "measure") { - await transport.requireFeatures(["measureDependencyGraph"], options); - } return transport.requestParsed(DependencyGraph, "/api/ee/dependencies/graph", { ...options, query: { type, id }, @@ -74,7 +74,6 @@ export function dependencyResource(transport: Transport) { params: DependencyDependentsParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("dependency.dependents", options); const data = await transport.requestParsed( DependencyNodeApiList, "/api/ee/dependencies/graph/dependents", @@ -108,7 +107,6 @@ export function dependencyResource(transport: Transport) { params: DependencyBrokenParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("dependency.broken", options); const data = await transport.requestParsed( DependencyEntityApiList, "/api/ee/dependencies/graph/broken", @@ -141,7 +139,6 @@ export function dependencyResource(transport: Transport) { params: DependencyItemListParams = {}, options: DependencyItemPageOptions = {}, ): AsyncIterable> { - await transport.require("dependency.unreferencedPages", options); if (queryLeavesNoKind(params)) { yield { items: [], total: 0 }; return; @@ -152,6 +149,7 @@ export function dependencyResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), + features: methodRequirements("dependency.unreferencedPages"), }); } @@ -166,7 +164,6 @@ export function dependencyResource(transport: Transport) { params: DependencyItemListParams = {}, options: DependencyItemPageOptions = {}, ): AsyncIterable> { - await transport.require("dependency.breakingPages", options); if (queryLeavesNoKind(params)) { yield { items: [], total: 0 }; return; @@ -177,10 +174,19 @@ export function dependencyResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), + features: methodRequirements("dependency.breakingPages"), }); } - return { graph, dependents, broken, unreferencedPages, breakingPages }; + return { + graph: explain("graph", graph, (type) => + type === "measure" ? ["measureDependencyGraph"] : [], + ), + dependents: explain("dependents", dependents), + broken: explain("broken", broken), + unreferencedPages: unreferencedPages, + breakingPages: breakingPages, + }; } // The server drops sandboxes from the kinds a `query` searches and builds one SQL union branch per diff --git a/packages/client/src/resources/document.test.ts b/packages/client/src/resources/document.test.ts index 0e954d0a..fab32aaf 100644 --- a/packages/client/src/resources/document.test.ts +++ b/packages/client/src/resources/document.test.ts @@ -2,8 +2,16 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + routeMissingResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -182,8 +190,11 @@ describe("document resource wire requests", () => { ]); }); - it("refuses the copy before the wire on a server without the route", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("explains an unrouted copy by a fresh probe on a server without the route", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(SERVER_58)], + SERVER_58, + ); const error = await thrownBy(() => mb.document.copy(4)); @@ -197,6 +208,9 @@ describe("document resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.2", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/document/4/copy", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/document.ts b/packages/client/src/resources/document.ts index 7aa47d28..4cd08333 100644 --- a/packages/client/src/resources/document.ts +++ b/packages/client/src/resources/document.ts @@ -8,15 +8,17 @@ import { } from "../domain/document"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; // `GET /api/document` wraps its rows in `{ items }` and reports no count, so the total a caller // reads off `ListResult` is the array's own length. const DocumentApiList = z.object({ items: z.array(Document) }).loose(); export function documentResource(transport: Transport) { + const explain = explainer(transport, "document"); + /** Get existing documents. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("document.list", options); const response = await transport.requestParsed(DocumentApiList, "/api/document", { ...options, }); @@ -25,7 +27,6 @@ export function documentResource(transport: Transport) { /** Return an existing document by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("document.get", options); return transport.requestParsed(Document, `/api/document/${id}`, { ...options }); } @@ -34,7 +35,6 @@ export function documentResource(transport: Transport) { params: DocumentCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("document.create", options); return transport.requestParsed(Document, "/api/document", { ...options, method: "POST", @@ -48,7 +48,6 @@ export function documentResource(transport: Transport) { params: DocumentUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("document.update", options); return transport.requestParsed(Document, `/api/document/${id}`, { ...options, method: "PUT", @@ -58,7 +57,6 @@ export function documentResource(transport: Transport) { /** Archive (soft-delete) a document by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("document.archive", options); return update(id, { archived: true }, options); } @@ -71,7 +69,6 @@ export function documentResource(transport: Transport) { params: DocumentCopyInput = {}, options: RequestOptions = {}, ): Promise { - await transport.require("document.copy", options); return transport.requestParsed(Document, `/api/document/${id}/copy`, { ...options, method: "POST", @@ -79,5 +76,5 @@ export function documentResource(transport: Transport) { }); } - return { list, get, create, update, archive, copy }; + return { list, get, create, update, archive, copy: explain("copy", copy) }; } diff --git a/packages/client/src/resources/eid-translation.ts b/packages/client/src/resources/eid-translation.ts index 5f5465f2..441079a7 100644 --- a/packages/client/src/resources/eid-translation.ts +++ b/packages/client/src/resources/eid-translation.ts @@ -11,7 +11,6 @@ export function eidTranslationResource(transport: Transport) { params: EidTranslateInput, options: RequestOptions = {}, ): Promise { - await transport.require("eidTranslation.translate", options); return transport.requestParsed(EidTranslateResult, "/api/eid-translation/translate", { ...options, method: "POST", diff --git a/packages/client/src/resources/erd.test.ts b/packages/client/src/resources/erd.test.ts index a507de4e..7ec67f13 100644 --- a/packages/client/src/resources/erd.test.ts +++ b/packages/client/src/resources/erd.test.ts @@ -2,7 +2,16 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; +import { PROBE_PATH } from "../version/probe"; import { CapabilityError } from "../version/preflight-error"; import { createServerProfile } from "../version/profile"; @@ -110,8 +119,20 @@ describe("erd resource wire requests", () => { expect(await mb.erd.get({ "database-id": 1 })).toEqual(ERD); }); - it("refuses before the wire on a server without the schema viewer token", async () => { - const { mb, capture } = clientOver([], UNLICENSED_62); + it("sends the request a cached profile without the token would have refused", async () => { + const { mb, capture } = clientOver([jsonResponse(ERD)], UNLICENSED_62); + + expect(await mb.erd.get({ "database-id": 1 })).toEqual(ERD); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/erd?database-id=1", + ]); + }); + + it("explains a premium refusal by a fresh probe as the missing schema viewer token", async () => { + const { mb, capture } = clientOver([ + premiumRefusalResponse("Schema Viewer"), + probeResponse(UNLICENSED_62), + ]); const error = await thrownBy(() => mb.erd.get({ "database-id": 1 })); @@ -125,11 +146,14 @@ describe("erd resource wire requests", () => { tokenFeature: "schema-viewer", serverVersion: "v1.62.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/erd?database-id=1", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses before the wire on a server older than the route, whatever its token grants", async () => { - const { mb, capture } = clientOver([], LICENSED_61); + it("explains an unrouted path on a server older than the route, whatever its token grants", async () => { + const { mb, capture } = clientOver([routeMissingResponse(), probeResponse(LICENSED_61)]); const error = await thrownBy(() => mb.erd.get({ "database-id": 1 })); @@ -143,6 +167,9 @@ describe("erd resource wire requests", () => { tokenFeature: "schema-viewer", serverVersion: "v1.61.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/erd?database-id=1", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/erd.ts b/packages/client/src/resources/erd.ts index 7164a464..064f214c 100644 --- a/packages/client/src/resources/erd.ts +++ b/packages/client/src/resources/erd.ts @@ -1,5 +1,6 @@ import { Erd } from "../domain/erd"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; const ERD_PATH = "/api/ee/erd"; @@ -10,6 +11,8 @@ export interface ErdParams { } export function erdResource(transport: Transport) { + const explain = explainer(transport, "erd"); + /** * Get the entity relationship diagram of a database: the readable tables as nodes, each with its * fields, and the foreign keys between them as edges. `table-ids` makes those tables the focal @@ -17,7 +20,6 @@ export function erdResource(transport: Transport) { * none. */ async function get(params: ErdParams, options: RequestOptions = {}): Promise { - await transport.require("erd.get", options); return transport.requestParsed(Erd, ERD_PATH, { ...options, query: { @@ -28,5 +30,5 @@ export function erdResource(transport: Transport) { }); } - return { get }; + return { get: explain("get", get) }; } diff --git a/packages/client/src/resources/field.test.ts b/packages/client/src/resources/field.test.ts index a3c14cb3..748b3056 100644 --- a/packages/client/src/resources/field.test.ts +++ b/packages/client/src/resources/field.test.ts @@ -2,8 +2,15 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -108,12 +115,21 @@ describe("field resource wire requests", () => { expect(capture.calls.map((call) => call.body)).toEqual(['{"semantic_type":"type/Price"}']); }); - it("sends the sensitivity label as a PUT carrying only that key", async () => { - const { mb, capture } = clientOver([jsonResponse({ ...FIELD, data_sensitivity: "PII" })]); + it("sends the sensitivity label as a PUT carrying only that key, once a fresh probe finds the column", async () => { + const { mb, capture } = clientOver([ + probeResponse(SERVER_64), + jsonResponse({ ...FIELD, data_sensitivity: "PII" }), + ]); await mb.field.update(101, { data_sensitivity: "PII" }); expect(capture.calls).toEqual([ + { + url: `https://mb.example.com/metabase${PROBE_PATH}`, + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, { url: "https://mb.example.com/metabase/api/field/101", method: "PUT", @@ -124,15 +140,18 @@ describe("field resource wire requests", () => { }); it("sends a withdrawn sensitivity label as null", async () => { - const { mb, capture } = clientOver([jsonResponse({ ...FIELD, data_sensitivity: null })]); + const { mb, capture } = clientOver([ + probeResponse(SERVER_64), + jsonResponse({ ...FIELD, data_sensitivity: null }), + ]); await mb.field.update(101, { data_sensitivity: null }); - expect(capture.calls.map((call) => call.body)).toEqual(['{"data_sensitivity":null}']); + expect(capture.calls.map((call) => call.body)).toEqual([null, '{"data_sensitivity":null}']); }); - it("refuses the sensitivity label before the wire on a server without the column", async () => { - const { mb, capture } = clientOver([], SERVER_63); + it("refuses the sensitivity label before the wire on a server a fresh probe finds without the column", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_63)]); const error = await thrownBy(() => mb.field.update(101, { data_sensitivity: "PII" })); @@ -146,7 +165,9 @@ describe("field resource wire requests", () => { tokenFeature: null, serverVersion: "v0.63.4", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); it("sends the search request with the value and limit as the query string", async () => { diff --git a/packages/client/src/resources/field.ts b/packages/client/src/resources/field.ts index 51827970..337ddf29 100644 --- a/packages/client/src/resources/field.ts +++ b/packages/client/src/resources/field.ts @@ -52,7 +52,6 @@ export function fieldResource(transport: Transport) { * for an unlabelled field; any other answers the field without the key (`hasDataSensitivity`). */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.get", options); const { features } = await transport.server(options); return transport.requestParsed(fieldDetailSchema(features), `/api/field/${id}`, { ...options }); } @@ -68,7 +67,6 @@ export function fieldResource(transport: Transport) { params: FieldUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("field.update", options); await transport.requireFeatures( params.data_sensitivity === undefined ? [] : ["fieldDataSensitivity"], options, @@ -83,15 +81,16 @@ export function fieldResource(transport: Transport) { /** * Label a field's data sensitivity by hand. A person's label is never overwritten by the server's - * classifier; `null` withdraws it, so whatever label the classifier wrote shows again. Answers - * the field with the label it now carries. + * classifier; `null` withdraws it, so whatever label the classifier wrote shows again. A server + * without the column drops the key silently, so the label is refused there before the wire. + * Answers the field with the label it now carries. */ async function setDataSensitivity( id: number, params: FieldSetDataSensitivityParams, options: RequestOptions = {}, ): Promise { - await transport.require("field.setDataSensitivity", options); + await transport.requireFeatures(["fieldDataSensitivity"], options); return transport.requestParsed(FieldWithDataSensitivity, `/api/field/${id}`, { ...options, method: "PUT", @@ -113,7 +112,6 @@ export function fieldResource(transport: Transport) { params: FieldSearchParams, options: RequestOptions = {}, ): Promise { - await transport.require("field.search", options); return transport.requestParsed(FieldSearchMatches, `/api/field/${id}/search/${searchId}`, { ...options, query: { value: params.value, limit: params.limit }, @@ -131,7 +129,6 @@ export function fieldResource(transport: Transport) { params: FieldRemappingParams, options: RequestOptions = {}, ): Promise { - await transport.require("field.remapping", options); const { features } = await transport.server(options); return fetchOptionalParsed( transport, @@ -143,7 +140,6 @@ export function fieldResource(transport: Transport) { /** Get the row count and the distinct-value count for a field. */ async function summary(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.summary", options); const [[, count], [, distincts]] = await transport.requestParsed( FieldApiSummary, `/api/field/${id}/summary`, @@ -154,9 +150,16 @@ export function fieldResource(transport: Transport) { /** Get the cached distinct values Metabase holds for a field. */ async function values(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.values", options); return transport.requestParsed(FieldValues, `/api/field/${id}/values`, { ...options }); } - return { get, update, setDataSensitivity, search, remapping, summary, values }; + return { + get, + update, + setDataSensitivity, + search, + remapping, + summary, + values, + }; } diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index bd3155ba..93faaefa 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -4,8 +4,17 @@ import { createClient } from "../client"; import { ConfigError } from "../errors"; import { HttpError } from "../http/errors"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -86,6 +95,14 @@ const SERVER_WITH_PREFLIGHT = createServerProfile({ tokenFeatures: { remote_sync: true }, }); +const UNLICENSED_SERVER = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: false }, +}); + function clientOver(responses: Array, server: ServerProfile = SERVER) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { @@ -96,6 +113,23 @@ function clientOver(responses: Array, server: ServerProfile = SERVER) return { mb, capture }; } +const PROBE_URL = `https://mb.example.com/metabase${PROBE_PATH}`; + +const PROPERTIES_READ = { url: PROBE_URL, method: "GET", headers: JSON_READ_HEADERS, body: null }; + +// The session properties `profile`'s server answers, carrying the settings in `settings`. +function sessionProperties(settings: object, profile: ServerProfile = SERVER): Response { + return jsonResponse({ version: { tag: profile.version.tag }, ...settings }); +} + +// The session properties an admin reads on a server tracking `branch`. +function trackedBranchResponse(branch: string): Response { + return sessionProperties({ + "remote-sync-branch": branch, + "token-features": { remote_sync: true }, + }); +} + function noContent(): Response { return new Response(null, { status: 204 }); } @@ -223,25 +257,185 @@ describe("git-sync resource wire requests", () => { ]); }); - it("sends the import request with the branch and force fields it was given", async () => { + it("sends the import request with the branch and force fields it was given, expecting the tracked branch", async () => { const { mb, capture } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), ]); - await mb.gitSync.import({ branch: "main", force: true }); + await mb.gitSync.import({ branch: "release", force: true }); expect(capture.calls).toEqual([ + PROPERTIES_READ, { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: '{"branch":"main","force":true}', + body: '{"branch":"release","force":true,"expected_branch":"main"}', }, ]); }); + it("sends the expected branch it was given without reading the setting", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + await mb.gitSync.import({ expected_branch: "dev" }); + + expect(capture.calls.map((call) => call.body)).toEqual(['{"expected_branch":"dev"}']); + }); + + it("sends the tracked branch to a server before the branch guard, which ignores it", async () => { + const { mb, capture } = clientOver([ + trackedBranchResponse("main"), + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + await mb.gitSync.import(); + + expect(capture.calls.map((call) => call.body)).toEqual([null, '{"expected_branch":"main"}']); + }); + + it("sends no expected branch where the setting is hidden on a server without remote sync, and explains its refusal", async () => { + const { mb, capture } = clientOver([ + sessionProperties({ "token-features": { remote_sync: false } }, UNLICENSED_SERVER), + premiumRefusalResponse("Remote Sync"), + ]); + + const error = await thrownBy(() => mb.gitSync.import()); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 58, + tokenFeature: "remote_sync", + serverVersion: "v1.63.0", + }); + expect(capture.calls.map((call) => [call.url, call.body])).toEqual([ + [PROBE_URL, null], + ["https://mb.example.com/metabase/api/ee/remote-sync/import", "{}"], + ]); + }); + + it("sends no expected branch to a licensed-out server whose setting is unset, and explains its refusal", async () => { + const { mb, capture } = clientOver([ + sessionProperties( + { "remote-sync-branch": null, "token-features": { remote_sync: false } }, + UNLICENSED_SERVER, + ), + premiumRefusalResponse("Remote Sync"), + ]); + + const error = await thrownBy(() => mb.gitSync.export()); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail.feature).toBe("remoteSync"); + expect(capture.calls.map((call) => [call.url, call.body])).toEqual([ + [PROBE_URL, null], + ["https://mb.example.com/metabase/api/ee/remote-sync/export", "{}"], + ]); + }); + + it("refuses an import for a caller who cannot read the setting on a server granting remote sync", async () => { + const { mb, capture } = clientOver([ + sessionProperties({ "token-features": { remote_sync: true } }), + ]); + + const error = await thrownBy(() => mb.gitSync.import()); + + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe( + "the remote-sync-branch setting is not readable: it is visible to admins only, and absent on a server without the remote-sync module", + ); + expect(capture.calls).toEqual([PROPERTIES_READ]); + }); + + it("refuses an import when the server tracks no branch", async () => { + const { mb, capture } = clientOver([ + sessionProperties({ "remote-sync-branch": null, "token-features": { remote_sync: true } }), + ]); + + const error = await thrownBy(() => mb.gitSync.import()); + + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe( + "git-sync tracks no branch: the remote-sync-branch setting is unset", + ); + expect(capture.calls).toEqual([PROPERTIES_READ]); + }); + + it("refuses a merge before the wire on a server a fresh probe finds without three-way merge", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER)], SERVER_WITH_PREFLIGHT); + + const error = await thrownBy(() => mb.gitSync.import({ merge: true })); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v63+ (this server is v1.60.0). Upgrade Metabase to use it.", + feature: "remoteSyncMerge", + since: 63, + tokenFeature: "remote_sync", + serverVersion: "v1.60.0", + }); + expect(capture.calls).toEqual([PROPERTIES_READ]); + }); + + it("judges a merge by the branch read, sending nothing more before the import", async () => { + const { mb, capture } = clientOver([ + sessionProperties( + { "remote-sync-branch": "main", "token-features": { remote_sync: true } }, + SERVER_WITH_PREFLIGHT, + ), + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + await mb.gitSync.import({ merge: true }); + + expect(capture.calls.map((call) => [call.url, call.body])).toEqual([ + [PROBE_URL, null], + [ + "https://mb.example.com/metabase/api/ee/remote-sync/import", + '{"merge":true,"expected_branch":"main"}', + ], + ]); + }); + + it("explains a refused import from its own branch read, without probing again", async () => { + const { mb, capture } = clientOver([ + sessionProperties( + { "remote-sync-branch": "main", "token-features": { remote_sync: false } }, + UNLICENSED_SERVER, + ), + premiumRefusalResponse("Remote Sync"), + ]); + + const error = await thrownBy(() => mb.gitSync.import()); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 58, + tokenFeature: "remote_sync", + serverVersion: "v1.63.0", + }); + expect(capture.calls.map((call) => [call.url, call.body])).toEqual([ + [PROBE_URL, null], + ["https://mb.example.com/metabase/api/ee/remote-sync/import", '{"expected_branch":"main"}'], + ]); + }); + it("returns the started import without a final task when no wait is given", async () => { const { mb } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), ]); @@ -250,6 +444,7 @@ describe("git-sync resource wire requests", () => { it("polls the current task after the import POST when a wait schedule is given", async () => { const { mb, capture } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), jsonResponse(RUNNING_TASK), jsonResponse(SETTLED_TASK), @@ -258,11 +453,12 @@ describe("git-sync resource wire requests", () => { await mb.gitSync.import({ wait: IMMEDIATE_POLL }); expect(capture.calls).toEqual([ + PROPERTIES_READ, { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: "{}", + body: '{"expected_branch":"main"}', }, { url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", @@ -281,6 +477,7 @@ describe("git-sync resource wire requests", () => { it("reports the settled task alongside the import that started it", async () => { const { mb } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), jsonResponse(SETTLED_TASK), ]); @@ -294,17 +491,19 @@ describe("git-sync resource wire requests", () => { it("does not poll for an import the server answered with no task to wait on", async () => { const { mb, capture } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ status: "success", task_id: null, message: "Already up to date" }), ]); await mb.gitSync.import({ wait: IMMEDIATE_POLL }); expect(capture.calls).toEqual([ + PROPERTIES_READ, { url: "https://mb.example.com/metabase/api/ee/remote-sync/import", method: "POST", headers: JSON_WRITE_HEADERS, - body: "{}", + body: '{"expected_branch":"main"}', }, ]); }); @@ -324,8 +523,23 @@ describe("git-sync resource wire requests", () => { ]); }); + it("sends the tracked branch as the export's branch when none is given", async () => { + const { mb, capture } = clientOver([ + trackedBranchResponse("main"), + jsonResponse({ message: "Export queued", task_id: 8 }), + ]); + + await mb.gitSync.export({ message: "update dashboards" }); + + expect(capture.calls.map((call) => call.body)).toEqual([ + null, + '{"branch":"main","message":"update dashboards"}', + ]); + }); + it("reports the settled task alongside the export that started it", async () => { const { mb } = clientOver([ + trackedBranchResponse("main"), jsonResponse({ message: "Export queued", task_id: 8 }), jsonResponse(SETTLED_TASK), ]); @@ -366,8 +580,8 @@ describe("git-sync resource wire requests", () => { expect(await mb.gitSync.exportPreflight({ branch: "main" })).toEqual(diverged); }); - it("refuses the export preflight before the wire on a server older than the route", async () => { - const { mb, capture } = clientOver([]); + it("explains an unrouted export preflight on a server older than the route", async () => { + const { mb, capture } = clientOver([routeMissingResponse(), probeResponse(SERVER)]); const error = await thrownBy(() => mb.gitSync.exportPreflight({ branch: "main" })); @@ -381,7 +595,28 @@ describe("git-sync resource wire requests", () => { tokenFeature: "remote_sync", serverVersion: "v1.60.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/remote-sync/export-preflight?branch=main", + PROBE_URL, + ]); + }); + + it("explains a refused preflight of the tracked branch by the branch read, in two requests", async () => { + const { mb, capture } = clientOver( + [trackedBranchResponse("main"), routeMissingResponse()], + SERVER_WITH_PREFLIGHT, + ); + + const error = await thrownBy(async () => + mb.gitSync.exportPreflight({ branch: await mb.gitSync.trackedBranch() }), + ); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail.feature).toBe("remoteSyncExportPreflight"); + expect(capture.calls.map((call) => call.url)).toEqual([ + PROBE_URL, + "https://mb.example.com/metabase/api/ee/remote-sync/export-preflight?branch=main", + ]); }); it("surfaces a branch mismatch as the server's conflict answer", async () => { @@ -597,7 +832,7 @@ describe("git-sync resource wire requests", () => { }); it("reads the tracked branch off the session properties", async () => { - const { mb, capture } = clientOver([jsonResponse({ "remote-sync-branch": "main" })]); + const { mb, capture } = clientOver([sessionProperties({ "remote-sync-branch": "main" })]); await mb.gitSync.branch(); @@ -612,19 +847,19 @@ describe("git-sync resource wire requests", () => { }); it("answers the effective branch, an environment-set one included", async () => { - const { mb } = clientOver([jsonResponse({ "remote-sync-branch": "main" })]); + const { mb } = clientOver([sessionProperties({ "remote-sync-branch": "main" })]); expect(await mb.gitSync.branch()).toBe("main"); }); it("reads an unconfigured branch setting's null as no branch", async () => { - const { mb } = clientOver([jsonResponse({ "remote-sync-branch": null })]); + const { mb } = clientOver([sessionProperties({ "remote-sync-branch": null })]); expect(await mb.gitSync.branch()).toBeNull(); }); it("refuses rather than reading a setting the caller may not see as unset", async () => { - const { mb } = clientOver([jsonResponse({ "site-name": "Metabase" })]); + const { mb } = clientOver([sessionProperties({ "site-name": "Metabase" })]); const error = await thrownBy(() => mb.gitSync.branch()); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 78bf68e9..4d04db9f 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -13,12 +13,13 @@ import { type SyncStashResult, SyncTask, } from "../domain/git-sync"; +import { SessionProperties } from "../domain/session-properties"; import { ConfigError } from "../errors"; import { HttpError } from "../http/errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type PollOptions, pollUntil } from "../poll"; -import { PROBE_PATH } from "../version/probe"; +import { explainer } from "../version/refusal"; import { listCollectionsAs } from "./collection"; import { fetchOptionalParsed } from "./optional-parsed"; @@ -60,10 +61,18 @@ const RemoteSyncSetting = z.string().nullable(); // `/api/setting/remote-sync-branch` answers nothing for a branch set by environment variable, while // the session properties carry every setting's effective value, and carry a setting only when the -// caller may read it: an absent key is "not readable", a null one "unset". -const RemoteSyncBranchProperty = z.object({ +// caller may read it: an absent key is "not readable", a null one "unset". They are read as a +// probe, so the merge check and a refusal's explanation that follow the read need no request of +// their own. +const RemoteSyncBranchProperty = SessionProperties.extend({ "remote-sync-branch": z.string().nullable().optional(), }); +type RemoteSyncBranchProperty = z.infer; + +const BRANCH_UNREADABLE_MESSAGE = + "the remote-sync-branch setting is not readable: it is visible to admins only, " + + "and absent on a server without the remote-sync module"; +const BRANCH_UNSET_MESSAGE = "git-sync tracks no branch: the remote-sync-branch setting is unset"; const FORBIDDEN_STATUS = 403; const UNREGISTERED_STATUS = 404; @@ -77,8 +86,8 @@ function isRemoteUnreadable(error: unknown): boolean { return error.status === FORBIDDEN_STATUS || error.status === UNREGISTERED_STATUS; } -// Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition is -// the server's. +// Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition +// is the server's. export interface SyncWaitParams { wait?: PollOptions | undefined; } @@ -115,15 +124,15 @@ export interface SyncCreateBranchParams { } export function gitSyncResource(transport: Transport) { + const explain = explainer(transport, "gitSync"); + /** Get the running or most recently finished sync task, or null when the server has none. */ async function currentTask(options: RequestOptions = {}): Promise { - await transport.require("gitSync.currentTask", options); return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, options); } /** Request cancellation of the running sync task, and answer it in the state that left it. */ async function cancelTask(options: RequestOptions = {}): Promise { - await transport.require("gitSync.cancelTask", options); return transport.requestParsed(SyncTask, "/api/ee/remote-sync/current-task/cancel", { ...options, method: "POST", @@ -132,7 +141,6 @@ export function gitSyncResource(transport: Transport) { /** Whether Metabase holds content changes the remote has not been told about. */ async function isDirty(options: RequestOptions = {}): Promise { - await transport.require("gitSync.isDirty", options); const flag = await transport.requestParsed(SyncDirtyFlag, "/api/ee/remote-sync/is-dirty", { ...options, }); @@ -141,7 +149,6 @@ export function gitSyncResource(transport: Transport) { /** List the objects whose local state differs from the remote. */ async function dirty(options: RequestOptions = {}): Promise> { - await transport.require("gitSync.dirty", options); const response = await transport.requestParsed(SyncDirtyList, "/api/ee/remote-sync/dirty", { ...options, }); @@ -156,7 +163,6 @@ export function gitSyncResource(transport: Transport) { params: SyncRemoteChangesParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.hasRemoteChanges", options); return transport.requestParsed(SyncRemoteChanges, "/api/ee/remote-sync/has-remote-changes", { ...options, query: { "force-refresh": params["force-refresh"] }, @@ -168,17 +174,15 @@ export function gitSyncResource(transport: Transport) { * pass `wait` to poll that task until it reaches a terminal status. A server already up to date * answers no task id, and there is then nothing to poll. A server that guards the tracked branch * refuses with 409 when `expected_branch` disagrees with the setting; left out, it is the tracked - * branch as read just before the request. `merge` folds the remote's changes into local content - * by a three-way merge and keeps un-pushed local changes, where a plain import refuses on a dirty - * instance. + * branch as read just before the request, which a server without the guard ignores. `merge` folds + * the remote's changes into local content by a three-way merge and keeps un-pushed local changes, + * where a plain import refuses on a dirty instance. */ async function importFromRemote( params: SyncImportParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.import", options); - await requireMerge(params.merge, options); - const expectedBranch = await guardedBranch(params.expected_branch, options); + const expectedBranch = await syncBranch(params.expected_branch, params.merge, options); const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { ...options, method: "POST", @@ -196,44 +200,40 @@ export function gitSyncResource(transport: Transport) { return { message, task_id: started.task_id, final: await settle(params.wait, options) }; } - async function requireMerge(merge: boolean | undefined, options: RequestOptions): Promise { - if (merge === true) { - await transport.requireFeatures(["remoteSyncMerge"], options); - } - } - - // A server that guards the tracked branch rejects an import or export that does not name it; an - // older one falls back to the setting by itself, so the setting is read only where it is checked. - async function guardedBranch( + // The tracked branch, which a server guarding it requires on an import and an export, and an + // older one ignores or takes as its own default. Where the server grants no remote sync there is + // no branch to send, and its own refusal — a route it lacks, a premium feature it lacks — says + // why. Where it does, an unset setting leaves nothing to sync, and a setting it does not show the + // caller means a caller who is not an admin, which every remote-sync route requires, while a + // guarding server would reject the missing branch before saying so. The merge check waits for the + // branch read, which is the probe it judges by. + async function syncBranch( given: string | undefined, + merge: boolean | undefined, options: RequestOptions, ): Promise { - if (given !== undefined) { - return given; - } - const { features } = await transport.server(options); - if (!features.remoteSyncBranchGuard) { - return undefined; + const properties = + given === undefined ? await transport.probe(RemoteSyncBranchProperty, options) : null; + if (merge === true) { + await transport.requireFeatures(["remoteSyncMerge"], options); } - return trackedBranch(options); + return properties === null ? given : defaultBranch(properties); } /** * Export Metabase's content to the remote. The endpoint queues a task and returns at once; pass * `wait` to poll that task until it reaches a terminal status. A server that guards the tracked - * branch refuses with 409 when `branch` is not the tracked one; left out, it is the tracked branch - * as read just before the request there, and the server's own default elsewhere. When the remote - * has moved past the last sync, a plain export ends in a `conflict` task where `merge` is - * available, and is refused with 400 before any task elsewhere; `merge` folds the remote's changes - * in by a three-way merge instead, and `force` overwrites them. + * branch refuses with 409 when `branch` is not the tracked one; left out, it is the tracked + * branch as read just before the request, which is the default of a server without the guard. + * When the remote has moved past the last sync, a plain export ends in a `conflict` task where + * `merge` is available, and is refused with 400 before any task elsewhere; `merge` folds the + * remote's changes in by a three-way merge instead, and `force` overwrites them. */ async function exportToRemote( params: SyncExportParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.export", options); - await requireMerge(params.merge, options); - const branchName = await guardedBranch(params.branch, options); + const branchName = await syncBranch(params.branch, params.merge, options); const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { ...options, method: "POST", @@ -264,7 +264,6 @@ export function gitSyncResource(transport: Transport) { params: SyncExportPreflightParams, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.exportPreflight", options); return transport.requestParsed(SyncExportPreflight, "/api/ee/remote-sync/export-preflight", { ...options, query: { branch: params.branch }, @@ -279,7 +278,6 @@ export function gitSyncResource(transport: Transport) { params: SyncStashParams, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.stash", options); const started = await transport.requestParsed(SyncStashStarted, "/api/ee/remote-sync/stash", { ...options, method: "POST", @@ -298,7 +296,6 @@ export function gitSyncResource(transport: Transport) { /** List the branches the configured remote carries. */ async function branches(options: RequestOptions = {}): Promise> { - await transport.require("gitSync.branches", options); const response = await transport.requestParsed(SyncBranchList, "/api/ee/remote-sync/branches", { ...options, }); @@ -310,7 +307,6 @@ export function gitSyncResource(transport: Transport) { params: SyncCreateBranchParams, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.createBranch", options); return transport.requestParsed(SyncBranchCreated, "/api/ee/remote-sync/create-branch", { ...options, method: "POST", @@ -327,7 +323,6 @@ export function gitSyncResource(transport: Transport) { synced: boolean, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.setCollectionSynced", options); return transport.requestParsed(SyncSettingsUpdateResult, "/api/ee/remote-sync/settings", { ...options, method: "PUT", @@ -339,14 +334,12 @@ export function gitSyncResource(transport: Transport) { async function syncedCollections( options: RequestOptions = {}, ): Promise> { - await transport.require("gitSync.syncedCollections", options); const data = await listCollectionsAs(transport, SyncScopeCollection, options); return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; } /** The remote's URL, or null when none is configured or the caller may not read it. */ async function remoteUrl(options: RequestOptions = {}): Promise { - await transport.require("gitSync.remoteUrl", options); try { const url = await fetchOptionalParsed( transport, @@ -369,25 +362,19 @@ export function gitSyncResource(transport: Transport) { * reading either as unset. */ async function branch(options: RequestOptions = {}): Promise { - await transport.require("gitSync.branch", options); - const properties = await transport.requestParsed(RemoteSyncBranchProperty, PROBE_PATH, { - ...options, - }); + const properties = await transport.probe(RemoteSyncBranchProperty, options); const tracked = properties["remote-sync-branch"]; if (tracked === undefined) { - throw new ConfigError( - "the remote-sync-branch setting is not readable: it is visible to admins only, and absent on a server without the remote-sync module", - ); + throw new ConfigError(BRANCH_UNREADABLE_MESSAGE); } return tracked; } /** The branch git-sync tracks, refused when none is configured or the caller may not read it. */ async function trackedBranch(options: RequestOptions = {}): Promise { - await transport.require("gitSync.trackedBranch", options); const tracked = await branch(options); if (tracked === null) { - throw new ConfigError("git-sync tracks no branch: the remote-sync-branch setting is unset"); + throw new ConfigError(BRANCH_UNSET_MESSAGE); } return tracked; } @@ -400,7 +387,6 @@ export function gitSyncResource(transport: Transport) { wait: PollOptions, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.waitForTask", options); return settle(wait, options); } @@ -413,22 +399,34 @@ export function gitSyncResource(transport: Transport) { } return { - currentTask, - cancelTask, - isDirty, - dirty, - hasRemoteChanges, - import: importFromRemote, - export: exportToRemote, - exportPreflight, - stash, - branches, - createBranch, - setCollectionSynced, + currentTask: explain("currentTask", currentTask), + cancelTask: explain("cancelTask", cancelTask), + isDirty: explain("isDirty", isDirty), + dirty: explain("dirty", dirty), + hasRemoteChanges: explain("hasRemoteChanges", hasRemoteChanges), + import: explain("import", importFromRemote), + export: explain("export", exportToRemote), + exportPreflight: explain("exportPreflight", exportPreflight), + stash: explain("stash", stash), + branches: explain("branches", branches), + createBranch: explain("createBranch", createBranch), + setCollectionSynced: explain("setCollectionSynced", setCollectionSynced), syncedCollections, remoteUrl, branch, trackedBranch, - waitForTask, + waitForTask: explain("waitForTask", waitForTask), }; } + +function defaultBranch(properties: RemoteSyncBranchProperty): string | undefined { + const tracked = properties["remote-sync-branch"]; + if (typeof tracked === "string") { + return tracked; + } + const grantsRemoteSync = properties["token-features"]?.["remote_sync"] === true; + if (!grantsRemoteSync) { + return undefined; + } + throw new ConfigError(tracked === null ? BRANCH_UNSET_MESSAGE : BRANCH_UNREADABLE_MESSAGE); +} diff --git a/packages/client/src/resources/glossary.ts b/packages/client/src/resources/glossary.ts index 85526e27..3e81b07a 100644 --- a/packages/client/src/resources/glossary.ts +++ b/packages/client/src/resources/glossary.ts @@ -22,7 +22,6 @@ export function glossaryResource(transport: Transport) { params: GlossaryListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("glossary.list", options); const response = await transport.requestParsed(GlossaryApiList, "/api/glossary", { ...options, query: { search: params.search }, @@ -35,7 +34,6 @@ export function glossaryResource(transport: Transport) { params: GlossaryCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("glossary.create", options); return transport.requestParsed(Glossary, "/api/glossary", { ...options, method: "POST", @@ -49,7 +47,6 @@ export function glossaryResource(transport: Transport) { params: GlossaryUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("glossary.update", options); return transport.requestParsed(Glossary, `/api/glossary/${id}`, { ...options, method: "PUT", @@ -59,7 +56,6 @@ export function glossaryResource(transport: Transport) { /** Delete a glossary entry by id. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("glossary.delete", options); await transport.requestRaw(`/api/glossary/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts index bfd03b65..b3a36ad5 100644 --- a/packages/client/src/resources/library.ts +++ b/packages/client/src/resources/library.ts @@ -11,6 +11,7 @@ import { } from "../domain/library"; import { TableSelectors } from "../domain/table"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; import { listCollectionsAs } from "./collection"; import { parseRequestBody } from "./request-body"; @@ -25,9 +26,10 @@ const LIBRARY_DATA_TYPE = "library-data"; const PublishTablesResponse = z.object({ target_collection: Collection.nullable() }); export function libraryResource(transport: Transport) { + const explain = explainer(transport, "library"); + /** Get the Library root and its child collections, or `null` on an instance that has none. */ async function get(options: RequestOptions = {}): Promise { - await transport.require("library.get", options); const { features } = await transport.server(options); const wire = await transport.requestParsed(libraryWireSchema(features), LIBRARY_ROOT_PATH, { ...options, @@ -56,7 +58,6 @@ export function libraryResource(transport: Transport) { * back from a refetch, which together make this idempotent. */ async function create(options: RequestOptions = {}): Promise { - await transport.require("library.create", options); const existing = await get(options); if (existing !== null) { return existing; @@ -71,7 +72,6 @@ export function libraryResource(transport: Transport) { /** The id of the Library's Data collection, creating the Library first when it does not exist. */ async function ensureDataCollectionId(options: RequestOptions = {}): Promise { - await transport.require("library.ensureDataCollectionId", options); const library = await create(options); const data = library.effective_children.find((child) => child.type === LIBRARY_DATA_TYPE); if (data === undefined) { @@ -93,7 +93,6 @@ export function libraryResource(transport: Transport) { params: LibraryPublishTablesInput, options: RequestOptions = {}, ): Promise { - await transport.require("library.publishTables", options); const body = parseRequestBody(LibraryPublishTablesInput, params, "tables to publish"); const response = await transport.requestParsed(PublishTablesResponse, PUBLISH_TABLES_PATH, { ...options, @@ -112,7 +111,6 @@ export function libraryResource(transport: Transport) { params: TableSelectors, options: RequestOptions = {}, ): Promise { - await transport.require("library.unpublishTables", options); const body = parseRequestBody(TableSelectors, params, "table selectors"); await transport.requestRaw(UNPUBLISH_TABLES_PATH, { ...options, @@ -122,5 +120,11 @@ export function libraryResource(transport: Transport) { }); } - return { get, create, ensureDataCollectionId, publishTables, unpublishTables }; + return { + get: explain("get", get), + create: explain("create", create), + ensureDataCollectionId: explain("ensureDataCollectionId", ensureDataCollectionId), + publishTables: explain("publishTables", publishTables), + unpublishTables: explain("unpublishTables", unpublishTables), + }; } diff --git a/packages/client/src/resources/measure.ts b/packages/client/src/resources/measure.ts index d686a838..3f896a9e 100644 --- a/packages/client/src/resources/measure.ts +++ b/packages/client/src/resources/measure.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { Measure, type MeasureCreateInput, type MeasureUpdateInput } from "../domain/measure"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; // `GET /api/measure` answers a bare array rather than a `{ data, total }` envelope, so the count a // caller reads off `ListResult` is the array's own length and the server reports none. @@ -13,16 +14,16 @@ export interface MeasureArchiveParams { } export function measureResource(transport: Transport) { + const explain = explainer(transport, "measure"); + /** List measures. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("measure.list", options); const data = await transport.requestParsed(MeasureApiList, "/api/measure", { ...options }); return { data, total: null }; } /** Get one measure by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("measure.get", options); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options }); } @@ -31,7 +32,6 @@ export function measureResource(transport: Transport) { params: MeasureCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("measure.create", options); return transport.requestParsed(Measure, "/api/measure", { ...options, method: "POST", @@ -48,7 +48,6 @@ export function measureResource(transport: Transport) { params: MeasureUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("measure.update", options); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options, method: "PUT", @@ -65,9 +64,14 @@ export function measureResource(transport: Transport) { params: MeasureArchiveParams, options: RequestOptions = {}, ): Promise { - await transport.require("measure.archive", options); return update(id, { archived: true, revision_message: params.revision_message }, options); } - return { list, get, create, update, archive }; + return { + list: explain("list", list), + get: explain("get", get), + create: explain("create", create), + update: explain("update", update), + archive: explain("archive", archive), + }; } diff --git a/packages/client/src/resources/metric.test.ts b/packages/client/src/resources/metric.test.ts index 0b55ed88..d7b90903 100644 --- a/packages/client/src/resources/metric.test.ts +++ b/packages/client/src/resources/metric.test.ts @@ -4,8 +4,16 @@ import { createClient } from "../client"; import type { MetricDefinition } from "../domain/metric"; import { ResponseShapeError } from "../errors"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + routeMissingResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -240,8 +248,11 @@ describe("metric resource wire requests", () => { ); }); - it("refuses the dimension listing before the wire on a server older than the route", async () => { - const { mb, capture } = clientOver([], SERVER_63); + it("explains an unrouted dimension listing on a server older than the route", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(SERVER_63)], + SERVER_63, + ); const error = await thrownBy(() => mb.metric.dimensions(42)); @@ -255,11 +266,17 @@ describe("metric resource wire requests", () => { tokenFeature: null, serverVersion: "v0.63.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/metric/42/dimension", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses a definition query before the wire on a server without the metric routes", async () => { - const { mb, capture } = clientOver([], SERVER_59); + it("explains an unrouted definition query on a server without the metric routes", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(SERVER_59)], + SERVER_59, + ); const error = await thrownBy(() => mb.metric.query(AVERAGE_ORDER)); @@ -273,6 +290,9 @@ describe("metric resource wire requests", () => { tokenFeature: null, serverVersion: "v0.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/metric/dataset", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/metric.ts b/packages/client/src/resources/metric.ts index 022ee871..f13a2e1d 100644 --- a/packages/client/src/resources/metric.ts +++ b/packages/client/src/resources/metric.ts @@ -5,6 +5,7 @@ import { MetricDimensionListing, } from "../domain/metric"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; export interface MetricDimensionListParams { query?: string | undefined; @@ -14,6 +15,8 @@ export interface MetricDimensionListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function metricResource(transport: Transport) { + const explain = explainer(transport, "metric"); + /** * Run a metric definition and return the query result envelope: one metric or measure, or an * arithmetic expression over several, each leaf filtered and projected on its own `lib/uuid`. @@ -22,7 +25,6 @@ export function metricResource(transport: Transport) { definition: MetricDefinition, options: RequestOptions = {}, ): Promise { - await transport.require("metric.query", options); return transport.requestParsed(CardQueryResult, "/api/metric/dataset", { ...options, method: "POST", @@ -38,7 +40,6 @@ export function metricResource(transport: Transport) { definition: MetricDefinition, options: RequestOptions = {}, ): Promise { - await transport.require("metric.breakoutValues", options); return transport.requestParsed(MetricBreakoutValues, "/api/metric/breakout-values", { ...options, method: "POST", @@ -55,7 +56,6 @@ export function metricResource(transport: Transport) { params: MetricDimensionListParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("metric.dimensions", options); return transport.requestParsed(MetricDimensionListing, `/api/metric/${id}/dimension`, { ...options, query: { @@ -66,5 +66,9 @@ export function metricResource(transport: Transport) { }); } - return { query, breakoutValues, dimensions }; + return { + query: explain("query", query), + breakoutValues: explain("breakoutValues", breakoutValues), + dimensions: explain("dimensions", dimensions), + }; } diff --git a/packages/client/src/resources/moderation-review.test.ts b/packages/client/src/resources/moderation-review.test.ts index f04a1987..3137fa87 100644 --- a/packages/client/src/resources/moderation-review.test.ts +++ b/packages/client/src/resources/moderation-review.test.ts @@ -2,8 +2,16 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + premiumRefusalResponse, + probeResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -111,8 +119,11 @@ describe("moderation-review resource wire requests", () => { ).toEqual(REVIEW); }); - it("refuses before the wire when the server grants no content_verification token", async () => { - const { mb, capture } = clientOver([], UNLICENSED); + it("explains a premium refusal as the missing content_verification token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Content verification"), probeResponse(UNLICENSED)], + UNLICENSED, + ); const error = await thrownBy(() => mb.moderationReview.create({ moderated_item_id: 42, moderated_item_type: "card" }), @@ -128,6 +139,9 @@ describe("moderation-review resource wire requests", () => { tokenFeature: "content_verification", serverVersion: "v1.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/moderation-review", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/moderation-review.ts b/packages/client/src/resources/moderation-review.ts index 5daed66c..2d5f0228 100644 --- a/packages/client/src/resources/moderation-review.ts +++ b/packages/client/src/resources/moderation-review.ts @@ -1,9 +1,12 @@ import { ModerationReview, ModerationReviewCreateInput } from "../domain/moderation-review"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; import { parseRequestBody } from "./request-body"; export function moderationReviewResource(transport: Transport) { + const explain = explainer(transport, "moderationReview"); + /** * Create a moderation review on a card or dashboard. `status: "verified"` marks the item * verified, `null` or an absent status records a note that leaves it unverified. The new review @@ -14,7 +17,6 @@ export function moderationReviewResource(transport: Transport) { params: ModerationReviewCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("moderationReview.create", options); const body = parseRequestBody(ModerationReviewCreateInput, params, "moderation review"); return transport.requestParsed(ModerationReview, "/api/moderation-review", { ...options, @@ -23,5 +25,5 @@ export function moderationReviewResource(transport: Transport) { }); } - return { create }; + return { create: explain("create", create) }; } diff --git a/packages/client/src/resources/notification.ts b/packages/client/src/resources/notification.ts index 897520b8..8d0a175c 100644 --- a/packages/client/src/resources/notification.ts +++ b/packages/client/src/resources/notification.ts @@ -76,7 +76,6 @@ export function notificationResource(transport: Transport) { params: NotificationListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("notification.list", options); const data = await transport.requestParsed(NotificationApiList, "/api/notification", { ...options, query: { @@ -92,7 +91,6 @@ export function notificationResource(transport: Transport) { /** Get one question alert by id, refusing an id that names a system-event notification. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.get", options); const notification = await transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, }); @@ -104,7 +102,6 @@ export function notificationResource(transport: Transport) { params: NotificationCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("notification.create", options); return transport.requestParsed(Notification, "/api/notification", { ...options, method: "POST", @@ -118,7 +115,6 @@ export function notificationResource(transport: Transport) { params: NotificationUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("notification.update", options); const current = await get(id, options); return transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, @@ -129,7 +125,6 @@ export function notificationResource(transport: Transport) { /** Archive a question alert by id, stopping every delivery. Metabase models this as an update. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.archive", options); return update(id, { active: false }, options); } @@ -138,7 +133,6 @@ export function notificationResource(transport: Transport) { * body. */ async function send(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.send", options); await get(id, options); await transport.requestRaw(`/api/notification/${id}/send`, { ...options, diff --git a/packages/client/src/resources/permission.ts b/packages/client/src/resources/permission.ts index 85926472..8040564e 100644 --- a/packages/client/src/resources/permission.ts +++ b/packages/client/src/resources/permission.ts @@ -9,7 +9,6 @@ export function permissionResource(transport: Transport) { * one database is the question. */ async function graph(options: RequestOptions = {}): Promise { - await transport.require("permission.graph", options); return transport.requestParsed(PermissionsGraph, "/api/permissions/graph", { ...options }); } @@ -22,7 +21,6 @@ export function permissionResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("permission.databaseGraph", options); return transport.requestParsed(PermissionsGraph, `/api/permissions/graph/db/${id}`, { ...options, }); @@ -30,7 +28,6 @@ export function permissionResource(transport: Transport) { /** Get the data permissions of one group on every database. Admin only. */ async function groupGraph(id: number, options: RequestOptions = {}): Promise { - await transport.require("permission.groupGraph", options); return transport.requestParsed(PermissionsGraph, `/api/permissions/graph/group/${id}`, { ...options, }); diff --git a/packages/client/src/resources/pulse.test.ts b/packages/client/src/resources/pulse.test.ts index ab8abf68..3f2a1d3b 100644 --- a/packages/client/src/resources/pulse.test.ts +++ b/packages/client/src/resources/pulse.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import { Pulse, type PulseUpdateInput } from "../domain/pulse"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; +import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; +import { createServerProfile } from "../version/profile"; import { mergePulseUpdate } from "./pulse"; @@ -59,6 +68,43 @@ const STORED = Pulse.parse({ const STORED_ARCHIVED: Pulse = { ...STORED, archived: true }; +const PROBE_URL = `https://mb.example.com/metabase${PROBE_PATH}`; + +const REGION_FILTER = { id: "region", type: "category" as const, default: ["West"] }; + +const NEW_SUBSCRIPTION = { + name: "Weekly orders", + dashboard_id: 10, + cards: [{ id: 94, include_csv: false, include_xls: false }], + channels: [{ channel_type: "email" as const, enabled: true, schedule_type: "daily" as const }], +}; + +const FILTERING_SERVER = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { dashboard_subscription_filters: true }, +}); + +const UNFILTERING_SERVER = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { dashboard_subscription_filters: false }, +}); + +const MISSING_SUBSCRIPTION_FILTERS = { + reason: "missing-token-feature", + detail: + "This operation requires the 'dashboard_subscription_filters' premium feature (not enabled on this server).", + feature: "dashboardSubscriptionFilters", + since: 58, + tokenFeature: "dashboard_subscription_filters", + serverVersion: "v1.63.0", +}; + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { @@ -134,6 +180,47 @@ describe("pulse resource wire requests", () => { ]); }); + it("sends a subscription's own filter values once a fresh probe finds the server honours them", async () => { + const { mb, capture } = clientOver([probeResponse(FILTERING_SERVER), jsonResponse(STORED)]); + + await mb.pulse.create({ ...NEW_SUBSCRIPTION, parameters: [REGION_FILTER] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + PROBE_URL, + "https://mb.example.com/metabase/api/pulse", + ]); + }); + + it("refuses a subscription's own filter values before the wire on a server that would send unfiltered results", async () => { + const { mb, capture } = clientOver([probeResponse(UNFILTERING_SERVER)]); + + const error = await thrownBy(() => + mb.pulse.create({ ...NEW_SUBSCRIPTION, parameters: [REGION_FILTER] }), + ); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual(MISSING_SUBSCRIPTION_FILTERS); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("refuses filter values on update before reading the stored subscription", async () => { + const { mb, capture } = clientOver([probeResponse(UNFILTERING_SERVER)]); + + const error = await thrownBy(() => mb.pulse.update(1, { parameters: [REGION_FILTER] })); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual(MISSING_SUBSCRIPTION_FILTERS); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("sends an empty filter list, which clears the subscription's own values, without asking the server", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED), jsonResponse(STORED)]); + + await mb.pulse.update(1, { parameters: [] }); + + expect(capture.calls.map((call) => call.method)).toEqual(["GET", "PUT"]); + }); + it("reads the stored subscription before PUTting the merged body", async () => { const { mb, capture } = clientOver([jsonResponse(STORED), jsonResponse(STORED)]); diff --git a/packages/client/src/resources/pulse.ts b/packages/client/src/resources/pulse.ts index fde3445c..8fb58366 100644 --- a/packages/client/src/resources/pulse.ts +++ b/packages/client/src/resources/pulse.ts @@ -34,7 +34,6 @@ export function pulseResource(transport: Transport) { params: PulseListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("pulse.list", options); const data = await transport.requestParsed(PulseApiList, "/api/pulse", { ...options, query: { dashboard_id: params.dashboard_id, archived: params.archived }, @@ -44,13 +43,16 @@ export function pulseResource(transport: Transport) { /** Get one dashboard subscription by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("pulse.get", options); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options }); } - /** Create a dashboard subscription — its cards and its delivery channels — from a full body. */ + /** + * Create a dashboard subscription — its cards and its delivery channels — from a full body. + * Non-empty `parameters` need the `dashboard_subscription_filters` premium feature, and are + * refused before the wire without it. + */ async function create(params: PulseCreateInput, options: RequestOptions = {}): Promise { - await transport.require("pulse.create", options); + await requireSubscriptionFilters(params.parameters, options); return transport.requestParsed(Pulse, "/api/pulse", { ...options, method: "POST", @@ -58,13 +60,16 @@ export function pulseResource(transport: Transport) { }); } - /** Update a dashboard subscription by id, carrying the server-defaulted flags forward. */ + /** + * Update a dashboard subscription by id, carrying the server-defaulted flags forward. Non-empty + * `parameters` are refused as on `create`. + */ async function update( id: number, params: PulseUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("pulse.update", options); + await requireSubscriptionFilters(params.parameters, options); const current = await get(id, options); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options, @@ -78,9 +83,19 @@ export function pulseResource(transport: Transport) { * update, and disables each of the subscription's channels as a side effect. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("pulse.archive", options); return update(id, { archived: true }, options); } + // A server without the feature stores `parameters` without complaint but sends the dashboard's + // own filter values, so an audience meant to get one slice would get every row the dashboard shows. + async function requireSubscriptionFilters( + parameters: PulseCreateInput["parameters"], + options: RequestOptions, + ): Promise { + if (parameters !== undefined && parameters.length > 0) { + await transport.requireFeatures(["dashboardSubscriptionFilters"], options); + } + } + return { list, get, create, update, archive }; } diff --git a/packages/client/src/resources/replacement.test.ts b/packages/client/src/resources/replacement.test.ts index 1c8c8324..93f26b7e 100644 --- a/packages/client/src/resources/replacement.test.ts +++ b/packages/client/src/resources/replacement.test.ts @@ -11,9 +11,13 @@ import { captureFetch, type FetchScript, jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -255,8 +259,11 @@ describe("replacement resource wire requests", () => { ); }); - it("refuses before the wire on a server without the dependencies token", async () => { - const { mb, capture } = clientOver([], UNLICENSED_SERVER); + it("explains a premium refusal as the missing dependencies token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Dependencies"), probeResponse(UNLICENSED_SERVER)], + UNLICENSED_SERVER, + ); const error = await mb.replacement .checkReplaceSource(SOURCE_SWAP) @@ -272,11 +279,17 @@ describe("replacement resource wire requests", () => { tokenFeature: "dependencies", serverVersion: "v1.60.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/replacement/check-replace-source", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses before the wire on a licensed server that predates replacement", async () => { - const { mb, capture } = clientOver([], PRE_REPLACEMENT_SERVER); + it("explains an unrouted call on a licensed server that predates replacement", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(PRE_REPLACEMENT_SERVER)], + PRE_REPLACEMENT_SERVER, + ); const error = await mb.replacement.listRuns().catch((caught: unknown) => caught); @@ -290,6 +303,9 @@ describe("replacement resource wire requests", () => { tokenFeature: "dependencies", serverVersion: "v1.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/replacement/runs", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/replacement.ts b/packages/client/src/resources/replacement.ts index 93dc578b..66e242c9 100644 --- a/packages/client/src/resources/replacement.ts +++ b/packages/client/src/resources/replacement.ts @@ -9,6 +9,7 @@ import { } from "../domain/replacement"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; const ReplacementApiRunList = z.array(ReplacementRun); @@ -20,6 +21,8 @@ export interface ReplacementRunListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function replacementResource(transport: Transport) { + const explain = explainer(transport, "replacement"); + /** * Check whether every usage of a source entity could be rewritten to read a target entity * instead: the two must share a database, the target must not depend on the source, every source @@ -30,7 +33,6 @@ export function replacementResource(transport: Transport) { params: ReplacementSourceInput, options: RequestOptions = {}, ): Promise { - await transport.require("replacement.checkReplaceSource", options); return transport.requestParsed(ReplacementCheck, "/api/ee/replacement/check-replace-source", { ...options, method: "POST", @@ -47,7 +49,6 @@ export function replacementResource(transport: Transport) { params: ReplacementSourceInput, options: RequestOptions = {}, ): Promise { - await transport.require("replacement.replaceSource", options); return transport.requestParsed(ReplacementRunStarted, "/api/ee/replacement/replace-source", { ...options, method: "POST", @@ -66,7 +67,6 @@ export function replacementResource(transport: Transport) { params: ReplacementModelWithTransformInput, options: RequestOptions = {}, ): Promise { - await transport.require("replacement.replaceModelWithTransform", options); return transport.requestParsed( ReplacementRunStarted, "/api/ee/replacement/replace-model-with-transform", @@ -82,7 +82,6 @@ export function replacementResource(transport: Transport) { params: ReplacementRunListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("replacement.listRuns", options); const data = await transport.requestParsed(ReplacementApiRunList, "/api/ee/replacement/runs", { ...options, query: { "is-active": params["is-active"] }, @@ -92,7 +91,6 @@ export function replacementResource(transport: Transport) { /** Get one replacement run by id, with its status and progress. Admins only. */ async function getRun(id: number, options: RequestOptions = {}): Promise { - await transport.require("replacement.getRun", options); return transport.requestParsed(ReplacementRun, `/api/ee/replacement/runs/${id}`, { ...options, }); @@ -100,7 +98,6 @@ export function replacementResource(transport: Transport) { /** Cancel the replacement in progress. Refuses with a 409 once the run has ended. Admins only. */ async function cancelRun(id: number, options: RequestOptions = {}): Promise { - await transport.require("replacement.cancelRun", options); await transport.requestParsed(CancelRunResponse, `/api/ee/replacement/runs/${id}/cancel`, { ...options, method: "POST", @@ -108,11 +105,11 @@ export function replacementResource(transport: Transport) { } return { - checkReplaceSource, - replaceSource, - replaceModelWithTransform, - listRuns, - getRun, - cancelRun, + checkReplaceSource: explain("checkReplaceSource", checkReplaceSource), + replaceSource: explain("replaceSource", replaceSource), + replaceModelWithTransform: explain("replaceModelWithTransform", replaceModelWithTransform), + listRuns: explain("listRuns", listRuns), + getRun: explain("getRun", getRun), + cancelRun: explain("cancelRun", cancelRun), }; } diff --git a/packages/client/src/resources/revision.test.ts b/packages/client/src/resources/revision.test.ts index 17d1e8c4..a30b0d80 100644 --- a/packages/client/src/resources/revision.test.ts +++ b/packages/client/src/resources/revision.test.ts @@ -2,8 +2,15 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -99,8 +106,21 @@ describe("revision resource wire requests", () => { expect(await mb.revision.list("card", 94)).toEqual({ data: [REVISION], total: null }); }); - it("refuses a measure's revisions before the wire on a server without measures", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("explains a rejected measure entity on a server without measures", async () => { + const { mb, capture } = clientOver( + [ + jsonResponse( + { + errors: { + entity: "value must be one of: card, dashboard, document, segment, transform.", + }, + }, + 400, + ), + probeResponse(SERVER_58), + ], + SERVER_58, + ); const error = await thrownBy(() => mb.revision.list("measure", 3)); @@ -114,7 +134,10 @@ describe("revision resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/revision/measure/3", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); it("lists a card's revisions on the oldest supported server without asking it", async () => { @@ -160,8 +183,17 @@ describe("revision resource wire requests", () => { }); }); - it("refuses a transform revert before the wire on a server without transforms", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("explains a rejected transform entity on a server without transforms", async () => { + const { mb, capture } = clientOver( + [ + jsonResponse( + { errors: { entity: "value must be one of: card, dashboard, document, segment." } }, + 400, + ), + probeResponse(SERVER_58), + ], + SERVER_58, + ); const error = await thrownBy(() => mb.revision.revert({ entity: "transform", id: 5, revision_id: 2 }), @@ -177,6 +209,9 @@ describe("revision resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/revision/revert", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/revision.ts b/packages/client/src/resources/revision.ts index 043db4a0..2a9d55b0 100644 --- a/packages/client/src/resources/revision.ts +++ b/packages/client/src/resources/revision.ts @@ -10,6 +10,7 @@ import { import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; +import { explainer } from "../version/refusal"; // `GET /api/revision/{entity}/{id}` answers a bare array, newest first, that the server does not // count. @@ -22,8 +23,8 @@ const RevisionApiRevert: z.ZodType = z.union([ RevisionRow.transform((revision) => ({ outcome: "unchanged" as const, revision })), ]); -// An entity kind an older server does not revision is refused by the feature that brought it, -// rather than as that server's route-missing 404. +// An older server rejects an entity kind it does not revision (an unrouted 404, or a 400 on the +// enum), and the rejection is explained by the feature that brought the kind. const ENTITY_FEATURES: Partial> = { measure: "measures", transform: "transforms", @@ -37,14 +38,14 @@ function entityFeatures(entity: RevisionEntity): FeatureName[] { // Every path parameter here is an enum member or a numeric id, so no fragment needs // `encodeURIComponent`. export function revisionResource(transport: Transport) { + const explain = explainer(transport, "revision"); + /** List the revisions of an entity the caller may read, newest first, each with its diff. */ async function list( entity: RevisionEntity, id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("revision.list", options); - await transport.requireFeatures(entityFeatures(entity), options); const data = await transport.requestParsed(RevisionApiList, `/api/revision/${entity}/${id}`, { ...options, }); @@ -59,8 +60,6 @@ export function revisionResource(transport: Transport) { params: RevisionRevertInput, options: RequestOptions = {}, ): Promise { - await transport.require("revision.revert", options); - await transport.requireFeatures(entityFeatures(params.entity), options); return transport.requestParsed(RevisionApiRevert, "/api/revision/revert", { ...options, method: "POST", @@ -68,5 +67,8 @@ export function revisionResource(transport: Transport) { }); } - return { list, revert }; + return { + list: explain("list", list, entityFeatures), + revert: explain("revert", revert, (params) => entityFeatures(params.entity)), + }; } diff --git a/packages/client/src/resources/search.test.ts b/packages/client/src/resources/search.test.ts index b34ac5ce..a315c3cf 100644 --- a/packages/client/src/resources/search.test.ts +++ b/packages/client/src/resources/search.test.ts @@ -1,8 +1,17 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; +import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; +import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -24,6 +33,24 @@ const JSON_READ_HEADERS = { "x-api-key": "mb_wire_test_key", }; +const PROBE_URL = `https://mb.example.com/metabase${PROBE_PATH}`; + +const VERIFYING_SERVER = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { content_verification: true }, +}); + +const UNVERIFYING_SERVER = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { content_verification: false }, +}); + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { @@ -35,7 +62,10 @@ function clientOver(responses: Array) { describe("search resource wire requests", () => { it("sends every search parameter, repeating the models and created_by keys per value", async () => { - const { mb, capture } = clientOver([jsonResponse({ data: [SEARCH_RESULT], total: 1 })]); + const { mb, capture } = clientOver([ + probeResponse(VERIFYING_SERVER), + jsonResponse({ data: [SEARCH_RESULT], total: 1 }), + ]); await mb.search.query({ q: "orders", @@ -53,6 +83,7 @@ describe("search resource wire requests", () => { }); expect(capture.calls).toEqual([ + { url: PROBE_URL, method: "GET", headers: JSON_READ_HEADERS, body: null }, { url: "https://mb.example.com/metabase/api/search?q=orders&models=card&models=dashboard&archived=true&limit=20&offset=40&table_db_id=2&verified=true&collection=7&created_by=3&created_by=4&search_native_query=true&include_metadata=true&include_dashboard_questions=true", method: "GET", @@ -62,6 +93,34 @@ describe("search resource wire requests", () => { ]); }); + it("refuses the verified filter before the wire on a server without content verification, which drops it", async () => { + const { mb, capture } = clientOver([probeResponse(UNVERIFYING_SERVER)]); + + const error = await thrownBy(() => mb.search.query({ q: "orders", verified: true })); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'content_verification' premium feature (not enabled on this server).", + feature: "contentVerification", + since: 58, + tokenFeature: "content_verification", + serverVersion: "v1.63.0", + }); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("sends an unverified search without asking the server what it grants", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [], total: 0 })]); + + await mb.search.query({ q: "orders", verified: false }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/search?q=orders&verified=false", + ]); + }); + it("omits every unset search parameter from the query string", async () => { const { mb, capture } = clientOver([jsonResponse({ data: [], total: 0 })]); diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index f8979d67..672e127f 100644 --- a/packages/client/src/resources/search.ts +++ b/packages/client/src/resources/search.ts @@ -43,12 +43,18 @@ export function searchResource(transport: Transport) { * actions, transforms), `include_metadata` attaches each card's `result_metadata`, * `include_dashboard_questions` also matches questions saved into a dashboard (excluded by * default), and `limit`/`offset` are the window the server applies before ranking hydration. + * `verified` needs the `content_verification` premium feature, and is refused before the wire + * without it. */ async function query( params: SearchParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("search.query", options); + // A server without content verification drops the filter and answers unverified results as + // if they had been filtered. + if (params.verified === true) { + await transport.requireFeatures(["contentVerification"], options); + } const response = await transport.requestParsed(SearchApiResponse, "/api/search", { ...options, query: { diff --git a/packages/client/src/resources/segment.ts b/packages/client/src/resources/segment.ts index adf3899e..109f2ed8 100644 --- a/packages/client/src/resources/segment.ts +++ b/packages/client/src/resources/segment.ts @@ -16,14 +16,12 @@ export interface SegmentArchiveParams { export function segmentResource(transport: Transport) { /** List every segment the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("segment.list", options); const data = await transport.requestParsed(SegmentApiList, "/api/segment", { ...options }); return { data, total: null }; } /** Get one segment by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("segment.get", options); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options }); } @@ -32,7 +30,6 @@ export function segmentResource(transport: Transport) { params: SegmentCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("segment.create", options); return transport.requestParsed(Segment, "/api/segment", { ...options, method: "POST", @@ -49,7 +46,6 @@ export function segmentResource(transport: Transport) { params: SegmentUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("segment.update", options); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options, method: "PUT", @@ -66,7 +62,6 @@ export function segmentResource(transport: Transport) { params: SegmentArchiveParams, options: RequestOptions = {}, ): Promise { - await transport.require("segment.archive", options); return update(id, { archived: true, revision_message: params.revision_message }, options); } diff --git a/packages/client/src/resources/setting.ts b/packages/client/src/resources/setting.ts index 68224fea..3b6056ac 100644 --- a/packages/client/src/resources/setting.ts +++ b/packages/client/src/resources/setting.ts @@ -24,7 +24,6 @@ function rethrowSettingError(error: unknown, key: string): never { export function settingResource(transport: Transport) { /** Get all settings and their values, with descriptions and environment-variable names. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("setting.list", options); const data = await transport.requestParsed(SettingApiList, "/api/setting", { ...options }); return { data, total: null }; } @@ -34,7 +33,6 @@ export function settingResource(transport: Transport) { * reaches the caller as `null`; a string-typed setting answers bare text rather than JSON. */ async function get(key: string, options: RequestOptions = {}): Promise { - await transport.require("setting.get", options); const path = `/api/setting/${encodeURIComponent(key)}`; return fetchOptionalParsed(transport, path, z.unknown(), options).catch((error: unknown) => rethrowSettingError(error, key), @@ -43,7 +41,6 @@ export function settingResource(transport: Transport) { /** Set a single setting's value by key. The endpoint answers 204 with no body. */ async function set(key: string, value: unknown, options: RequestOptions = {}): Promise { - await transport.require("setting.set", options); await transport .requestRaw(`/api/setting/${encodeURIComponent(key)}`, { ...options, diff --git a/packages/client/src/resources/setup.ts b/packages/client/src/resources/setup.ts index b4d34b19..b3e941b5 100644 --- a/packages/client/src/resources/setup.ts +++ b/packages/client/src/resources/setup.ts @@ -7,7 +7,6 @@ export function setupResource(transport: Transport) { * applying the instance preferences. The setup token is single-use. */ async function create(params: SetupInput, options: RequestOptions = {}): Promise { - await transport.require("setup.create", options); return transport.requestParsed(SetupResult, "/api/setup", { ...options, method: "POST", diff --git a/packages/client/src/resources/snippet.ts b/packages/client/src/resources/snippet.ts index a1da6f1c..4414e752 100644 --- a/packages/client/src/resources/snippet.ts +++ b/packages/client/src/resources/snippet.ts @@ -18,7 +18,6 @@ export function snippetResource(transport: Transport) { params: SnippetListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("snippet.list", options); const data = await transport.requestParsed(SnippetApiList, "/api/native-query-snippet", { ...options, query: { archived: params.archived }, @@ -28,7 +27,6 @@ export function snippetResource(transport: Transport) { /** Get one native query snippet by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("snippet.get", options); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options }); } @@ -37,7 +35,6 @@ export function snippetResource(transport: Transport) { params: SnippetCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("snippet.create", options); return transport.requestParsed(Snippet, "/api/native-query-snippet", { ...options, method: "POST", @@ -51,7 +48,6 @@ export function snippetResource(transport: Transport) { params: SnippetUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("snippet.update", options); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options, method: "PUT", @@ -64,7 +60,6 @@ export function snippetResource(transport: Transport) { * own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("snippet.archive", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/table.test.ts b/packages/client/src/resources/table.test.ts index 66c8af36..06b71c3c 100644 --- a/packages/client/src/resources/table.test.ts +++ b/packages/client/src/resources/table.test.ts @@ -3,8 +3,16 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; import { ConfigError } from "../errors"; import type { ClientCredentials } from "../http/transport"; -import { captureFetch, jsonResponse, TEST_USER_AGENT, thrownBy } from "../testing/fetch-capture"; +import { + captureFetch, + jsonResponse, + probeResponse, + routeMissingResponse, + TEST_USER_AGENT, + thrownBy, +} from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; import type { CsvFile } from "./csv-upload"; @@ -75,6 +83,16 @@ const SERVER_60 = createServerProfile({ tokenFeatures: null, }); +const PROBE_URL = `https://mb.example.com/metabase${PROBE_PATH}`; + +// What a medallion-era server answers a tier name with: its enum rejects the value. +function dataLayerRejected(): Response { + return jsonResponse( + { errors: { data_layer: "nullable enum of gold, silver, bronze, copper" } }, + 400, + ); +} + const SERVER_58 = createServerProfile({ edition: "oss", version: { kind: "release", tag: "v0.58.0", major: 58, patch: 0 }, @@ -83,6 +101,17 @@ const SERVER_58 = createServerProfile({ tokenFeatures: null, }); +// The first server that keeps a table's user edits apart from the table, so a `null` withdraws one. +const SERVER_64 = createServerProfile({ + edition: "oss", + version: { kind: "release", tag: "v0.64.1", major: 64, patch: 1 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +const TABLE_URL = "https://mb.example.com/metabase/api/table/11"; + const FOREIGN_KEY = { relationship: "Mt1", origin_id: 205, @@ -124,8 +153,8 @@ describe("table resource wire requests", () => { ]); }); - it("sends every list filter under the server's own kebab-case name", async () => { - const { mb, capture } = clientOver([jsonResponse([TABLE])]); + it("sends every list filter under the server's own kebab-case name, after probing for the access filters", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_60), jsonResponse([TABLE])]); await mb.table.list({ term: "ord", @@ -141,12 +170,13 @@ describe("table resource wire requests", () => { }); expect(capture.calls.map((call) => call.url)).toEqual([ + PROBE_URL, "https://mb.example.com/metabase/api/table?term=ord&visibility-type=hidden&data-layer=final&data-source=upload&owner-user-id=7&owner-email=ada%40example.com&orphan-only=false&can-query=true&can-write=false&include-transform-targets=true", ]); }); - it("refuses an access filter before the wire on a server whose listing drops it", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("refuses an access filter before the wire on a server whose listing drops it, as a fresh probe finds it", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_58)]); const error = await thrownBy(() => mb.table.list({ term: "ord", "can-query": true })); @@ -160,11 +190,24 @@ describe("table resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("sends an access filter a cached profile from before an upgrade would have refused", async () => { + const { mb, capture } = clientOver( + [probeResponse(SERVER_60), jsonResponse([TABLE])], + SERVER_58, + ); + + expect(await mb.table.list({ "can-query": true })).toEqual({ data: [TABLE], total: null }); + expect(capture.calls.map((call) => call.url)).toEqual([ + PROBE_URL, + "https://mb.example.com/metabase/api/table?can-query=true", + ]); }); it("refuses the unused filter before the wire on a server without dependency tracking", async () => { - const { mb, capture } = clientOver([]); + const { mb, capture } = clientOver([probeResponse(SERVER_60)]); const error = await thrownBy(() => mb.table.list({ "unused-only": true })); @@ -178,7 +221,7 @@ describe("table resource wire requests", () => { tokenFeature: "dependencies", serverVersion: "v0.60.4", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); }); it("lists with the filters the oldest supported server takes without asking it", async () => { @@ -191,6 +234,22 @@ describe("table resource wire requests", () => { ]); }); + it("lists with every gated filter set to false without asking the oldest supported server", async () => { + const { mb, capture } = clientOver([jsonResponse([TABLE])], SERVER_58); + + await mb.table.list({ + "can-query": false, + "can-write": false, + "include-transform-targets": false, + "unused-only": false, + "published-only": false, + }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/table?unused-only=false&published-only=false&can-query=false&can-write=false&include-transform-targets=false", + ]); + }); + it("reports no total for the bare-array listing, which the server does not count", async () => { const { mb } = clientOver([jsonResponse([TABLE])]); @@ -275,8 +334,11 @@ describe("table resource wire requests", () => { ]); }); - it("refuses the bulk edit before the wire on a server below its floor", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("explains an unrouted bulk edit on a server below its floor", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(SERVER_58)], + SERVER_58, + ); const error = await thrownBy(() => mb.table.bulkEdit({ table_ids: [11], data_layer: "final" })); @@ -290,7 +352,10 @@ describe("table resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/data-studio/table/edit", + PROBE_URL, + ]); }); it("sends the get request", async () => { @@ -323,8 +388,8 @@ describe("table resource wire requests", () => { ]); }); - it("refuses a tier name for data_layer before the wire on a server that speaks medallions", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("explains a rejected tier name for data_layer on a server that speaks medallions", async () => { + const { mb, capture } = clientOver([dataLayerRejected(), probeResponse(SERVER_58)], SERVER_58); const error = await thrownBy(() => mb.table.update(11, { data_layer: "final" })); @@ -338,19 +403,22 @@ describe("table resource wire requests", () => { tokenFeature: null, serverVersion: "v0.58.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/table/11", + PROBE_URL, + ]); }); - it("sends a medallion name for data_layer to the server that speaks it", async () => { - const { mb, capture } = clientOver([jsonResponse(TABLE)], SERVER_58); + it("sends a medallion name for data_layer to the server a fresh probe finds speaking it", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_58), jsonResponse(TABLE)], SERVER_58); await mb.table.update(11, { data_layer: "gold" }); - expect(capture.calls.map((call) => call.body)).toEqual(['{"data_layer":"gold"}']); + expect(capture.calls.map((call) => call.body)).toEqual([null, '{"data_layer":"gold"}']); }); it("refuses a medallion name for data_layer before the wire on a server that speaks tiers", async () => { - const { mb, capture } = clientOver([]); + const { mb, capture } = clientOver([probeResponse(SERVER_60)]); const error = await thrownBy(() => mb.table.update(11, { data_layer: "gold" })); @@ -358,17 +426,73 @@ describe("table resource wire requests", () => { expect(error.message).toBe( 'data_layer "gold" is a medallion name; this server names a table\'s layer final, internal, hidden', ); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("refuses a data_authority withdrawal on update before the wire on a server that keeps it on the table, as a fresh probe finds it", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_60)], SERVER_64); + + const error = await thrownBy(() => mb.table.update(11, { data_authority: null })); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v64+ (this server is v0.60.4). Upgrade Metabase to use it.", + feature: "tableUserValueWithdrawal", + since: 64, + tokenFeature: null, + serverVersion: "v0.60.4", + }); + expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); + }); + + it("sends a data_authority withdrawal on update to the server a fresh probe finds keeping user edits apart", async () => { + const { mb, capture } = clientOver([probeResponse(SERVER_64), jsonResponse(TABLE)]); + + await mb.table.update(11, { data_authority: null }); + + expect( + capture.calls.map((call) => ({ url: call.url, method: call.method, body: call.body })), + ).toEqual([ + { url: PROBE_URL, method: "GET", body: null }, + { url: TABLE_URL, method: "PUT", body: '{"data_authority":null}' }, + ]); + }); + + it("fails an update the server answers with the table outside the collection asked for", async () => { + const { mb, capture } = clientOver([jsonResponse(TABLE)], SERVER_64); + + const error = await thrownBy(() => mb.table.update(11, { collection_id: 7 })); + + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe( + "the server applied the update but left table 11 in no collection: it does not move a table to a collection through an update", + ); + expect(capture.calls.map((call) => call.url)).toEqual([TABLE_URL]); }); - it("refuses a tier name in the data-layer filter on a server that speaks medallions", async () => { - const { mb, capture } = clientOver([], SERVER_58); + it("sends a collection on update without consulting the server first", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TABLE, collection_id: 7 })]); + + await mb.table.update(11, { collection_id: 7 }); + + expect( + capture.calls.map((call) => ({ url: call.url, method: call.method, body: call.body })), + ).toEqual([{ url: TABLE_URL, method: "PUT", body: '{"collection_id":7}' }]); + }); + + it("explains a rejected tier name in the data-layer filter on a server that speaks medallions", async () => { + const { mb, capture } = clientOver([dataLayerRejected(), probeResponse(SERVER_58)], SERVER_58); const error = await thrownBy(() => mb.table.list({ "data-layer": "hidden" })); assert(error instanceof CapabilityError, "expected CapabilityError"); expect(error.developerDetail.feature).toBe("tableDataLayerTiers"); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/table?data-layer=hidden", + PROBE_URL, + ]); }); it("sends the query-metadata request", async () => { diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 883c213d..378344d6 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -20,6 +20,7 @@ import { ConfigError } from "../errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; +import { explainer } from "../version/refusal"; import { buildCsvFormData, type CsvFile } from "./csv-upload"; import { fetchOptionalParsed } from "./optional-parsed"; @@ -56,7 +57,9 @@ export interface TableListParams { } // Older servers leave the query map open and drop a filter they do not know without a word, so a -// filter that arrived after the oldest supported major is refused by name before the wire. +// filter that arrived after the oldest supported major is refused by name before the wire. Every +// server applies such a filter only when it is `true`, so a `false` one reads the same dropped or +// not and needs no feature. const LIST_PARAM_FEATURES: ReadonlyArray = [ ["can-query", "tableListAccessFilters"], ["can-write", "tableListAccessFilters"], @@ -66,17 +69,26 @@ const LIST_PARAM_FEATURES: ReadonlyArray params[param] !== undefined).map( + return LIST_PARAM_FEATURES.filter(([param]) => params[param] === true).map( ([, feature]) => feature, ); } +// A server older than the tier vocabulary rejects a tier name; the rejection names the feature. +function dataLayerFeatures(value: TableDataLayer | null | undefined): FeatureName[] { + return value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success + ? ["tableDataLayerTiers"] + : []; +} + const UPLOAD_UPDATE_PATHS: Record = { append: "append-csv", replace: "replace-csv", }; export function tableResource(transport: Transport) { + const explain = explainer(transport, "table"); + /** * List every table the caller can see, across all databases. `term` matches names and display * names; `can-query` and `can-write` keep only the tables the caller may query or edit the @@ -87,9 +99,7 @@ export function tableResource(transport: Transport) { params: TableListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("table.list", options); await transport.requireFeatures(listParamFeatures(params), options); - await requireDataLayer(params["data-layer"], options); const data = await transport.requestParsed(TableApiList, "/api/table", { ...options, query: { @@ -112,7 +122,6 @@ export function tableResource(transport: Transport) { /** Get one table by id, without its fields. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("table.get", options); return transport.requestParsed(Table, `/api/table/${id}`, { ...options }); } @@ -122,29 +131,28 @@ export function tableResource(transport: Transport) { params: TableUpdateInput, options: RequestOptions = {}, ): Promise
{ - await transport.require("table.update", options); - await requireDataLayer(params.data_layer, options); - return transport.requestParsed(Table, `/api/table/${id}`, { + await refuseRewrittenDataLayer(params.data_layer, options); + await requireDataAuthorityNull(params.data_authority, options); + const updated = await transport.requestParsed(Table, `/api/table/${id}`, { ...options, method: "PUT", body: params, }); + assertCollectionWritten(params.collection_id, updated); + return updated; } - // A tier name is refused below 59 by feature; a medallion name is refused from 59 on by value, - // because a requirement can only say a server is too old, never too new. - async function requireDataLayer( + // A server that names tiers maps a medallion name onto one on update without a word, and for + // `copper` leaves the table visible while storing it hidden, so the name is refused by value + // before the wire. A tier name on an older server is that server's own 400. + async function refuseRewrittenDataLayer( value: TableDataLayer | null | undefined, options: RequestOptions, ): Promise { - if (value === null || value === undefined) { - return; - } - if (TableDataLayerTier.safeParse(value).success) { - await transport.requireFeatures(["tableDataLayerTiers"], options); + if (value === null || value === undefined || TableDataLayerTier.safeParse(value).success) { return; } - const { features } = await transport.server(options); + const { features } = await transport.verifiedServer(options); if (features.tableDataLayerTiers) { throw new ConfigError( `data_layer "${value}" is a medallion name; this server names a table's layer ${TableDataLayerTier.options.join(", ")}`, @@ -157,7 +165,6 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("table.queryMetadata", options); return transport.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`, { ...options, }); @@ -171,7 +178,6 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("table.fks", options); const data = await fetchOptionalParsed(transport, `/api/table/${id}/fks`, TableApiFks, { ...options, }); @@ -187,7 +193,6 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("table.syncSchema", options); const ack = await transport.requestParsed(SyncSchemaResponse, `/api/table/${id}/sync_schema`, { ...options, method: "POST", @@ -203,7 +208,6 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("table.rescanValues", options); const ack = await transport.requestParsed( FieldValuesResponse, `/api/table/${id}/rescan_values`, @@ -221,7 +225,6 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("table.discardValues", options); const ack = await transport.requestParsed( FieldValuesResponse, `/api/table/${id}/discard_values`, @@ -239,7 +242,6 @@ export function tableResource(transport: Transport) { params: TableBulkEditInput, options: RequestOptions = {}, ): Promise { - await transport.require("table.bulkEdit", options); await requireDataAuthorityNull(params.data_authority, options); await transport.requestParsed(BulkEditResponse, BULK_EDIT_PATH, { ...options, @@ -250,9 +252,11 @@ export function tableResource(transport: Transport) { } // A server that keeps `data_authority` on the table itself, where the column is NOT NULL, has no - // `null` to store: the request fails there rather than withdrawing a value. + // `null` to store: the single-table update and the bulk edit both hand it to that column, and + // the database fails the write as a bare 500, or an app database that coerces NULL stores + // something other than a withdrawal. Neither names the cause, so it is refused here. async function requireDataAuthorityNull( - value: TableBulkEditInput["data_authority"], + value: TableUpdateInput["data_authority"], options: RequestOptions, ): Promise { if (value === null) { @@ -286,7 +290,6 @@ export function tableResource(transport: Transport) { selectors: TableSelectors, options: RequestOptions = {}, ): Promise { - await transport.require("table.bulkSyncSchema", options); return postSelection(BULK_SYNC_SCHEMA_PATH, selectors, options); } @@ -298,7 +301,6 @@ export function tableResource(transport: Transport) { selectors: TableSelectors, options: RequestOptions = {}, ): Promise { - await transport.require("table.bulkRescanValues", options); return postSelection(BULK_RESCAN_VALUES_PATH, selectors, options); } @@ -310,7 +312,6 @@ export function tableResource(transport: Transport) { selectors: TableSelectors, options: RequestOptions = {}, ): Promise { - await transport.require("table.bulkDiscardValues", options); return postSelection(BULK_DISCARD_VALUES_PATH, selectors, options); } @@ -340,7 +341,6 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("table.appendCsv", options); return updateFromCsv(id, "append", file, options); } @@ -355,24 +355,39 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("table.replaceCsv", options); return updateFromCsv(id, "replace", file, options); } return { - list, + list: explain("list", list, (params) => dataLayerFeatures(params?.["data-layer"])), get, - update, + update: explain("update", update, (_id, params) => dataLayerFeatures(params.data_layer)), queryMetadata, fks, syncSchema, rescanValues, discardValues, - bulkEdit, - bulkSyncSchema, - bulkRescanValues, - bulkDiscardValues, + bulkEdit: explain("bulkEdit", bulkEdit), + bulkSyncSchema: explain("bulkSyncSchema", bulkSyncSchema), + bulkRescanValues: explain("bulkRescanValues", bulkRescanValues), + bulkDiscardValues: explain("bulkDiscardValues", bulkDiscardValues), appendCsv, replaceCsv, }; } + +// A server whose update predates publishing a table takes `collection_id` in its open body, leaves +// it out of the columns it writes, and answers 200 with the table where it was. Which servers do +// is a matter of patch release, not major, so the table the server answers is the judge. +function assertCollectionWritten(requested: TableUpdateInput["collection_id"], table: Table): void { + if (requested === undefined || table.collection_id === requested) { + return; + } + const stayed = + table.collection_id === null || table.collection_id === undefined + ? "in no collection" + : `in collection ${table.collection_id}`; + throw new ConfigError( + `the server applied the update but left table ${table.id} ${stayed}: it does not move a table to a collection through an update`, + ); +} diff --git a/packages/client/src/resources/timeline-event.ts b/packages/client/src/resources/timeline-event.ts index aaf41ef4..739362c0 100644 --- a/packages/client/src/resources/timeline-event.ts +++ b/packages/client/src/resources/timeline-event.ts @@ -8,7 +8,6 @@ import type { RequestOptions, Transport } from "../http/transport"; export function timelineEventResource(transport: Transport) { /** Get one timeline event by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.get", options); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options }); } @@ -17,7 +16,6 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timelineEvent.create", options); return transport.requestParsed(TimelineEvent, "/api/timeline-event", { ...options, method: "POST", @@ -34,7 +32,6 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timelineEvent.update", options); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options, method: "PUT", @@ -47,13 +44,11 @@ export function timelineEventResource(transport: Transport) { * endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.archive", options); return update(id, { archived: true }, options); } /** Permanently delete a timeline event by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.delete", options); await transport.requestRaw(`/api/timeline-event/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/timeline.ts b/packages/client/src/resources/timeline.ts index c5e991cb..4458b192 100644 --- a/packages/client/src/resources/timeline.ts +++ b/packages/client/src/resources/timeline.ts @@ -31,7 +31,6 @@ export function timelineResource(transport: Transport) { params: TimelineListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("timeline.list", options); const data = await transport.requestParsed(TimelineApiList, "/api/timeline", { ...options, query: { archived: params.archived }, @@ -41,7 +40,6 @@ export function timelineResource(transport: Transport) { /** Get one timeline by id, without its events. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.get", options); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options }); } @@ -54,7 +52,6 @@ export function timelineResource(transport: Transport) { params: TimelineEventsParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("timeline.events", options); const timeline = await transport.requestParsed(TimelineWithEvents, `/api/timeline/${id}`, { ...options, query: { include: "events", archived: params.archived }, @@ -67,7 +64,6 @@ export function timelineResource(transport: Transport) { params: TimelineCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timeline.create", options); return transport.requestParsed(Timeline, "/api/timeline", { ...options, method: "POST", @@ -84,7 +80,6 @@ export function timelineResource(transport: Transport) { params: TimelineUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timeline.update", options); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options, method: "PUT", @@ -97,13 +92,11 @@ export function timelineResource(transport: Transport) { * not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.archive", options); return update(id, { archived: true }, options); } /** Permanently delete a timeline and all its events by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.delete", options); await transport.requestRaw(`/api/timeline/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/transform-dag-run.test.ts b/packages/client/src/resources/transform-dag-run.test.ts index 8d51d804..195ccee3 100644 --- a/packages/client/src/resources/transform-dag-run.test.ts +++ b/packages/client/src/resources/transform-dag-run.test.ts @@ -6,9 +6,12 @@ import { captureFetch, type FetchScript, jsonResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -102,8 +105,11 @@ describe("transform-dag-run resource wire requests", () => { ]); }); - it("refuses before the wire on a server without DAG runs", async () => { - const { mb, capture } = clientOver([], OLDER_SERVER); + it("explains an unrouted call on a server without DAG runs", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(OLDER_SERVER)], + OLDER_SERVER, + ); const error = await mb.transformDagRun.transformRuns(90).catch((caught: unknown) => caught); @@ -117,6 +123,9 @@ describe("transform-dag-run resource wire requests", () => { tokenFeature: null, serverVersion: "v0.63.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/transform-dag-run/90/transform-runs", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/transform-dag-run.ts b/packages/client/src/resources/transform-dag-run.ts index 7afe53ff..079ec8ab 100644 --- a/packages/client/src/resources/transform-dag-run.ts +++ b/packages/client/src/resources/transform-dag-run.ts @@ -3,12 +3,15 @@ import { z } from "zod"; import { TransformMemberRun } from "../domain/transform-dag-run"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; // A bare array rather than a `{ data, total }` envelope, so the server reports no count. const TransformMemberRunList = z.array(TransformMemberRun); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformDagRunResource(transport: Transport) { + const explain = explainer(transport, "transformDagRun"); + /** * List the transform runs a DAG run coordinated so far. A member run is inserted when its * transform starts, so the list grows while the DAG run is in progress. @@ -17,7 +20,6 @@ export function transformDagRunResource(transport: Transport) { runId: number, options: RequestOptions = {}, ): Promise> { - await transport.require("transformDagRun.transformRuns", options); const data = await transport.requestParsed( TransformMemberRunList, `/api/transform-dag-run/${runId}/transform-runs`, @@ -28,7 +30,6 @@ export function transformDagRunResource(transport: Transport) { /** Cancel an in-progress DAG run and request cancellation of its still-running transforms. */ async function cancel(runId: number, options: RequestOptions = {}): Promise { - await transport.require("transformDagRun.cancel", options); await transport.requestRaw(`/api/transform-dag-run/${runId}/cancel`, { ...options, method: "POST", @@ -36,5 +37,8 @@ export function transformDagRunResource(transport: Transport) { }); } - return { transformRuns, cancel }; + return { + transformRuns: explain("transformRuns", transformRuns), + cancel: explain("cancel", cancel), + }; } diff --git a/packages/client/src/resources/transform-inspector.test.ts b/packages/client/src/resources/transform-inspector.test.ts index d7e1ba62..795cbce9 100644 --- a/packages/client/src/resources/transform-inspector.test.ts +++ b/packages/client/src/resources/transform-inspector.test.ts @@ -7,9 +7,13 @@ import { captureFetch, type FetchScript, jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -154,8 +158,11 @@ describe("transform-inspector resource wire requests", () => { ]); }); - it("refuses before the wire on a server without the Python transforms token", async () => { - const { mb, capture } = clientOver([], UNLICENSED_SERVER); + it("explains a premium refusal as the missing Python transforms token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Python transforms"), probeResponse(UNLICENSED_SERVER)], + UNLICENSED_SERVER, + ); const error = await mb.transformInspector.discover(7).catch((caught: unknown) => caught); @@ -169,11 +176,17 @@ describe("transform-inspector resource wire requests", () => { tokenFeature: "transforms-python", serverVersion: "v1.60.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transforms/7/inspect", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses before the wire on a server older than the inspector, whatever its token grants", async () => { - const { mb, capture } = clientOver([], OLDER_LICENSED_SERVER); + it("explains an unrouted call on a server older than the inspector, whatever its token grants", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(OLDER_LICENSED_SERVER)], + OLDER_LICENSED_SERVER, + ); const error = await mb.transformInspector.discover(7).catch((caught: unknown) => caught); @@ -187,6 +200,9 @@ describe("transform-inspector resource wire requests", () => { tokenFeature: "transforms-python", serverVersion: "v1.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transforms/7/inspect", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/transform-inspector.ts b/packages/client/src/resources/transform-inspector.ts index 1b90b753..8114c08f 100644 --- a/packages/client/src/resources/transform-inspector.ts +++ b/packages/client/src/resources/transform-inspector.ts @@ -6,18 +6,20 @@ import { type TransformLensQueryInput, } from "../domain/transform-inspector"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; function lensPath(id: number, lensId: string): string { return `/api/ee/transforms/${id}/inspect/${encodeURIComponent(lensId)}`; } export function transformInspectorResource(transport: Transport) { + const explain = explainer(transport, "transformInspector"); + /** * Discover what the inspector can show for a transform: its source and target tables with field * statistics, the fields its query touches, and the lenses available on it. */ async function discover(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformInspector.discover", options); return transport.requestParsed(TransformInspection, `/api/ee/transforms/${id}/inspect`, { ...options, }); @@ -34,7 +36,6 @@ export function transformInspectorResource(transport: Transport) { params: LensParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("transformInspector.lens", options); return transport.requestParsed(TransformLens, lensPath(id, lensId), { ...options, query: { join_step: params.join_step }, @@ -52,7 +53,6 @@ export function transformInspectorResource(transport: Transport) { params: TransformLensQueryInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformInspector.query", options); return transport.requestParsed(CardQueryResult, `${lensPath(id, lensId)}/query`, { ...options, method: "POST", @@ -60,5 +60,9 @@ export function transformInspectorResource(transport: Transport) { }); } - return { discover, lens, query }; + return { + discover: explain("discover", discover), + lens: explain("lens", lens), + query: explain("query", query), + }; } diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts index 3fe7cdbd..e157b115 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -12,6 +12,7 @@ import { } from "../domain/transform-job"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; export interface TransformJobRunParams { /** Re-run the whole plan, including dependencies that are already fresh. */ @@ -20,9 +21,10 @@ export interface TransformJobRunParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformJobResource(transport: Transport) { + const explain = explainer(transport, "transformJob"); + /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transformJob.list", options); const { features } = await transport.server(options); // A bare array rather than a `{ data, total }` envelope, so the server reports no count. const data = await transport.requestParsed( @@ -35,7 +37,6 @@ export function transformJobResource(transport: Transport) { /** Get one transform job by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformJob.get", options); const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, @@ -47,7 +48,6 @@ export function transformJobResource(transport: Transport) { params: TransformJobCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.create", options); const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), "/api/transform-job", { ...options, @@ -62,7 +62,6 @@ export function transformJobResource(transport: Transport) { params: TransformJobUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.update", options); const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, @@ -73,7 +72,6 @@ export function transformJobResource(transport: Transport) { /** Delete a transform job by id, leaving the transforms it ran untouched. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformJob.delete", options); await transport.requestRaw(`/api/transform-job/${id}`, { ...options, method: "DELETE", @@ -90,7 +88,6 @@ export function transformJobResource(transport: Transport) { params: TransformJobRunParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.run", options); const { features } = await transport.server(options); return transport.requestParsed( transformJobRunResultSchema(features), @@ -104,7 +101,6 @@ export function transformJobResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("transformJob.transforms", options); const { features } = await transport.server(options); const data = await transport.requestParsed( z.array(transformRowSchema(features)), @@ -122,7 +118,6 @@ export function transformJobResource(transport: Transport) { active: boolean, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.setActive", options); return transport.requestParsed(TransformJobActiveResult, "/api/transform-job/active", { ...options, method: "PUT", @@ -130,5 +125,14 @@ export function transformJobResource(transport: Transport) { }); } - return { list, get, create, update, delete: remove, run, transforms, setActive }; + return { + list: explain("list", list), + get: explain("get", get), + create: explain("create", create), + update: explain("update", update), + delete: explain("delete", remove), + run: explain("run", run), + transforms: explain("transforms", transforms), + setActive: explain("setActive", setActive), + }; } diff --git a/packages/client/src/resources/transform-python.test.ts b/packages/client/src/resources/transform-python.test.ts index acbff7f7..ea8632ac 100644 --- a/packages/client/src/resources/transform-python.test.ts +++ b/packages/client/src/resources/transform-python.test.ts @@ -8,9 +8,12 @@ import { captureFetch, type FetchScript, jsonResponse, + premiumRefusalResponse, + probeResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -155,8 +158,11 @@ describe("transform-python resource wire requests", () => { ); }); - it("refuses before the wire on a server without the Python transforms token", async () => { - const { mb, capture } = clientOver([], UNLICENSED_SERVER); + it("explains a premium refusal as the missing Python transforms token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Python transforms"), probeResponse(UNLICENSED_SERVER)], + UNLICENSED_SERVER, + ); const error = await mb.transformPython.getLibrary("common").catch((caught: unknown) => caught); @@ -170,11 +176,20 @@ describe("transform-python resource wire requests", () => { tokenFeature: "transforms-python", serverVersion: "v1.60.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transforms-python/library/common", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses a test run before the wire on a server that takes source tables as a map", async () => { - const { mb, capture } = clientOver([], MAP_SOURCE_TABLES_SERVER); + it("explains a rejected test run on a server that takes source tables as a map", async () => { + const { mb, capture } = clientOver( + [ + jsonResponse({ errors: { source_tables: "map from table alias to table id" } }, 400), + probeResponse(MAP_SOURCE_TABLES_SERVER), + ], + MAP_SOURCE_TABLES_SERVER, + ); const error = await mb.transformPython.testRun(TEST_RUN).catch((caught: unknown) => caught); @@ -188,6 +203,9 @@ describe("transform-python resource wire requests", () => { tokenFeature: "transforms-python", serverVersion: "v1.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transforms-python/test-run", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/transform-python.ts b/packages/client/src/resources/transform-python.ts index 9ccc59d0..d5af0c02 100644 --- a/packages/client/src/resources/transform-python.ts +++ b/packages/client/src/resources/transform-python.ts @@ -9,6 +9,7 @@ import { type PythonTestRunResult, } from "../domain/transform-python"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; // The two answers a test run gives share only `logs`, so each is told apart by the key only it // carries. @@ -26,9 +27,10 @@ function libraryPath(path: string): string { } export function transformPythonResource(transport: Transport) { + const explain = explainer(transport, "transformPython"); + /** Get the Python library at a path, the user module every Python transform can import. */ async function getLibrary(path: string, options: RequestOptions = {}): Promise { - await transport.require("transformPython.getLibrary", options); return transport.requestParsed(PythonLibrary, libraryPath(path), { ...options }); } @@ -38,7 +40,6 @@ export function transformPythonResource(transport: Transport) { params: PythonLibraryUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformPython.updateLibrary", options); return transport.requestParsed(PythonLibrary, libraryPath(path), { ...options, method: "PUT", @@ -55,7 +56,6 @@ export function transformPythonResource(transport: Transport) { params: PythonTestRunInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformPython.testRun", options); return transport.requestParsed(PythonApiTestRun, "/api/ee/transforms-python/test-run", { ...options, method: "POST", @@ -63,5 +63,9 @@ export function transformPythonResource(transport: Transport) { }); } - return { getLibrary, updateLibrary, testRun }; + return { + getLibrary: explain("getLibrary", getLibrary), + updateLibrary: explain("updateLibrary", updateLibrary), + testRun: explain("testRun", testRun), + }; } diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts index b73e13fa..e0407335 100644 --- a/packages/client/src/resources/transform-tag.ts +++ b/packages/client/src/resources/transform-tag.ts @@ -7,6 +7,7 @@ import { } from "../domain/transform-tag"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; // `GET /api/transform-tag` answers a bare array rather than a `{ data, total }` envelope, so the // count a caller reads off `ListResult` is the array's own length and the server reports none. @@ -14,9 +15,10 @@ const TransformTagApiList = z.array(TransformTag); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformTagResource(transport: Transport) { + const explain = explainer(transport, "transformTag"); + /** List every transform tag the caller can see, built-in tags included. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transformTag.list", options); const data = await transport.requestParsed(TransformTagApiList, "/api/transform-tag", { ...options, }); @@ -28,7 +30,6 @@ export function transformTagResource(transport: Transport) { params: TransformTagCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTag.create", options); return transport.requestParsed(TransformTag, "/api/transform-tag", { ...options, method: "POST", @@ -42,7 +43,6 @@ export function transformTagResource(transport: Transport) { params: TransformTagUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTag.update", options); return transport.requestParsed(TransformTag, `/api/transform-tag/${id}`, { ...options, method: "PUT", @@ -52,7 +52,6 @@ export function transformTagResource(transport: Transport) { /** Delete a transform tag by id, detaching it from every transform and job carrying it. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformTag.delete", options); await transport.requestRaw(`/api/transform-tag/${id}`, { ...options, method: "DELETE", @@ -60,5 +59,10 @@ export function transformTagResource(transport: Transport) { }); } - return { list, create, update, delete: remove }; + return { + list: explain("list", list), + create: explain("create", create), + update: explain("update", update), + delete: explain("delete", remove), + }; } diff --git a/packages/client/src/resources/transform-test.test.ts b/packages/client/src/resources/transform-test.test.ts index 88b6673a..86557770 100644 --- a/packages/client/src/resources/transform-test.test.ts +++ b/packages/client/src/resources/transform-test.test.ts @@ -9,12 +9,16 @@ import { HttpError } from "../http/errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, + type FetchScript, jsonResponse, + premiumRefusalResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, thrownBy, - type FetchScript, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -246,8 +250,11 @@ describe("transform-test resource wire requests", () => { expect(error.specificFieldErrors).toEqual({ name: "should be at least 1 character" }); }); - it("refuses before the wire on a server older than the route, whatever its token grants", async () => { - const { mb, capture } = clientOver([], LICENSED_64); + it("explains an unrouted call on a server older than the route, whatever its token grants", async () => { + const { mb, capture } = clientOver( + [routeMissingResponse(), probeResponse(LICENSED_64)], + LICENSED_64, + ); const error = await thrownBy(() => mb.transformTest.list()); @@ -261,11 +268,17 @@ describe("transform-test resource wire requests", () => { tokenFeature: "transforms-testing", serverVersion: "v1.64.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transform-test", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); - it("refuses a server without the token feature before any request leaves", async () => { - const { mb, capture } = clientOver([], UNLICENSED_SERVER); + it("explains a premium refusal as the missing transforms-testing token", async () => { + const { mb, capture } = clientOver( + [premiumRefusalResponse("Transforms testing"), probeResponse(UNLICENSED_SERVER)], + UNLICENSED_SERVER, + ); const error = await thrownBy(() => mb.transformTest.list()); @@ -282,6 +295,9 @@ describe("transform-test resource wire requests", () => { tokenFeature: "transforms-testing", serverVersion: "v1.65.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/ee/transform-test", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); }); diff --git a/packages/client/src/resources/transform-test.ts b/packages/client/src/resources/transform-test.ts index 2a15e36f..aa8448df 100644 --- a/packages/client/src/resources/transform-test.ts +++ b/packages/client/src/resources/transform-test.ts @@ -8,6 +8,7 @@ import { } from "../domain/transform-test"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; +import { explainer } from "../version/refusal"; // `GET /api/ee/transform-test` answers a bare array rather than a `{ data, total }` envelope, so // the count a caller reads off `ListResult` is the array's own length and the server reports none. @@ -19,12 +20,13 @@ export interface TransformTestListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformTestResource(transport: Transport) { + const explain = explainer(transport, "transformTest"); + /** List the transform tests, optionally only those of one transform. */ async function list( params: TransformTestListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("transformTest.list", options); const data = await transport.requestParsed(TransformTestApiList, "/api/ee/transform-test", { ...options, query: { "transform-id": params["transform-id"] }, @@ -34,7 +36,6 @@ export function transformTestResource(transport: Transport) { /** Get a transform test by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformTest.get", options); return transport.requestParsed(TransformTest, `/api/ee/transform-test/${id}`, { ...options }); } @@ -47,7 +48,6 @@ export function transformTestResource(transport: Transport) { params: TransformTestCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTest.create", options); return transport.requestParsed(TransformTest, "/api/ee/transform-test", { ...options, method: "POST", @@ -64,7 +64,6 @@ export function transformTestResource(transport: Transport) { params: TransformTestUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTest.update", options); return transport.requestParsed(TransformTest, `/api/ee/transform-test/${id}`, { ...options, method: "PUT", @@ -74,7 +73,6 @@ export function transformTestResource(transport: Transport) { /** Delete a transform test by id. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformTest.delete", options); await transport.requestRaw(`/api/ee/transform-test/${id}`, { ...options, method: "DELETE", @@ -89,12 +87,18 @@ export function transformTestResource(transport: Transport) { * refused and nothing meaningful ran. */ async function run(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformTest.run", options); return transport.requestParsed(TransformTestRunResult, `/api/ee/transform-test/${id}/run`, { ...options, method: "POST", }); } - return { list, get, create, update, delete: remove, run }; + return { + list: explain("list", list), + get: explain("get", get), + create: explain("create", create), + update: explain("update", update), + delete: explain("delete", remove), + run: explain("run", run), + }; } diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts index 4b7c06df..0325afb4 100644 --- a/packages/client/src/resources/transform.test.ts +++ b/packages/client/src/resources/transform.test.ts @@ -7,9 +7,12 @@ import { captureFetch, type FetchScript, jsonResponse, + probeResponse, + routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { @@ -361,8 +364,8 @@ describe("transform resource wire requests", () => { ]); }); - it("refuses the checkpoint reset before the wire on a server without the route", async () => { - const { mb, capture } = clientOver([]); + it("explains an unrouted checkpoint reset on a server without the route", async () => { + const { mb, capture } = clientOver([routeMissingResponse(), probeResponse(SERVER)]); const error = await mb.transform.resetCheckpoint(7).catch((caught: unknown) => caught); @@ -376,7 +379,10 @@ describe("transform resource wire requests", () => { tokenFeature: null, serverVersion: "v0.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/transform/7/reset-checkpoint", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); it("sends the checkpoint reset request", async () => { @@ -436,8 +442,8 @@ describe("transform resource wire requests", () => { ]); }); - it("refuses a DAG run before the wire on a server without DAG runs", async () => { - const { mb, capture } = clientOver([]); + it("explains an unrouted DAG run on a server without DAG runs", async () => { + const { mb, capture } = clientOver([routeMissingResponse(), probeResponse(SERVER)]); const error = await mb.transform .runDag(7, { direction: "downstream" }) @@ -453,7 +459,10 @@ describe("transform resource wire requests", () => { tokenFeature: null, serverVersion: "v0.59.0", }); - expect(capture.calls).toEqual([]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/transform/7/run-dag", + `https://mb.example.com/metabase${PROBE_PATH}`, + ]); }); it("starts a run and returns without polling when no wait is given", async () => { diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index 9654cf02..6144c9c1 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -26,6 +26,8 @@ import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; import { type PollOptions, pollUntil } from "../poll"; import type { Features } from "../version/features"; +import { methodRequirements } from "../version/requirements"; +import { explainer } from "../version/refusal"; // `GET /api/transform` and `GET /api/transform/{id}/dependencies` both answer a bare array rather // than a `{ data, total }` envelope, so the count a caller reads off `ListResult` is the array's @@ -80,6 +82,8 @@ const TransformDagTransformList = z.array(TransformDagTransform); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { + const explain = explainer(transport, "transform"); + async function requestTransformList( path: string, opts: TransportRequestOptions, @@ -96,13 +100,11 @@ export function transformResource(transport: Transport) { /** List every transform the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transform.list", options); return requestTransformList("/api/transform", { ...options }); } /** Get one transform by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.get", options); const { features } = await transport.server(options); return transport.requestParsed(transformDetailSchema(features), `/api/transform/${id}`, { ...options, @@ -114,7 +116,6 @@ export function transformResource(transport: Transport) { params: TransformCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transform.create", options); return requestTransform("/api/transform", { ...options, method: "POST", body: params }); } @@ -124,13 +125,11 @@ export function transformResource(transport: Transport) { params: TransformUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transform.update", options); return requestTransform(`/api/transform/${id}`, { ...options, method: "PUT", body: params }); } /** Delete a transform by id, leaving any table it already materialized in place. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.delete", options); await transport.requestRaw(`/api/transform/${id}`, { ...options, method: "DELETE", @@ -140,7 +139,6 @@ export function transformResource(transport: Transport) { /** Drop a transform's materialized output table, keeping the transform definition. */ async function deleteTable(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.deleteTable", options); await transport.requestRaw(`/api/transform/${id}/table`, { ...options, method: "DELETE", @@ -153,13 +151,11 @@ export function transformResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("transform.dependencies", options); return requestTransformList(`/api/transform/${id}/dependencies`, { ...options }); } /** Request cancellation of a transform's current run. */ async function cancel(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.cancel", options); await transport.requestRaw(`/api/transform/${id}/cancel`, { ...options, method: "POST", @@ -169,7 +165,6 @@ export function transformResource(transport: Transport) { /** Get one transform run by run id — not by the id of the transform that produced it. */ async function getRun(runId: number, options: RequestOptions = {}): Promise { - await transport.require("transform.getRun", options); return transport.requestParsed(TransformRun, `/api/transform/run/${runId}`, { ...options }); } @@ -178,13 +173,13 @@ export function transformResource(transport: Transport) { params: TransformRunPageParams = {}, options: TransformRunPageOptions = {}, ): AsyncIterable> { - await transport.require("transform.runPages", options); yield* paginatePages(transport, "/api/transform/run", TransformRun, { query: { "transform-ids": params["transform-ids"] }, ...(options.offset !== undefined && { offset: options.offset }), ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), + features: methodRequirements("transform.runPages"), }); } @@ -196,7 +191,6 @@ export function transformResource(transport: Transport) { params: TransformRunSummaryPageParams = {}, options: TransformRunPageOptions = {}, ): AsyncIterable> { - await transport.require("transform.runSummaryPages", options); yield* paginatePages(transport, "/api/transform/runs", TransformRunSummary, { query: { types: params.types, @@ -212,6 +206,7 @@ export function transformResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), + features: methodRequirements("transform.runSummaryPages"), }); } @@ -220,7 +215,6 @@ export function transformResource(transport: Transport) { * source rather than the rows past the last checkpoint. */ async function resetCheckpoint(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.resetCheckpoint", options); await transport.requestRaw(`/api/transform/${id}/reset-checkpoint`, { ...options, method: "POST", @@ -240,7 +234,6 @@ export function transformResource(transport: Transport) { params: TransformDagParams, options: RequestOptions = {}, ): Promise { - await transport.require("transform.runDag", options); return transport.requestParsed(TransformDagRunResult, `/api/transform/${id}/run-dag`, { ...options, method: "POST", @@ -254,7 +247,6 @@ export function transformResource(transport: Transport) { params: TransformDagParams, options: RequestOptions = {}, ): Promise> { - await transport.require("transform.dagTransforms", options); const data = await transport.requestParsed( TransformDagTransformList, `/api/transform/${id}/dag-transforms`, @@ -273,7 +265,6 @@ export function transformResource(transport: Transport) { params: TransformRunParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("transform.run", options); const kickoff = await transport.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { ...options, method: "POST", @@ -323,20 +314,20 @@ export function transformResource(transport: Transport) { } return { - list, - get, - create, - update, - delete: remove, - deleteTable, - dependencies, - cancel, - getRun, - runPages, - runSummaryPages, - resetCheckpoint, - runDag, - dagTransforms, - run, + list: explain("list", list), + get: explain("get", get), + create: explain("create", create), + update: explain("update", update), + delete: explain("delete", remove), + deleteTable: explain("deleteTable", deleteTable), + dependencies: explain("dependencies", dependencies), + cancel: explain("cancel", cancel), + getRun: explain("getRun", getRun), + runPages: runPages, + runSummaryPages: runSummaryPages, + resetCheckpoint: explain("resetCheckpoint", resetCheckpoint), + runDag: explain("runDag", runDag), + dagTransforms: explain("dagTransforms", dagTransforms), + run: explain("run", run), }; } diff --git a/packages/client/src/resources/upload.ts b/packages/client/src/resources/upload.ts index 7958fce0..c3e2c856 100644 --- a/packages/client/src/resources/upload.ts +++ b/packages/client/src/resources/upload.ts @@ -22,7 +22,6 @@ export function uploadResource(transport: Transport) { params: UploadCsvParams, options: RequestOptions = {}, ): Promise { - await transport.require("upload.createFromCsv", options); const form = buildCsvFormData(file); form.append("collection_id", params.collection_id); const response = await transport.requestRaw("/api/upload/csv", { diff --git a/packages/client/src/resources/user.ts b/packages/client/src/resources/user.ts index 1a660714..c360dfec 100644 --- a/packages/client/src/resources/user.ts +++ b/packages/client/src/resources/user.ts @@ -4,7 +4,6 @@ import type { RequestOptions, Transport } from "../http/transport"; export function userResource(transport: Transport) { /** Get the user the request's credentials authenticate as. */ async function current(options: RequestOptions = {}): Promise { - await transport.require("user.current", options); return transport.requestParsed(CurrentUser, "/api/user/current", { ...options }); } diff --git a/packages/client/src/testing/fake-client.test.ts b/packages/client/src/testing/fake-client.test.ts index 8aada629..46938115 100644 --- a/packages/client/src/testing/fake-client.test.ts +++ b/packages/client/src/testing/fake-client.test.ts @@ -125,21 +125,6 @@ describe("createFakeClient", () => { await expect(client.server()).rejects.toThrow("no server profile in fake client plan"); }); - it("records each required key with the requests already served, and refuses none", async () => { - const fake = createFakeClient({ - routes: [{ path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }], - }); - - await fake.client.require("gitSync.branches"); - await fake.client.requestParsed(PingResponse, PATH); - await fake.client.require("card.list"); - - expect(fake.required).toEqual([ - { key: "gitSync.branches", precedingRequests: 0 }, - { key: "card.list", precedingRequests: 1 }, - ]); - }); - it("records each feature list a parameter asked for, with the requests already served", async () => { const fake = createFakeClient({ routes: [{ path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }], diff --git a/packages/client/src/testing/fake-client.ts b/packages/client/src/testing/fake-client.ts index 67e41dc4..0fefa9da 100644 --- a/packages/client/src/testing/fake-client.ts +++ b/packages/client/src/testing/fake-client.ts @@ -8,8 +8,8 @@ import { } from "../http/transport"; import { NO_SERVER_TAG, parseJsonResponse } from "../http/response-shape"; import type { FeatureName } from "../version/features"; +import { PROBE_PATH } from "../version/probe"; import type { ServerProfile } from "../version/profile"; -import type { MethodKey } from "../version/requirements"; const FAKE_STATUS = 200; @@ -49,32 +49,30 @@ export interface FakeClientPlan { readonly server?: ServerProfile; } -// A key a resource method required, with how many requests the fake had already served by then — -// zero proves the method asked before it reached for the wire. -export interface FakeRequirement { - readonly key: MethodKey; - readonly precedingRequests: number; -} - -// A feature list a method asked for because of a parameter it was given, recorded the same way. +// A feature list a method asked for because of a parameter it was given, with how many requests the +// fake had already served by then — zero proves the method asked before it reached for the wire. export interface FakeFeatureRequirement { readonly features: ReadonlyArray; readonly precedingRequests: number; } -// The fake records what a method required and never refuses: enforcement belongs to the real -// transport and is proven there, so a resource test needs no profile to reach its wire assertions. +// The fake records what a method required and never refuses or explains: both belong to the real +// transport and are proven there, so a resource test needs no profile to reach its wire assertions. export interface FakeClient { readonly client: Transport; readonly calls: ReadonlyArray; - readonly required: ReadonlyArray; readonly requiredFeatures: ReadonlyArray; } export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { const calls: FakeClientCall[] = []; - const required: FakeRequirement[] = []; const requiredFeatures: FakeFeatureRequirement[] = []; + const plannedServer = (): ServerProfile => { + if (plan.server === undefined) { + throw new Error("no server profile in fake client plan"); + } + return plan.server; + }; const client: Transport = { async requestParsed( schema: ZodType, @@ -110,17 +108,20 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { throw new Error("requestStream not implemented in fake client"); }, async server() { - if (plan.server === undefined) { - throw new Error("no server profile in fake client plan"); - } - return plan.server; + return plannedServer(); + }, + async verifiedServer() { + return plannedServer(); }, - async require(key) { - required.push({ key, precedingRequests: calls.length }); + async probe(reader) { + return client.requestParsed(reader, PROBE_PATH); }, async requireFeatures(features) { requiredFeatures.push({ features, precedingRequests: calls.length }); }, + async explainRefusal(_features, error) { + return error; + }, }; - return { client, calls, required, requiredFeatures }; + return { client, calls, requiredFeatures }; } diff --git a/packages/client/src/testing/fetch-capture.ts b/packages/client/src/testing/fetch-capture.ts index bd11db34..c047ee6a 100644 --- a/packages/client/src/testing/fetch-capture.ts +++ b/packages/client/src/testing/fetch-capture.ts @@ -1,6 +1,11 @@ // Test-only fetch double (the keyring-mock pattern): scripted responses plus a capture of every // call, shared by the client/oauth/logout suites so each doesn't grow its own drifting stub. +import type { ServerProfile } from "../version/profile"; + +const NOT_FOUND_STATUS = 404; +const PAYMENT_REQUIRED_STATUS = 402; + // A caller identity no production code could produce, so a hardcoded fallback cannot fake it. export const TEST_USER_AGENT = "some-embedder/9.9.9"; @@ -64,6 +69,31 @@ export function jsonResponse(body: unknown, status = 200): Response { }); } +// What `/api/session/properties` answers for the server `profile` describes, so a test hands the +// client's own probe the server it means. +export function probeResponse(profile: ServerProfile): Response { + return jsonResponse({ + version: { tag: profile.version.tag }, + ...(profile.tokenFeatures !== null && { "token-features": profile.tokenFeatures }), + }); +} + +// Metabase's answer for a path no route serves. +export function routeMissingResponse(): Response { + return jsonResponse("API endpoint does not exist.", NOT_FOUND_STATUS); +} + +// Metabase's answer for a route behind a premium feature its license does not grant. +export function premiumRefusalResponse(featureLabel: string): Response { + return jsonResponse( + { + message: `${featureLabel} is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/`, + status: "error-premium-feature-not-available", + }, + PAYMENT_REQUIRED_STATUS, + ); +} + async function capturedBody(body: RequestInit["body"]): Promise { if (body === undefined || body === null) { return null; diff --git a/packages/client/src/version/explain-guard.test.ts b/packages/client/src/version/explain-guard.test.ts new file mode 100644 index 00000000..ee7a64a8 --- /dev/null +++ b/packages/client/src/version/explain-guard.test.ts @@ -0,0 +1,85 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { isMethodKey, METHOD_KEYS, methodRequirements } from "./requirements"; + +const RESOURCES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "resources"); + +const NAMESPACE = /explainer\(transport, "(\w+)"\)/; +// `explain("", ` and whether a parameter-feature callback follows the method. +const EXPLAINED = /\bexplain\(\s*"(\w+)",\s*\w+\s*(,)?/g; +const PAGED = /methodRequirements\("(\w+\.\w+)"\)/g; + +// Methods whose whole requirement guards a parameter a server without it would drop, so they refuse +// before the request through `requireFeatures` and the server never gets to refuse them. +const REFUSED_BEFORE_THE_REQUEST: ReadonlySet = new Set(["field.setDataSensitivity"]); + +interface Wiring { + explained: Set; + explainedWithParameters: Set; + paged: Set; +} + +function readWiring(): Wiring { + const wiring: Wiring = { + explained: new Set(), + explainedWithParameters: new Set(), + paged: new Set(), + }; + for (const file of readdirSync(RESOURCES)) { + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) { + continue; + } + const source = readFileSync(resolve(RESOURCES, file), "utf8"); + for (const [, key] of source.matchAll(PAGED)) { + if (key !== undefined) { + wiring.paged.add(key); + } + } + const namespace = NAMESPACE.exec(source)?.[1]; + if (namespace === undefined) { + continue; + } + for (const [, name, parameters] of source.matchAll(EXPLAINED)) { + if (name === undefined) { + continue; + } + const key = `${namespace}.${name}`; + wiring.explained.add(key); + if (parameters !== undefined) { + wiring.explainedWithParameters.add(key); + } + } + } + return wiring; +} + +describe("the explanation of gated methods", () => { + const wiring = readWiring(); + + // An unwrapped gated method still works; its refusal just reaches the caller as a bare HTTP + // error instead of the feature the server lacks. + it("wraps every method that needs a feature, or hands its features to the page walk", () => { + const unexplained = METHOD_KEYS.filter( + (key) => + methodRequirements(key).length > 0 && + !wiring.explained.has(key) && + !wiring.paged.has(key) && + !REFUSED_BEFORE_THE_REQUEST.has(key), + ); + expect(unexplained).toEqual([]); + }); + + it("wraps nothing that could have no feature to name", () => { + const pointless = [...wiring.explained].filter( + (key) => + isMethodKey(key) && + methodRequirements(key).length === 0 && + !wiring.explainedWithParameters.has(key), + ); + expect(pointless).toEqual([]); + }); +}); diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts index 6d7a92b1..94046813 100644 --- a/packages/client/src/version/features.ts +++ b/packages/client/src/version/features.ts @@ -19,7 +19,8 @@ export const FEATURE_RULES = { transformJobActivation: { since: 61 }, measures: { since: 59 }, library: { since: 59, tokenFeature: "library" }, - remoteSync: { since: 60, tokenFeature: "remote_sync" }, + remoteSync: { since: 58, tokenFeature: "remote_sync" }, + remoteSyncRemoteChanges: { since: 59, tokenFeature: "remote_sync" }, remoteSyncExportPreflight: { since: 63, tokenFeature: "remote_sync" }, remoteSyncBranchGuard: { since: 63 }, remoteSyncMerge: { since: 63, tokenFeature: "remote_sync" }, @@ -39,7 +40,6 @@ export const FEATURE_RULES = { invalidMbqlIsBadRequest: { since: 59 }, boxplotDisplay: { since: 59 }, nativeTableTemplateTag: { since: 59 }, - compiledQueryOmitsCollection: { since: 59, until: 62 }, smartLinkMeasureModel: { since: 60 }, dashboardSubscriptionFilters: { since: 58, tokenFeature: "dashboard_subscription_filters" }, dependencyGraph: { since: 58, tokenFeature: "dependencies" }, @@ -62,7 +62,7 @@ export const FEATURE_RULES = { sourceReplacement: { since: 60, tokenFeature: "dependencies" }, metricDefinitionQuery: { since: 60 }, metricDimensionListing: { since: 64 }, - oauthFullAccessScope: { since: 63 }, + oauthFullAccessScopeUnadvertised: { since: 64 }, exportCsvByteOrderMark: { since: 63 }, } satisfies Record; diff --git a/packages/client/src/version/probe.ts b/packages/client/src/version/probe.ts index d4743cf8..9cb3d532 100644 --- a/packages/client/src/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -1,3 +1,5 @@ +import type { ZodType } from "zod"; + import { SessionProperties, type TokenFeatures } from "../domain/session-properties"; import type { Transport } from "../http/transport"; @@ -6,8 +8,8 @@ import { type Edition, editionFromTag, parseTag, type ServerVersion } from "./ta export const PROBE_PATH = "/api/session/properties"; export const PROBE_TIMEOUT_MS = 10_000; -// `edition` is what the tag stamps, and null when the tag stamps nothing (`vUNKNOWN`); a `-SNAPSHOT` -// tag stamps it even though it names no release. +// `edition` is what the tag stamps, and null when the tag stamps nothing (`vUNKNOWN`); a +// `-SNAPSHOT` tag stamps it even though it names no release. export interface ServerInfo { readonly version: ServerVersion; readonly edition: Edition | null; @@ -17,21 +19,31 @@ export interface ServerInfo { } interface ProbeOptions { + timeoutMs?: number; retries?: number; } -// The probe runs before a profile exists, so it asks only for the wire and never for `require`. +// The probe runs before a profile exists, so it asks only for the wire. type ProbeTransport = Pick; export async function probeServer( client: ProbeTransport, opts: ProbeOptions = {}, ): Promise { - const properties = await client.requestParsed(SessionProperties, PROBE_PATH, { - timeoutMs: PROBE_TIMEOUT_MS, + return serverInfoFromProperties(await probeProperties(client, SessionProperties, opts)); +} + +// The session properties through `reader`, for a caller that wants a setting they carry beside the +// version and token features a probe reads. +export async function probeProperties( + client: ProbeTransport, + reader: ZodType, + opts: ProbeOptions = {}, +): Promise { + return client.requestParsed(reader, PROBE_PATH, { + timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS, retries: opts.retries ?? 0, }); - return serverInfoFromProperties(properties); } export function serverInfoFromProperties(properties: SessionProperties): ServerInfo { diff --git a/packages/client/src/version/refusal.test.ts b/packages/client/src/version/refusal.test.ts new file mode 100644 index 00000000..3bd297bc --- /dev/null +++ b/packages/client/src/version/refusal.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import type { ExplainOptions, RequestOptions, Transport } from "../http/transport"; +import type { PollOptions } from "../poll"; + +import type { FeatureName } from "./features"; +import { explainer } from "./refusal"; + +interface ExplainCall { + features: readonly FeatureName[]; + error: unknown; + options: ExplainOptions | undefined; +} + +const EXPLAINED = new Error("explained"); + +function notOnThisPath(): never { + throw new Error("the explainer reaches the transport only to explain a failure"); +} + +// A transport that records what each failure asked it to explain and answers `EXPLAINED`. +function recordingTransport(): { transport: Transport; calls: ExplainCall[] } { + const calls: ExplainCall[] = []; + const transport: Transport = { + requestParsed: notOnThisPath, + requestRaw: notOnThisPath, + requestStream: notOnThisPath, + server: notOnThisPath, + verifiedServer: notOnThisPath, + probe: notOnThisPath, + requireFeatures: notOnThisPath, + async explainRefusal(features, error, options) { + calls.push({ features, error, options }); + return EXPLAINED; + }, + }; + return { transport, calls }; +} + +const REFUSED = new Error("refused"); + +async function refuse(_kind: string, _id: number, _options: RequestOptions = {}): Promise { + throw REFUSED; +} + +describe("explainer", () => { + it("answers what the wrapped method answers and explains nothing", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "erd"); + + const get = explain("get", async (id: number) => ({ id })); + + expect(await get(4)).toEqual({ id: 4 }); + expect(calls).toEqual([]); + }); + + it("explains a failure by the method's own requirements, and throws what the transport answers", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "transformJob"); + + const setActive = explain("setActive", refuse); + + await expect(setActive("job", 1)).rejects.toBe(EXPLAINED); + expect(calls).toEqual([ + { features: ["transformJobActivation", "transforms"], error: REFUSED, options: {} }, + ]); + }); + + it("puts the features the arguments bring ahead of the method's own", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "dependency"); + + const graph = explain("graph", refuse, (kind) => + kind === "measure" ? ["measureDependencyGraph"] : [], + ); + + await expect(graph("measure", 1)).rejects.toBe(EXPLAINED); + await expect(graph("card", 1)).rejects.toBe(EXPLAINED); + expect(calls.map((call) => call.features)).toEqual([ + ["measureDependencyGraph", "dependencyGraph"], + ["dependencyGraph"], + ]); + }); + + it("hands the explanation the caller's signal and timeout from the trailing options", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "erd"); + const controller = new AbortController(); + + const get = explain("get", refuse); + + await expect( + get("x", 1, { signal: controller.signal, timeoutMs: 250, retries: 0 }), + ).rejects.toBe(EXPLAINED); + expect(calls.map((call) => call.options)).toEqual([ + { signal: controller.signal, timeoutMs: 250 }, + ]); + }); + + it("bounds the explanation by a trailing wait schedule's signal and overall timeout", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "gitSync"); + const controller = new AbortController(); + + const waitForTask = explain("waitForTask", async (_wait: PollOptions) => { + throw REFUSED; + }); + + await expect( + waitForTask({ intervalMs: 10, timeoutMs: 600_000, signal: controller.signal }), + ).rejects.toBe(EXPLAINED); + expect(calls.map((call) => call.options)).toEqual([ + { signal: controller.signal, timeoutMs: 600_000 }, + ]); + }); + + it("reads no options from a trailing params object", async () => { + const { transport, calls } = recordingTransport(); + const explain = explainer(transport, "erd"); + + const get = explain("get", async (_params: { "database-id": number }) => { + throw REFUSED; + }); + + await expect(get({ "database-id": 1 })).rejects.toBe(EXPLAINED); + expect(calls.map((call) => call.options)).toEqual([{}]); + }); +}); diff --git a/packages/client/src/version/refusal.ts b/packages/client/src/version/refusal.ts new file mode 100644 index 00000000..3de41d11 --- /dev/null +++ b/packages/client/src/version/refusal.ts @@ -0,0 +1,70 @@ +import type { ExplainOptions, Transport } from "../http/transport"; + +import type { FeatureName } from "./features"; +import { isMethodKey, type MethodKey, methodRequirements } from "./requirements"; + +// The features a call needs only because of the arguments it was handed, where a server without +// them rejects the request rather than dropping the argument. +export type ParameterFeatures = (...args: A) => readonly FeatureName[]; + +type NamespaceOf = K extends `${infer N}.${string}` ? N : never; +type MethodNamespace = NamespaceOf; +type NameIn = K extends `${N}.${infer M}` ? M : never; +type MethodName = NameIn; + +type Explain = ( + name: MethodName, + method: (...args: A) => Promise, + // The method alone sets the exposed signature; a callback reading fewer arguments must not drop + // the rest from it. + parameterFeatures?: NoInfer>, +) => (...args: A) => Promise; + +// Wraps one resource's gated methods as the client exposes them: the server decides whether the +// call is allowed, and a refusal it answers with reads as the feature it lacks. Parameter features +// come first, since a parameter's floor sits above its method's. +export function explainer( + transport: Transport, + namespace: N, +): Explain { + return (name, method, parameterFeatures) => { + const key = methodKey(namespace, name); + return async (...args) => { + try { + return await method(...args); + } catch (error) { + const features = [ + ...(parameterFeatures === undefined ? [] : parameterFeatures(...args)), + ...methodRequirements(key), + ]; + throw await transport.explainRefusal(features, error, explainOptionsOf(args)); + } + }; + }; +} + +function methodKey(namespace: string, name: string): MethodKey { + const key = `${namespace}.${name}`; + if (!isMethodKey(key)) { + throw new Error(`no requirements entry for ${key}`); + } + return key; +} + +// Every resource method takes its `RequestOptions` last, and a call that leaves them out ends on +// its params instead. Metabase names no field `signal` or `timeoutMs`, so a trailing argument +// holding either is the caller's own budget for the call: its `RequestOptions`, or a wait schedule +// (`PollOptions`) whose signal and overall timeout bound the explanation as they bound the wait. +function explainOptionsOf(args: readonly unknown[]): ExplainOptions { + const last = args.at(-1); + if (typeof last !== "object" || last === null) { + return {}; + } + const signal = "signal" in last && last.signal instanceof AbortSignal ? last.signal : null; + const timeoutMs = + "timeoutMs" in last && typeof last.timeoutMs === "number" ? last.timeoutMs : null; + return { + ...(signal !== null && { signal }), + ...(timeoutMs !== null && { timeoutMs }), + }; +} diff --git a/packages/client/src/version/requirement-check.test.ts b/packages/client/src/version/requirement-check.test.ts index a10d3484..90e41cdd 100644 --- a/packages/client/src/version/requirement-check.test.ts +++ b/packages/client/src/version/requirement-check.test.ts @@ -51,7 +51,7 @@ describe("checkFeatures over a method's requirements", () => { detail: "This operation requires the 'remote_sync' premium feature (not enabled on this server).", feature: "remoteSync", - since: 60, + since: 58, tokenFeature: "remote_sync", serverVersion: "v1.60.4", }); @@ -113,7 +113,7 @@ describe("checkFeatures over a method's requirements", () => { detail: "This operation requires the 'remote_sync' premium feature (not enabled on this server).", feature: "remoteSync", - since: 60, + since: 58, tokenFeature: "remote_sync", serverVersion: "vUNKNOWN", }); diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index d8097bec..0c95a237 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -1,80 +1,17 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import ts from "typescript"; import { describe, expect, it } from "vitest"; import { createClient } from "../client"; -import { errorMessage } from "../errors"; -import type { ClientCredentials, Transport } from "../http/transport"; -import { cardResource } from "../resources/card"; -import { collectionResource } from "../resources/collection"; -import { contentTranslationResource } from "../resources/content-translation"; -import { dashboardResource } from "../resources/dashboard"; -import { databaseResource } from "../resources/database"; -import { datasetResource } from "../resources/dataset"; -import { dependencyResource } from "../resources/dependency"; -import { documentResource } from "../resources/document"; -import { eidTranslationResource } from "../resources/eid-translation"; -import { erdResource } from "../resources/erd"; -import { fieldResource } from "../resources/field"; -import { gitSyncResource } from "../resources/git-sync"; -import { glossaryResource } from "../resources/glossary"; -import { libraryResource } from "../resources/library"; -import { measureResource } from "../resources/measure"; -import { metricResource } from "../resources/metric"; -import { moderationReviewResource } from "../resources/moderation-review"; -import { notificationResource } from "../resources/notification"; -import { permissionResource } from "../resources/permission"; -import { pulseResource } from "../resources/pulse"; -import { replacementResource } from "../resources/replacement"; -import { revisionResource } from "../resources/revision"; -import { searchResource } from "../resources/search"; -import { segmentResource } from "../resources/segment"; -import { settingResource } from "../resources/setting"; -import { setupResource } from "../resources/setup"; -import { snippetResource } from "../resources/snippet"; -import { tableResource } from "../resources/table"; -import { timelineEventResource } from "../resources/timeline-event"; -import { timelineResource } from "../resources/timeline"; -import { transformDagRunResource } from "../resources/transform-dag-run"; -import { transformInspectorResource } from "../resources/transform-inspector"; -import { transformJobResource } from "../resources/transform-job"; -import { transformPythonResource } from "../resources/transform-python"; -import { transformResource } from "../resources/transform"; -import { transformTagResource } from "../resources/transform-tag"; -import { transformTestResource } from "../resources/transform-test"; -import { uploadResource } from "../resources/upload"; -import { userResource } from "../resources/user"; -import { createFakeClient } from "../testing/fake-client"; +import type { ClientCredentials } from "../http/transport"; import { TEST_USER_AGENT } from "../testing/fetch-capture"; import { FEATURE_RULES, type FeatureRule } from "./features"; -import { createServerProfile } from "./profile"; -import { - isMethodKey, - METHOD_REQUIREMENTS, - type MethodKey, - methodRequirements, -} from "./requirements"; - -const RESOURCES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "resources"); +import { isMethodKey, METHOD_REQUIREMENTS, methodRequirements } from "./requirements"; const CREDENTIALS: ClientCredentials = { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "mb_requirements_key" }, }; -// A method that picks its wire shape by generation reads the profile between asking and requesting. -const SERVER = createServerProfile({ - edition: "oss", - version: { kind: "release", tag: "v0.63.0", major: 63, patch: 0 }, - date: null, - hash: null, - tokenFeatures: null, -}); - const ESCAPE_HATCHES: ReadonlySet = new Set([ "server", "requestParsed", @@ -104,381 +41,11 @@ function methodKeysOnClient(): string[] { return keys.toSorted(); } -function namespaceOf(fileName: string): string { - const [head, ...rest] = fileName.replace(/\.ts$/, "").split("-"); - return [head, ...rest.map((part) => part[0]?.toUpperCase() + part.slice(1))].join(""); -} - -interface RequiredLiteral { - readonly file: string; - readonly key: string; - // The function whose body holds the call, named as the resource exposes it. - readonly method: string; -} - -const RESOURCE_FACTORY_SUFFIX = "Resource"; -const TRANSPORT_PARAMETER = "transport"; -const REQUIRE_METHOD = "require"; - -function parseSource(file: string): ts.SourceFile { - const text = readFileSync(resolve(RESOURCES_DIR, file), "utf8"); - return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); -} - -function descendantsOf(node: ts.Node): ts.Node[] { - const found: ts.Node[] = []; - const visit = (child: ts.Node): void => { - found.push(child); - ts.forEachChild(child, visit); - }; - ts.forEachChild(node, visit); - return found; -} - -function isExportedResourceFactory(statement: ts.Statement): statement is ts.FunctionDeclaration { - if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) { - return false; - } - const exported = (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0; - return exported && statement.name.text.endsWith(RESOURCE_FACTORY_SUFFIX); -} - -function resourceFactoryOf(source: ts.SourceFile): ts.FunctionDeclaration | null { - const factories = source.statements.filter(isExportedResourceFactory); - if (factories.length > 1) { - throw new Error(`${source.fileName}: ${factories.length} resource factories in one file`); - } - const [factory] = factories; - return factory ?? null; -} - -type ExposedEntry = readonly [declared: string, exposed: string]; - -function exposedEntryOf(property: ts.ObjectLiteralElementLike, file: string): ExposedEntry { - if (ts.isShorthandPropertyAssignment(property)) { - return [property.name.text, property.name.text]; - } - const aliased = - ts.isPropertyAssignment(property) && - ts.isIdentifier(property.name) && - ts.isIdentifier(property.initializer); - if (!aliased) { - throw new Error( - `${file}: \`${property.getText()}\` exposes something other than a declared function`, - ); - } - return [property.initializer.text, property.name.text]; -} - -// `{ delete: remove, import: importFromRemote }`: the name a caller reaches a method by, for a -// function whose own name is a reserved word. -function exposedNamesOf( - factory: ts.FunctionDeclaration, - file: string, -): ReadonlyMap { - const returned = factory.body?.statements.find(ts.isReturnStatement); - if (returned?.expression === undefined || !ts.isObjectLiteralExpression(returned.expression)) { - throw new Error(`${file}: the resource factory returns no object literal`); - } - return new Map(returned.expression.properties.map((property) => exposedEntryOf(property, file))); -} - -function isRequireCall(node: ts.Node): node is ts.CallExpression { - if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { - return false; - } - const { expression: receiver, name } = node.expression; - return ( - ts.isIdentifier(receiver) && - receiver.text === TRANSPORT_PARAMETER && - name.text === REQUIRE_METHOD - ); -} - -function requiredKeyOf(call: ts.CallExpression, file: string): string { - const [key] = call.arguments; - if (key === undefined || !ts.isStringLiteral(key)) { - throw new Error( - `${file}: \`${call.getText()}\` asks for something other than a string literal`, - ); - } - return key.text; -} - -function enclosingDeclaredFunction( - call: ts.CallExpression, - factory: ts.FunctionDeclaration, - file: string, -): string { - let current: ts.Node = call.parent; - while (current !== factory) { - if (ts.isFunctionDeclaration(current) && current.name !== undefined) { - return current.name.text; - } - current = current.parent; - } - throw new Error(`${file}: \`${call.getText()}\` sits outside a declared method`); -} - -function requiredLiterals(): RequiredLiteral[] { - return readdirSync(RESOURCES_DIR) - .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) - .flatMap((file) => { - const factory = resourceFactoryOf(parseSource(file)); - if (factory === null) { - return []; - } - const exposed = exposedNamesOf(factory, file); - return descendantsOf(factory) - .filter(isRequireCall) - .map((call) => { - const declared = enclosingDeclaredFunction(call, factory, file); - return { - file, - key: requiredKeyOf(call, file), - method: exposed.get(declared) ?? declared, - }; - }); - }); -} - -// One call per resource, with the wire request the fake refuses once the method reaches it — the -// refusal proves the method went to the wire only after it asked. -interface ResourceDrive { - readonly key: MethodKey; - readonly invoke: (transport: Transport) => Promise; - readonly wireError: string; -} - -const CSV = { filename: "rows.csv", bytes: new TextEncoder().encode("a\n1\n") }; - -const DRIVES: ReadonlyArray = [ - { - key: "card.get", - invoke: (t) => cardResource(t).get(1), - wireError: "unexpected request: GET /api/card/1", - }, - { - key: "collection.tree", - invoke: (t) => collectionResource(t).tree(), - wireError: "unexpected request: GET /api/collection/tree", - }, - { - key: "contentTranslation.download", - invoke: (t) => contentTranslationResource(t).download(), - wireError: "requestStream not implemented in fake client", - }, - { - key: "dashboard.get", - invoke: (t) => dashboardResource(t).get(1), - wireError: "unexpected request: GET /api/dashboard/1", - }, - { - key: "database.schemas", - invoke: (t) => databaseResource(t).schemas(1), - wireError: "unexpected request: GET /api/database/1/schemas", - }, - { - key: "dataset.query", - invoke: (t) => datasetResource(t).query({}), - wireError: "unexpected request: POST /api/dataset", - }, - { - key: "dependency.graph", - invoke: (t) => dependencyResource(t).graph("card", 1), - wireError: "unexpected request: GET /api/ee/dependencies/graph", - }, - { - key: "document.get", - invoke: (t) => documentResource(t).get(1), - wireError: "unexpected request: GET /api/document/1", - }, - { - key: "eidTranslation.translate", - invoke: (t) => eidTranslationResource(t).translate({ entity_ids: {} }), - wireError: "unexpected request: POST /api/eid-translation/translate", - }, - { - key: "erd.get", - invoke: (t) => erdResource(t).get({ "database-id": 1 }), - wireError: "unexpected request: GET /api/ee/erd", - }, - { - key: "field.get", - invoke: (t) => fieldResource(t).get(1), - wireError: "unexpected request: GET /api/field/1", - }, - { - key: "gitSync.branches", - invoke: (t) => gitSyncResource(t).branches(), - wireError: "unexpected request: GET /api/ee/remote-sync/branches", - }, - { - key: "glossary.list", - invoke: (t) => glossaryResource(t).list(), - wireError: "unexpected request: GET /api/glossary", - }, - { - key: "library.get", - invoke: (t) => libraryResource(t).get(), - wireError: "unexpected request: GET /api/ee/library/", - }, - { - key: "measure.get", - invoke: (t) => measureResource(t).get(1), - wireError: "unexpected request: GET /api/measure/1", - }, - { - key: "metric.dimensions", - invoke: (t) => metricResource(t).dimensions(1), - wireError: "unexpected request: GET /api/metric/1/dimension", - }, - { - key: "moderationReview.create", - invoke: (t) => - moderationReviewResource(t).create({ moderated_item_id: 1, moderated_item_type: "card" }), - wireError: "unexpected request: POST /api/moderation-review", - }, - { - key: "notification.get", - invoke: (t) => notificationResource(t).get(1), - wireError: "unexpected request: GET /api/notification/1", - }, - { - key: "permission.databaseGraph", - invoke: (t) => permissionResource(t).databaseGraph(1), - wireError: "unexpected request: GET /api/permissions/graph/db/1", - }, - { - key: "pulse.get", - invoke: (t) => pulseResource(t).get(1), - wireError: "unexpected request: GET /api/pulse/1", - }, - { - key: "replacement.getRun", - invoke: (t) => replacementResource(t).getRun(1), - wireError: "unexpected request: GET /api/ee/replacement/runs/1", - }, - { - key: "revision.list", - invoke: (t) => revisionResource(t).list("card", 1), - wireError: "unexpected request: GET /api/revision/card/1", - }, - { - key: "search.query", - invoke: (t) => searchResource(t).query(), - wireError: "unexpected request: GET /api/search", - }, - { - key: "segment.get", - invoke: (t) => segmentResource(t).get(1), - wireError: "unexpected request: GET /api/segment/1", - }, - { - key: "setting.list", - invoke: (t) => settingResource(t).list(), - wireError: "unexpected request: GET /api/setting", - }, - { - key: "setup.create", - invoke: (t) => - setupResource(t).create({ - token: "setup-token", - user: { email: "admin@example.com", password: "pw" }, - prefs: { site_name: "Test" }, - }), - wireError: "unexpected request: POST /api/setup", - }, - { - key: "snippet.get", - invoke: (t) => snippetResource(t).get(1), - wireError: "unexpected request: GET /api/native-query-snippet/1", - }, - { - key: "table.get", - invoke: (t) => tableResource(t).get(1), - wireError: "unexpected request: GET /api/table/1", - }, - { - key: "timeline.get", - invoke: (t) => timelineResource(t).get(1), - wireError: "unexpected request: GET /api/timeline/1", - }, - { - key: "timelineEvent.get", - invoke: (t) => timelineEventResource(t).get(1), - wireError: "unexpected request: GET /api/timeline-event/1", - }, - { - key: "transform.get", - invoke: (t) => transformResource(t).get(1), - wireError: "unexpected request: GET /api/transform/1", - }, - { - key: "transformDagRun.transformRuns", - invoke: (t) => transformDagRunResource(t).transformRuns(1), - wireError: "unexpected request: GET /api/transform-dag-run/1/transform-runs", - }, - { - key: "transformInspector.discover", - invoke: (t) => transformInspectorResource(t).discover(1), - wireError: "unexpected request: GET /api/ee/transforms/1/inspect", - }, - { - key: "transformJob.get", - invoke: (t) => transformJobResource(t).get(1), - wireError: "unexpected request: GET /api/transform-job/1", - }, - { - key: "transformPython.getLibrary", - invoke: (t) => transformPythonResource(t).getLibrary("common"), - wireError: "unexpected request: GET /api/ee/transforms-python/library/common", - }, - { - key: "transformTag.list", - invoke: (t) => transformTagResource(t).list(), - wireError: "unexpected request: GET /api/transform-tag", - }, - { - key: "transformTest.get", - invoke: (t) => transformTestResource(t).get(1), - wireError: "unexpected request: GET /api/ee/transform-test/1", - }, - { - key: "upload.createFromCsv", - invoke: (t) => uploadResource(t).createFromCsv(CSV, { collection_id: "1" }), - wireError: "requestRaw not implemented in fake client", - }, - { - key: "user.current", - invoke: (t) => userResource(t).current(), - wireError: "unexpected request: GET /api/user/current", - }, -]; - -async function messageOf(pending: Promise): Promise { - try { - await pending; - } catch (error) { - return errorMessage(error); - } - return "resolved"; -} - describe("METHOD_REQUIREMENTS", () => { it("names exactly the methods the client exposes", () => { expect(methodKeysOnClient()).toEqual(TABLE_KEYS); }); - it("is required by every resource method under the method's own key, and by nothing else", () => { - const literals = requiredLiterals(); - const foreign = literals.filter( - ({ file, key, method }) => key !== `${namespaceOf(file)}.${method}`, - ); - expect(foreign).toEqual([]); - expect(literals.map(({ key }) => key).toSorted()).toEqual(TABLE_KEYS); - }); - // A requirement says the route exists on the server; a rule that ends at some major describes // a shape an older server had, and refusing on it would name a floor the server is above. it("requires no feature bounded by `until`", () => { @@ -492,19 +59,6 @@ describe("METHOD_REQUIREMENTS", () => { ); expect(bounded).toEqual([]); }); - - it("has every resource namespace driven below", () => { - const driven = new Set(DRIVES.map((drive) => drive.key.split(".")[0])); - const named = new Set(TABLE_KEYS.map((key) => key.split(".")[0])); - expect([...driven].toSorted()).toEqual([...named].toSorted()); - }); - - it.each(DRIVES)("$key asks the transport before its first request", async (drive) => { - const fake = createFakeClient({ server: SERVER }); - - expect(await messageOf(drive.invoke(fake.client))).toBe(drive.wireError); - expect(fake.required).toEqual([{ key: drive.key, precedingRequests: 0 }]); - }); }); describe("isMethodKey", () => { diff --git a/packages/client/src/version/requirements.ts b/packages/client/src/version/requirements.ts index 637b003e..429a39a0 100644 --- a/packages/client/src/version/requirements.ts +++ b/packages/client/src/version/requirements.ts @@ -66,7 +66,7 @@ export const METHOD_REQUIREMENTS = { "gitSync.cancelTask": ["remoteSync"], "gitSync.isDirty": ["remoteSync"], "gitSync.dirty": ["remoteSync"], - "gitSync.hasRemoteChanges": ["remoteSync"], + "gitSync.hasRemoteChanges": ["remoteSyncRemoteChanges"], "gitSync.import": ["remoteSync"], "gitSync.export": ["remoteSync"], "gitSync.exportPreflight": ["remoteSyncExportPreflight"], @@ -74,7 +74,7 @@ export const METHOD_REQUIREMENTS = { "gitSync.branches": ["remoteSync"], "gitSync.createBranch": ["remoteSync"], "gitSync.setCollectionSynced": ["remoteSync"], - "gitSync.syncedCollections": ["remoteSync"], + "gitSync.syncedCollections": [], "gitSync.remoteUrl": [], "gitSync.branch": [], "gitSync.trackedBranch": [], diff --git a/tests/e2e/cli-error.ts b/tests/e2e/cli-error.ts index 77a9bb3b..b314d899 100644 --- a/tests/e2e/cli-error.ts +++ b/tests/e2e/cli-error.ts @@ -17,7 +17,7 @@ const CliErrorEnvelope = z type CliErrorEnvelope = z.infer; // The e2e harness is never a TTY, so the CLI renders errors as a JSON envelope on stderr (the -// agent-facing path); in a TTY it renders plain text. Any leading warn() lines (e.g. preflight +// agent-facing path); in a TTY it renders plain text. Any leading warn() lines (e.g. skew // notices) precede the envelope, whose first line starts with "{". function parseEnvelope(stderr: string): CliErrorEnvelope | null { const lines = stderr.split("\n"); diff --git a/tests/e2e/dependency.e2e.test.ts b/tests/e2e/dependency.e2e.test.ts index 70a91355..f45ee511 100644 --- a/tests/e2e/dependency.e2e.test.ts +++ b/tests/e2e/dependency.e2e.test.ts @@ -18,7 +18,7 @@ import { CommandHelpEntry } from "../../packages/cli/src/runtime/command-help"; import { readBootstrap, type E2EBootstrap, type ServerIdentity } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile } from "./seed-profile"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; import { SEEDED } from "./seed/seeded"; import { requirementFailure, requireServer, serverHas } from "./server-gate"; @@ -54,8 +54,8 @@ const skipReason = requireServer("dependency › dependency e2e against EE depen ]); const listingGap = requirementFailure(["dependencyItemListings"]); -// The listing verbs need a newer server than `graph`, and the preflight reports the version gap -// before the missing token feature. +// The listing verbs need a newer server than `graph`, and the explained refusal names the version +// gap before the missing token feature. function listingLiveRefusal(server: ServerIdentity): string { if (listingGap?.reason !== "version-too-old") { return DEPENDENCIES_REFUSAL; @@ -160,7 +160,7 @@ describe("dependency arg validation e2e (no Metabase contact required)", () => { expect(result.stdout).toBe(""); }); - it("graph refuses before any request when the cached probe lacks the dependencies feature", async () => { + it("graph sends the request a cached probe without the dependencies feature would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 64); @@ -169,21 +169,21 @@ describe("dependency arg validation e2e (no Metabase contact required)", () => { configHome, }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(DEPENDENCIES_REFUSAL); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); - it("unreferenced refuses before any request when the cached probe lacks the dependencies feature", async () => { + it("unreferenced sends the request a cached probe without the dependencies feature would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 64); const result = await runCli({ args: ["dependency", "unreferenced", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(DEPENDENCIES_REFUSAL); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/document.e2e.test.ts b/tests/e2e/document.e2e.test.ts index 71223328..4a107b3a 100644 --- a/tests/e2e/document.e2e.test.ts +++ b/tests/e2e/document.e2e.test.ts @@ -8,7 +8,7 @@ import { DocumentListEnvelope } from "../../packages/cli/src/commands/document/l import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile } from "./seed-profile"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; import { SEEDED } from "./seed/seeded"; import { serverHas } from "./server-gate"; @@ -297,7 +297,7 @@ describe("document e2e", () => { expect(cliErrorMessage(result.stderr)).toBe(copyRefusal(bootstrap.server.version?.tag)); }); - it("copy refuses before any request when the cached probe says v58", async () => { + it("copy sends the request a cached v58 probe would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); @@ -306,9 +306,9 @@ describe("document e2e", () => { configHome, }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(copyRefusal("v0.58.0")); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/field.e2e.test.ts b/tests/e2e/field.e2e.test.ts index 97f6631c..3fa0dabf 100644 --- a/tests/e2e/field.e2e.test.ts +++ b/tests/e2e/field.e2e.test.ts @@ -17,7 +17,7 @@ import { FieldRemappingResult } from "../../packages/cli/src/output/views/field" import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile } from "./seed-profile"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; import { SEEDED } from "./seed/seeded"; import { requireServer, serverHas } from "./server-gate"; @@ -624,7 +624,7 @@ describe("field e2e", () => { expect(result.stdout).toBe(""); }); - it("set-sensitivity refuses before any request when the cached probe says v63", async () => { + it("set-sensitivity asks the server itself rather than a cached v63 probe", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 63); @@ -633,9 +633,31 @@ describe("field e2e", () => { configHome, }); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); + expect(result.stdout).toBe(""); + }); + + it("set-sensitivity labels a field, or refuses where a fresh probe shows the server would drop the label", async () => { + const result = await runCli({ + args: ["field", "set-sensitivity", String(customersEmailFieldId), "PII", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + if (serverHas("fieldDataSensitivity")) { + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, FieldWithDataSensitivityCompact).data_sensitivity).toBe( + "PII", + ); + return; + } + const serverTag = bootstrap.server.version?.tag; + assert(serverTag !== undefined, "a server without the column is a release with a tag"); expect(result.exitCode).toBe(2); expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(sensitivityRefusal("v0.63.0")); + expect(cliErrorMessage(result.stderr)).toBe(sensitivityRefusal(serverTag)); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index 89f770a1..f6a52d35 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -12,8 +12,8 @@ import { CommandHelpEntry } from "../../packages/cli/src/runtime/command-help"; import { readBootstrap, type E2EBootstrap, type ServerIdentity } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; -import { seedProbedProfile } from "./seed-profile"; -import { requirementFailure, requireServer, serverHas } from "./server-gate"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; +import { requirementFailure, requireServer, requireServerWithout } from "./server-gate"; const REMOTE_SYNC_REFUSAL = "This operation requires the 'remote_sync' premium feature (not enabled on this server)."; @@ -22,11 +22,24 @@ const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets thi const skipReason = requireServer("git-sync › git-sync e2e against EE git-sync endpoints", [ "remoteSync", ]); +const remoteChangesSkipReason = requireServer( + "git-sync › has-remote-changes against a licensed server with the remote-changes route", + ["remoteSyncRemoteChanges"], +); const preflightSkipReason = requireServer( "git-sync › export-preflight against a licensed server with the preflight route", ["remoteSyncExportPreflight"], ); const preflightGap = requirementFailure(["remoteSyncExportPreflight"]); +const remoteSyncGap = requirementFailure(["remoteSync"]); +const withoutRemoteSyncSkipReason = requireServerWithout( + "git-sync › import and export against a server without remote sync", + ["remoteSync"], +); +const withoutPreflightSkipReason = requireServerWithout( + "git-sync › export-preflight against a server without the preflight route", + ["remoteSyncExportPreflight"], +); function olderServerRefusal(serverTag: string): string { return `This operation requires Metabase v63+ (this server is ${serverTag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`; @@ -143,27 +156,27 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { expect(result.stdout).toBe(""); }); - it("export-preflight refuses before any request when the cached probe lacks remote sync", async () => { + it("export-preflight asks the server itself rather than a cached probe without remote sync", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 64); const result = await runCli({ args: ["git-sync", "export-preflight", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(REMOTE_SYNC_REFUSAL); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); - it("export-preflight refuses before any request when the cached probe is older than the route", async () => { + it("export-preflight asks the server itself rather than a cached probe older than the route", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 62); const result = await runCli({ args: ["git-sync", "export-preflight", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(olderServerRefusal("v0.62.0")); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); @@ -182,7 +195,56 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { }); }); -describe.skipIf(preflightSkipReason === null)( +// A server without remote sync shows no tracked branch, so the CLI sends the import or export without +// one and the server's own refusal, explained by a fresh probe, names what is missing. +describe.skipIf(withoutRemoteSyncSkipReason !== null)( + "git-sync import and export against a server without remote sync", + () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function liveRefusal(): string { + if (remoteSyncGap === null) { + throw new Error("this lane runs only on a server without remote sync"); + } + return remoteSyncGap.reason === "version-too-old" + ? `${remoteSyncGap.detail}\n${DOWNGRADE_REMEDY}` + : remoteSyncGap.detail; + } + + it.each([["import"], ["export"]])( + "%s is sent and the server's refusal names the missing feature (exit 2)", + async (verb) => { + const result = await runCli({ + args: ["git-sync", verb, "--no-wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe(liveRefusal()); + expect(result.stdout).toBe(""); + }, + ); + }, +); + +describe.skipIf(withoutPreflightSkipReason !== null)( "git-sync export-preflight capability gate against a server without the preflight route", () => { let bootstrap: E2EBootstrap; @@ -359,55 +421,52 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(parseJson(result.stdout, WaitResult)).toEqual({ status: "idle" }); }); - it("import without git-sync configured refuses, before the request where the server expects a branch", async () => { + it("import without git-sync configured refuses before the request, having read the unset branch", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["git-sync", "import", "--no-wait", "--json"], configHome, env: authEnv(), }); - if (serverHas("remoteSyncBranchGuard")) { - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("config"); - expect(cliErrorMessage(result.stderr)).toBe( - "git-sync tracks no branch: the remote-sync-branch setting is unset", - ); - return; - } - expect(result.exitCode).toBe(1); - expect(cliErrorCategory(result.stderr)).toBe("http"); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe( + "git-sync tracks no branch: the remote-sync-branch setting is unset", + ); + expect(result.stdout).toBe(""); }); - it("export without git-sync configured refuses, before the request where the server expects a branch", async () => { + it("export without git-sync configured refuses before the request, having read the unset branch", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["git-sync", "export", "--no-wait", "--json"], configHome, env: authEnv(), }); - if (serverHas("remoteSyncBranchGuard")) { - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("config"); - expect(cliErrorMessage(result.stderr)).toBe( - "git-sync tracks no branch: the remote-sync-branch setting is unset", - ); - return; - } - expect(result.exitCode).toBe(1); - expect(cliErrorCategory(result.stderr)).toBe("http"); - }); - it("has-remote-changes without git-sync configured surfaces the server's 400 message", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["git-sync", "has-remote-changes", "--json"], - configHome, - env: authEnv(), - }); - expect(result.exitCode).toBe(1); - expect(cliErrorMessage(result.stderr)).toBe("Remote sync is not configured."); + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe( + "git-sync tracks no branch: the remote-sync-branch setting is unset", + ); + expect(result.stdout).toBe(""); }); + it.skipIf(remoteChangesSkipReason !== null)( + "has-remote-changes without git-sync configured surfaces the server's 400 message", + async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["git-sync", "has-remote-changes", "--json"], + configHome, + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(cliErrorMessage(result.stderr)).toBe("Remote sync is not configured."); + }, + ); + it("cancel-task surfaces the server's 400 message when there is no running task", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ diff --git a/tests/e2e/oauth.e2e.test.ts b/tests/e2e/oauth.e2e.test.ts index 11fd03b5..cca2fe50 100644 --- a/tests/e2e/oauth.e2e.test.ts +++ b/tests/e2e/oauth.e2e.test.ts @@ -18,10 +18,10 @@ import { } from "./setup/oauth-harness"; // The full-API OAuth backend (the session-middleware bearer bridge plus the mb:full scope) ships -// in Metabase v63, so head images have it and the 58–61 matrix stacks don't: ≤59 has no OAuth -// server at all, and 60–62 expose one scoped to the agent API/MCP only — its bearer tokens 401 on -// the REST API. The suite self-skips when bootstrap's live discovery probe found no authorization -// server advertising the full-access scope. +// from Metabase v62.3.3, so head images have it and the 58–61 matrix stacks don't: ≤59 has no +// OAuth server at all, and 60, 61 and the earlier 62 patches expose one scoped to the agent +// API/MCP only — its bearer tokens 401 on the REST API. The suite self-skips when bootstrap's live +// discovery probe found no authorization server granting the full-access scope. // // `mb auth login` opens a real browser, so we can't drive it as a subprocess. Instead we run the // library `oauthLogin()` in-process with an injected "browser" that authenticates as admin and diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index 46061b7d..4df23023 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -13,7 +13,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { assertCompactColumns, assertCompletedQuery } from "./card-query"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; -import { QUERY_NORMALIZATION_MESSAGE, serverHas } from "./server-gate"; +import { QUERY_NORMALIZATION_MESSAGE } from "./server-gate"; import { SEEDED } from "./seed/seeded"; const VALID_QUERY = { @@ -78,12 +78,6 @@ function ordersByStatusNative(): Record { }; } -// A server that drops the compiled query's collection from every answer cannot say whether it has -// one. -function compiledCollection(): Pick { - return serverHas("compiledQueryOmitsCollection") ? { collection: null } : {}; -} - describe("query e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -420,7 +414,6 @@ describe("query e2e", () => { expect(compiled).toEqual({ query: expect.stringContaining('FROM\n "public"."orders"'), params: null, - ...compiledCollection(), }); }); @@ -437,7 +430,6 @@ describe("query e2e", () => { expect(compiled).toEqual({ query: expect.stringContaining(' FROM "public"."orders"'), params: null, - ...compiledCollection(), }); expect(compiled.query).not.toContain("\n"); }); diff --git a/tests/e2e/search.e2e.test.ts b/tests/e2e/search.e2e.test.ts index b56e61ce..1fc239cf 100644 --- a/tests/e2e/search.e2e.test.ts +++ b/tests/e2e/search.e2e.test.ts @@ -12,6 +12,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; +import { serverHas } from "./server-gate"; const ORDERS_BY_STATUS_COMPACT = { id: SEEDED.ordersCardId, @@ -111,6 +112,41 @@ describe("search e2e", () => { }); }); + it("--verified finds only verified content, or refuses where the server would ignore the filter", async () => { + const configHome = await makeIsolatedConfigHome(); + const search = async () => + runCli({ args: ["search", "Orders", "--verified", "--json"], configHome, env: authEnv() }); + + if (!serverHas("contentVerification")) { + const result = await search(); + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe( + "This operation requires the 'content_verification' premium feature (not enabled on this server).", + ); + expect(result.stdout).toBe(""); + return; + } + const verified = await runCli({ + args: ["card", "verify", String(SEEDED.ordersCardId), "--json"], + configHome, + env: authEnv(), + }); + expect(verified.exitCode, verified.stderr).toBe(0); + + const envelope = await pollUntil( + async () => { + const result = await search(); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, SearchListEnvelope); + }, + (found) => found.returned > 0, + SEARCH_INDEX_POLL, + ); + + expect(envelope.data).toEqual([ORDERS_BY_STATUS_COMPACT]); + }); + it("--models card narrows the result to the cards-only set", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ diff --git a/tests/e2e/seed-profile.ts b/tests/e2e/seed-profile.ts index 18968508..9117f5c7 100644 --- a/tests/e2e/seed-profile.ts +++ b/tests/e2e/seed-profile.ts @@ -10,6 +10,10 @@ import { const SEED_PROFILE_NAME = "default"; +// What a command sent to the seeded profile's server reports: nothing listens there, so an answer +// of this kind proves the request left rather than being refused on the cached probe's word. +export const UNREACHABLE_SEED_MESSAGE = "Could not reach Metabase: fetch failed"; + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key]; @@ -21,13 +25,13 @@ function restoreEnv(key: string, value: string | undefined): void { // The CLI's own storage writes the seeded records, so the binary under test reads a profile it // could have written itself; the storage reads its config home and keyring switch off the // environment, which is pointed at the test's config home for the write alone. -async function withSeedEnv(configHome: string, seed: () => Promise): Promise { +async function withSeedEnv(configHome: string, seed: () => Promise): Promise { const prevXdg = process.env["XDG_CONFIG_HOME"]; const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; process.env["XDG_CONFIG_HOME"] = configHome; process.env["MB_CLI_DISABLE_KEYRING"] = "1"; try { - await seed(); + return await seed(); } finally { restoreEnv("XDG_CONFIG_HOME", prevXdg); restoreEnv("MB_CLI_DISABLE_KEYRING", prevKeyring); @@ -44,12 +48,10 @@ export async function seedProbedProfileAt( configHome: string, target: SeedTarget, server: ServerInfo, -): Promise { - await withSeedEnv(configHome, async () => { - await writeProbedProfile(SEED_PROFILE_NAME, server, target); - }); +): Promise { + return withSeedEnv(configHome, async () => writeProbedProfile(SEED_PROFILE_NAME, server, target)); } -export async function seedProbedProfile(configHome: string, major: number): Promise { - await seedProbedProfileAt(configHome, UNREACHABLE_TARGET, probeAt(major)); +export async function seedProbedProfile(configHome: string, major: number): Promise { + return seedProbedProfileAt(configHome, UNREACHABLE_TARGET, probeAt(major)); } diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index 3a06185c..9c2881f5 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -80,8 +80,22 @@ export function requireServer(lane: string, required: readonly FeatureName[]): s return failure.detail; } +// The inverse gate, for a lane pinning how a server without `features` answers: a stack holding +// every one of them cannot show that, so the lane skips there and is reported like any other. +export function requireServerWithout( + lane: string, + features: readonly FeatureName[], +): string | null { + if (requirementFailure(features) !== null) { + return null; + } + const reason = `this server has ${features.join(", ")}, so it cannot show how one without them answers`; + recordGateSkip(lane, reason); + return reason; +} + const OAUTH_UNSUPPORTED_REASON = - "server does not support full-API OAuth login (Metabase v63+) — re-run e2e:bootstrap if the image changed"; + "server does not support full-API OAuth login (Metabase v62.3.3+) — re-run e2e:bootstrap if the image changed"; // Gate for the OAuth login suite: a version check would be wrong here (head images without the // OAuth backend would run and fail), so bootstrap probes the discovery endpoint live and the @@ -95,13 +109,13 @@ export function requireOAuthServer(lane: string): string | null { return OAUTH_UNSUPPORTED_REASON; } -// The first of `required` this stack lacks, as the CLI's preflight would report it, so a suite -// pinning a live refusal can pick the generation's exact message by its reason. +// The first of `required` this stack lacks, as the client explains the server's refusal of it, so a +// suite pinning a live refusal can pick the generation's exact message by its reason. export function requirementFailure(required: readonly FeatureName[]): RequirementFailure | null { return checkFeatures(required, resolveServerProfile()); } -// The exact question the CLI's preflight and the client's `require()` ask. It logs nothing: a +// The exact question the client asks of a fresh probe to explain a refusal or to check a parameter. It logs nothing: a // suite reading it asserts one of two exact outcomes rather than skipping coverage. export function serverHas(feature: FeatureName): boolean { return resolveServerProfile().features[feature]; diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index c316796f..c6333a1d 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -97,11 +97,11 @@ const TRANSFORM_UNAVAILABLE_ON_58 = { const GIT_SYNC_UNAVAILABLE_ON_58 = { name: "git-sync", failure: { - reason: "version-too-old", + reason: "missing-token-feature", detail: - "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", feature: "remoteSync", - since: 60, + since: 58, tokenFeature: "remote_sync", serverVersion: "v0.58.0", }, @@ -177,9 +177,9 @@ describe("skills e2e", () => { expect(result.stderr).toBe(""); }); - it("list in text mode against a v58 profile names each skipped skill on stderr", async () => { + it("list in text mode against a v58 profile names each skipped skill and the probe it judged by", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfile(configHome, 58); + const probedAt = await seedProbedProfile(configHome, 58); const result = await runCli({ args: ["skills", "list", "--format", "text"], configHome }); @@ -191,6 +191,7 @@ describe("skills e2e", () => { [ `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, + `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).`, ].join("\n"), ); }); diff --git a/tests/e2e/subscription.e2e.test.ts b/tests/e2e/subscription.e2e.test.ts index 7733fa7d..61ddee1e 100644 --- a/tests/e2e/subscription.e2e.test.ts +++ b/tests/e2e/subscription.e2e.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { + Pulse, PulseCompact, type PulseChannelCompact, type PulseCreateInput, @@ -9,9 +10,10 @@ import { parseJson } from "@metabase/client/json"; import { SubscriptionListEnvelope } from "../../packages/cli/src/commands/subscription/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; -import { cliErrorMessage } from "./cli-error"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { SEEDED } from "./seed/seeded"; +import { serverHas } from "./server-gate"; const SUBSCRIPTION_NAME = "Weekly orders"; const RECIPIENT = "team@example.com"; @@ -139,6 +141,47 @@ describe("subscription e2e", () => { expect(created).toEqual(expectedCompact(created.id)); }); + it("create with the subscription's own filter values keeps them, or refuses where the server would send unfiltered results", async () => { + const regionFilter = { id: "e2e-region", type: "category", default: ["West"] }; + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["subscription", "create", "--json"], + stdin: JSON.stringify({ ...NEW_SUBSCRIPTION_BODY, parameters: [regionFilter] }), + configHome, + env: authEnv(), + }); + + const listed = async () => { + const list = await runCli({ + args: ["subscription", "list", "--json"], + configHome, + env: authEnv(), + }); + expect(list.exitCode, list.stderr).toBe(0); + return parseJson(list.stdout, SubscriptionListEnvelope).total; + }; + if (serverHas("dashboardSubscriptionFilters")) { + expect(result.exitCode, result.stderr).toBe(0); + const created = parseJson(result.stdout, PulseCompact); + const full = await runCli({ + args: ["subscription", "get", String(created.id), "--full", "--json"], + configHome, + env: authEnv(), + }); + expect(full.exitCode, full.stderr).toBe(0); + expect(parseJson(full.stdout, Pulse).parameters).toEqual([regionFilter]); + expect(await listed()).toBe(1); + return; + } + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe( + "This operation requires the 'dashboard_subscription_filters' premium feature (not enabled on this server).", + ); + expect(result.stdout).toBe(""); + expect(await listed()).toBe(0); + }); + it("create with an unknown channel_type fails on Zod validation before any request", async () => { const result = await runCli({ args: ["subscription", "create", "--json"], diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index eb1fdd90..f1ecf6e7 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -18,7 +18,7 @@ import { TableListEnvelope } from "../../packages/cli/src/commands/table/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile } from "./seed-profile"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; import { SEEDED } from "./seed/seeded"; import { serverHas } from "./server-gate"; @@ -363,31 +363,27 @@ describe("table e2e", () => { ); }); - it("list --can-query refuses before any request when the cached probe says v58", async () => { + it("list --can-query asks the server itself rather than a cached v58 probe", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); const result = await runCli({ args: ["table", "list", "--can-query", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe( - `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, - ); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); - it("list --unused-only refuses before any request when the cached probe lacks the dependencies feature", async () => { + it("list --unused-only asks the server itself rather than a cached probe without the dependencies feature", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); const result = await runCli({ args: ["table", "list", "--unused-only", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe( - "This operation requires the 'dependencies' premium feature (not enabled on this server).", - ); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); @@ -401,7 +397,7 @@ describe("table e2e", () => { expect(result.stdout).toBe(""); }); - it("list refuses a medallion data layer name on a server that speaks tiers, and sends it to one that speaks medallions", async () => { + it("list sends a medallion data layer name, which a server that speaks tiers rejects and one that speaks medallions filters by", async () => { const result = await listWarehouse("--data-layer", "gold"); if (!serverHas("tableDataLayerTiers")) { @@ -409,10 +405,10 @@ describe("table e2e", () => { expect(parseJson(result.stdout, TableListEnvelope)).toEqual(warehouseEnvelope([])); return; } - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("http"); expect(cliErrorMessage(result.stderr)).toBe( - 'data_layer "gold" is a medallion name; this server names a table\'s layer final, internal, hidden', + "data-layer: should be either :final, :internal or :hidden, received: :gold", ); expect(result.stdout).toBe(""); }); @@ -731,6 +727,32 @@ describe("table e2e", () => { expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); }); + it("update withdraws data_authority on a server that keeps user edits apart, or refuses before the request on one that would fail the write", async () => { + const result = await runCli({ + args: [ + "table", + "update", + String(SEEDED.tables.reviews), + "--body", + JSON.stringify({ data_authority: null }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + if (serverHas("tableUserValueWithdrawal")) { + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, Table).id).toBe(SEEDED.tables.reviews); + return; + } + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + `This operation requires Metabase v64+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, + ); + expect(result.stdout).toBe(""); + }); + it("sync-schema queues a schema sync for the table and returns ok", async () => { const result = await runCli({ args: ["table", "sync-schema", String(SEEDED.tables.reviews), "--json"], @@ -833,7 +855,7 @@ describe("table e2e", () => { expect(cliErrorMessage(result.stderr)).toBe(bulkEditRefusal(bootstrap.server.version?.tag)); }); - it("bulk-edit refuses before any request when the cached probe says v58", async () => { + it("bulk-edit sends the request a cached v58 probe would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); @@ -848,9 +870,9 @@ describe("table e2e", () => { configHome, }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(bulkEditRefusal("v0.58.0")); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 6d261dc9..d00f103a 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -1080,24 +1080,5 @@ describe.skipIf(serverHas("transforms"))( ); expect(result.stdout).toBe(""); }); - - it("--skip-preflight bypasses the gate and surfaces the raw server 404 the gate prevents (exit 1)", async () => { - const serverTag = bootstrap.server.version?.tag; - assert(serverTag !== undefined, "gate block requires a known cached server version"); - const configHome = await loginWithCachedProbe(); - - const result = await runCli({ - args: ["transform", "list", "--skip-preflight", "--json"], - configHome, - }); - - expect(result.exitCode).toBe(1); - expect(cliErrorMessage(result.stderr)).toBe( - `This endpoint is not available on Metabase ${serverTag}: GET /api/transform. ` + - "It may require a newer Metabase major version.\n" + - "Run `mb auth list` to see this server's version.", - ); - expect(result.stdout).toBe(""); - }); }, ); diff --git a/tests/e2e/verify.e2e.test.ts b/tests/e2e/verify.e2e.test.ts index 1bc431a6..87413beb 100644 --- a/tests/e2e/verify.e2e.test.ts +++ b/tests/e2e/verify.e2e.test.ts @@ -10,9 +10,9 @@ import { CommandHelpEntry } from "../../packages/cli/src/runtime/command-help"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile } from "./seed-profile"; +import { seedProbedProfile, UNREACHABLE_SEED_MESSAGE } from "./seed-profile"; import { SEEDED } from "./seed/seeded"; -import { requireServer } from "./server-gate"; +import { requireServer, requireServerWithout } from "./server-gate"; const CONTENT_VERIFICATION_REFUSAL = "This operation requires the 'content_verification' premium feature (not enabled on this server)."; @@ -57,15 +57,15 @@ describe("verify arg validation e2e (no Metabase contact required)", () => { expect(result.stdout).toBe(""); }); - it("refuses before any request when the cached probe lacks content verification", async () => { + it("sends the verification a cached probe without content verification would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 64); const result = await runCli({ args: ["card", "verify", "1", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(CONTENT_VERIFICATION_REFUSAL); + expect(result.exitCode).toBe(1); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); @@ -87,7 +87,12 @@ describe("verify arg validation e2e (no Metabase contact required)", () => { ); }); -describe.skipIf(skipReason === null)( +const withoutVerificationSkipReason = requireServerWithout( + "verify › verify against a server without content verification", + ["contentVerification"], +); + +describe.skipIf(withoutVerificationSkipReason !== null)( "verify capability gate against a server without content verification", () => { let bootstrap: E2EBootstrap; diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index bb792d72..8c13571f 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -6,6 +6,7 @@ import { describeVersion } from "@metabase/client/version/tag"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; +import { MeasureListEnvelope } from "../../packages/cli/src/commands/measure/list"; import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; import { probeAt, @@ -17,22 +18,22 @@ import { import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile, seedProbedProfileAt, seedProfile } from "./seed-profile"; +import { + seedProbedProfile, + seedProbedProfileAt, + seedProfile, + UNREACHABLE_SEED_MESSAGE, +} from "./seed-profile"; import { E2E_BUILTIN_TRANSFORM_JOBS } from "./seed/ids"; import { requireServer } from "./server-gate"; const BEYOND_KNOWN = KNOWN_RANGE.max + 5; const BELOW_KNOWN = KNOWN_RANGE.min - 1; -const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; -const MEASURES_REFUSAL = - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it."; -const UNREACHABLE_MESSAGE = "Could not reach Metabase: fetch failed"; - const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max + 1}. Run \`mb upgrade\` for a newer CLI.`; -const OLDER_NOTICE = `Metabase v0.${BELOW_KNOWN}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`; +const OLDER_NOTICE = `Metabase v0.${BELOW_KNOWN}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`; -describe("version preflight enforcement e2e", () => { +describe("server-decided gating e2e", () => { const tempDirs: string[] = []; afterEach(async () => { @@ -45,31 +46,19 @@ describe("version preflight enforcement e2e", () => { return dir; } - it("refuses a command whose feature the cached server version predates (exit 2)", async () => { + it("sends a command whose feature a cached v58 probe predates, rather than refusing on the cache's word", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); const result = await runCli({ args: ["measure", "list"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe(`${MEASURES_REFUSAL}\n${DOWNGRADE_REMEDY}`); - expect(result.stdout).toBe(""); - }); - - it("bypasses the refusal and reaches the network layer when --skip-preflight is passed", async () => { - const configHome = await makeIsolatedConfigHome(); - await seedProbedProfile(configHome, 58); - - const result = await runCli({ args: ["measure", "list", "--skip-preflight"], configHome }); - expect(result.exitCode).toBe(1); expect(cliErrorCategory(result.stderr)).toBe("network"); - expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); - it("probes the server itself when a gated command runs without a cached probe and fails on the network layer", async () => { + it("sends a gated command without a cached probe, probing nothing first", async () => { const configHome = await makeIsolatedConfigHome(); await seedProfile(configHome); @@ -77,37 +66,19 @@ describe("version preflight enforcement e2e", () => { expect(result.exitCode).toBe(1); expect(cliErrorCategory(result.stderr)).toBe("network"); - expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); - it("refuses a token-gated command when the cached server lacks the premium feature (exit 2)", async () => { + it("sends a token-gated command a cached probe without the premium feature would have refused", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 60); const result = await runCli({ args: ["git-sync", "status"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe( - "This operation requires the 'remote_sync' premium feature (not enabled on this server).", - ); - expect(result.stdout).toBe(""); - }); - - it("bypasses the refusal via MB_CLI_SKIP_PREFLIGHT=1 and reaches the network layer", async () => { - const configHome = await makeIsolatedConfigHome(); - await seedProbedProfile(configHome, 58); - - const result = await runCli({ - args: ["measure", "list"], - configHome, - env: { MB_CLI_SKIP_PREFLIGHT: "1" }, - }); - expect(result.exitCode).toBe(1); expect(cliErrorCategory(result.stderr)).toBe("network"); - expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_SEED_MESSAGE); expect(result.stdout).toBe(""); }); }); @@ -239,26 +210,47 @@ describe("version skew notices e2e", () => { }); }); - // A cached v58 profile refuses measures before any request; the live server clears that floor, - // so the refusal is the stale-probe case the CLI must diagnose without retrying the command. - const refusalReprobeSkipReason = requireServer("version › re-probe on a refusal", ["measures"]); + // A cached v58 profile predates measures; the live server clears that floor, so the command must + // run on the server's word rather than be refused on the cache's. + const staleFloorSkipReason = requireServer("version › a stale cache below a floor", ["measures"]); - describe.skipIf(refusalReprobeSkipReason !== null)("re-probe on a refusal", () => { - it("re-probes once, refreshes the stale profile, and appends the change to the refusal", async () => { + describe.skipIf(staleFloorSkipReason !== null)("a stale cache below a floor", () => { + it("runs the command the stale cache predates against the live server that clears its floor", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfileAt(configHome, liveTarget(), probeAt(58)); - const seededAt = await lastProbedAt(configHome); const result = await runCli({ args: ["measure", "list", "--json"], configHome }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("capability"); - expect(cliErrorMessage(result.stderr)).toBe( - `${MEASURES_REFUSAL}\n${versionChangedNote("v0.58.0")}`, - ); - expect(result.stdout).toBe(""); - - await expectRefreshedProbe(configHome, seededAt); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, MeasureListEnvelope).has_more).toBe(false); }); }); + + // A license activated after the cache was written: the command's own check asks the server, and + // the answer replaces the cache. + const activatedLicenseSkipReason = requireServer( + "version › a license activated since the cache", + ["contentVerification"], + ); + + describe.skipIf(activatedLicenseSkipReason !== null)( + "a license activated since the cache", + () => { + it("runs a check against the live server and writes its answer back to the profile", async () => { + const configHome = await makeIsolatedConfigHome(); + const seededAt = await seedProbedProfileAt(configHome, liveTarget(), { + ...bootstrap.server, + tokenFeatures: {}, + }); + + const result = await runCli({ + args: ["search", "orders", "--verified", "--json"], + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + await expectRefreshedProbe(configHome, seededAt); + }); + }, + ); }); From abb1604628dd2533d3fd544393e223069db32375 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 19:13:39 -0400 Subject: [PATCH 02/11] fix review findings on server-authoritative gates - a 400 is a feature refusal only when its field errors name nothing outside what the missing feature gates; parameter features declare the request fields they cover, and the CapabilityError carries the server's answer as its cause - explainer offers explain and explainWalk, so a paged walk's refusal is explained without threading features through paginatePages - explanation runs under the failed request's own budget; a timeout leaves the refusal standing rather than reading as an interrupt - simplify the transport's probe state to the profile in force and the newest probe; onServerProbed fires only for the newest answer - requireFeatures always confirms against a verified profile - CLI: probe on the way out only for shape errors and unrouted 404s; decide the refreshed-profile note by the tag a shape was chosen for; save every probe, once per distinct answer per run - git-sync: refuse syncedCollections/branch without remoteSync; restore pre-v63 import/export without a readable branch; read named features - table update reports a dropped collection_id as PartialWriteError (exit 1) --- .claude/skills/add-resource-command/SKILL.md | 6 +- .claude/skills/review/SKILL.md | 2 +- CLAUDE.md | 2 +- README.md | 36 +-- docs/architecture.md | 10 +- packages/cli/src/commands/runtime.test.ts | 43 ++- packages/cli/src/commands/runtime.ts | 166 ++++++----- .../cli/src/core/auth/server-summary.test.ts | 2 +- packages/cli/src/core/auth/server-summary.ts | 2 +- packages/cli/src/output/error.test.ts | 2 + packages/cli/src/output/error.ts | 1 + packages/client/README.md | 4 +- packages/client/src/errors.ts | 26 ++ packages/client/src/http/errors.ts | 7 +- packages/client/src/http/transport.test.ts | 60 ++-- packages/client/src/http/transport.ts | 257 +++++++++++------- packages/client/src/index.ts | 2 + packages/client/src/paginate.test.ts | 36 +-- packages/client/src/paginate.ts | 21 +- .../src/resources/content-translation.ts | 2 +- packages/client/src/resources/dependency.ts | 20 +- packages/client/src/resources/document.ts | 2 +- packages/client/src/resources/erd.ts | 2 +- .../client/src/resources/git-sync.test.ts | 35 ++- packages/client/src/resources/git-sync.ts | 69 +++-- packages/client/src/resources/library.ts | 2 +- packages/client/src/resources/measure.ts | 2 +- packages/client/src/resources/metric.ts | 2 +- .../client/src/resources/moderation-review.ts | 2 +- packages/client/src/resources/replacement.ts | 2 +- packages/client/src/resources/revision.ts | 11 +- packages/client/src/resources/table.test.ts | 26 +- packages/client/src/resources/table.ts | 51 ++-- .../client/src/resources/transform-dag-run.ts | 2 +- .../src/resources/transform-inspector.ts | 2 +- .../client/src/resources/transform-job.ts | 2 +- .../client/src/resources/transform-python.ts | 2 +- .../client/src/resources/transform-tag.ts | 2 +- .../client/src/resources/transform-test.ts | 2 +- packages/client/src/resources/transform.ts | 9 +- packages/client/src/testing/fake-client.ts | 2 +- .../client/src/version/explain-guard.test.ts | 33 +-- .../client/src/version/preflight-error.ts | 20 +- packages/client/src/version/refusal.test.ts | 81 ++---- packages/client/src/version/refusal.ts | 84 +++--- .../client/src/version/requirement-check.ts | 19 ++ packages/client/src/version/requirements.ts | 6 +- tests/e2e/git-sync.e2e.test.ts | 2 +- tests/e2e/version.e2e.test.ts | 2 +- 49 files changed, 674 insertions(+), 509 deletions(-) diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index 7365e0a9..cac6dfe4 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -214,7 +214,7 @@ The nine conventions every method follows: `ListResult` = `{ data, total }`. 7. A string path parameter always goes through `encodeURIComponent`. 8. Every method carries the endpoint's description as a doc comment. -9. Every method has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), keyed exactly as the method is reached on the client: `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported through `explain("", method)`, where `const explain = explainer(transport, "")` from `packages/client/src/version/refusal.ts` opens the factory (a `Pages` method instead passes `features: methodRequirements(".")` to `paginatePages`), so the server's rejection reads as the feature it lacks; nothing refuses on the table's word. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is named through `explain`'s `parameterFeatures` argument and sent; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire with `await transport.requireFeatures([...], options)`, with a comment saying what the server would do. `requirements.test.ts` fails a client method the table lacks and a table key no method answers to. +9. Every method has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), keyed exactly as the method is reached on the client: `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported through `explain("", method)`, where `const { explain, explainWalk } = explainer(transport, "")` from `packages/client/src/version/refusal.ts` opens the factory, and a `Pages` method answering an async iterable through `explainWalk("", method)`, so the server's rejection reads as the feature it lacks; nothing refuses on the table's word, and `explain-guard.test.ts` fails a gated method exported unwrapped. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is sent and named through `explain`'s third argument, a small named function beside the method that takes the method's arguments and returns `{ feature, fields }` entries — the feature, and the request `fields` the argument travels in as the server names them in a 400; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire with `await transport.requireFeatures([...], options)`, with a comment saying what the server would do. `requirements.test.ts` fails a client method the table lacks and a table key no method answers to. Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten. @@ -456,7 +456,7 @@ rg -n "/api/" packages/client/src/domain/.ts && echo FAIL || echo OK # endp rg -n "\.request(Parsed|Raw|Stream)\(" packages/client/src/resources/.ts || echo "FAIL: no transport call — is this file doing anything?" rg -n "\$\{[a-zA-Z_$][\w$]*\}" packages/client/src/resources/.ts # every string interpolation: numeric id, or encodeURIComponent? rg -n "Resource" packages/client/src/client.ts || echo "FAIL: namespace not composed onto the client" -rg -n "transport\.require\(\"\." packages/client/src/resources/.ts || echo "FAIL: no require() — every method opens with one" +rg -n "explainer\(transport, \"\"\)" packages/client/src/resources/.ts || echo "CHECK: no explainer — right only when METHOD_REQUIREMENTS gates no method" rg -n "\"\." packages/client/src/version/requirements.ts || echo "FAIL: no METHOD_REQUIREMENTS entries for " rg -n "\.major\s*(<|>|=)" packages/client/src/domain/.ts packages/client/src/resources/.ts && echo FAIL || echo OK # only version/features.ts compares a major @@ -509,7 +509,7 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and every gated method exported through the resource's `explain` (`explainer(transport, "")`). +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and the namespace composed in `createClient` through `explain("", …)`. - [ ] **Requirements** — every method has its `"."` entry in `METHOD_REQUIREMENTS`; a gated route has a behaviour-named rule in `FEATURE_RULES` verified against the Metabase release branches; a shape that differs by generation has its `WireV` + converter + exported reader, with converter unit tests per generation. - [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. - [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index c076e91a..6d7ac364 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -110,7 +110,7 @@ A new or changed method comes with its wire test in `packages/client/src/resourc `packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison or a literal major used as a decision anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. -Every method in `resources/*.ts` has a matching `METHOD_REQUIREMENTS` entry under its own namespace, strictest feature first (`requirements.test.ts`). A gated method not exported through the resource's `explain` (`explainer(transport, "")`) is FAIL. A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through `transport.requireFeatures` or a value check on `verifiedServer`, with a comment saying what the server would do; a parameter the server rejects belongs in `explain`'s `parameterFeatures`. A method that chooses what to send from `transport.server()`'s `features` is FAIL — that profile may be a stale cache; send what every server accepts, or decide against `verifiedServer`. A method without an entry, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. +Every method in `resources/*.ts` has a matching `METHOD_REQUIREMENTS` entry under its own namespace, strictest feature first (`requirements.test.ts`). A gated method not exported through the resource's `explain` or `explainWalk` (`explainer(transport, "")`) is FAIL (`explain-guard.test.ts`). A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through `transport.requireFeatures` or a value check on `verifiedServer`, with a comment saying what the server would do; a parameter the server rejects belongs in `explain`'s parameter callback, with the request fields it travels in. A method that chooses what to send from `transport.server()`'s `features` is FAIL — that profile may be a stale cache; send what every server accepts, or decide against `verifiedServer`. A method without an entry, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. Version-driven drift is a `WireV` schema plus a converter, selected by a feature and handed to `requestParsed` through an exported `Schema(features): z.ZodType` reader. The canonical schema is the newest server's shape; a field an older server cannot report is `.nullable()`. FAIL: `.optional()` meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a `z.union` of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in `packages/cli/skill-data/` where a `requires:` frontmatter key or a `` … `` fence belongs (markers on their own lines, never inside a table). diff --git a/CLAUDE.md b/CLAUDE.md index 25e21d2d..639aca79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Every resource lives in `packages/client/src/domain/.ts` and - `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". - Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. - Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE.min … max + 1` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. -- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameterFeatures?)` from `const explain = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace, and when the server rejects the call `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError`. A parameter an older server rejects names its feature through `parameterFeatures`. Only a parameter an unsupporting server would drop or rewrite without a word is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. +- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameters?)`, or `explainWalk(…)` for a method answering an async iterable, from `const { explain, explainWalk } = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace; `explain-guard.test.ts` fails a gated method exported unwrapped. When the server rejects the call, `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError` that carries the server's own answer. A parameter an older server rejects is named through `parameters`, a small named function beside the method returning `{ feature, fields }` entries: the feature and the request fields the argument travels in; a 400 naming a field outside those is the server's own error, not the feature's refusal. Only a call an unsupporting server would answer wrongly without a word — a parameter it drops or rewrites, or a method whose answer it would read differently — is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. - List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. diff --git a/README.md b/README.md index b2eb3ac5..133d4d23 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Command-line client for Metabase. Logs in to an instance in your browser (OAuth, ## Supported Metabase versions -The CLI is built against Metabase majors **58 through 64** (the client's `KNOWN_RANGE`), the latest patch of each; a newer release runs as the major after the newest known one, with one stderr notice per run; a development build (master or a local build, reporting a tag such as `vUNKNOWN`) is treated as newer than every release, with every version-gated command available and no notice; and an older one keeps its real major, gets one stderr notice per run pointing at a Metabase upgrade, and is refused command by command with the version it needs. +The CLI is built against Metabase majors **58 through 64** (the client's `KNOWN_RANGE`), the latest patch of each; a newer release runs as the major after the newest known one, with one stderr notice per run; a development build (master or a local build, reporting a tag such as `vUNKNOWN`) is treated as newer than every release, with every version-gated command available and no notice; and an older one keeps its real major and gets one stderr notice per run pointing at a Metabase upgrade; a command the server refuses for a feature it lacks names that feature and the version it needs. Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server decides whether a request is allowed: every command sends its requests, and when the server refuses one (an endpoint it does not serve, or a premium feature it does not grant), the CLI probes the server once and reports what is missing with an actionable message (exit code `2`): @@ -525,7 +525,7 @@ mb table fks 42 --json ### `mb table update ` -Patch a table (`PUT /api/table/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type`, `field_order`, `show_in_getting_started`, `data_authority`, `data_source`, `data_layer`, `owner_email`, `owner_user_id`, `collection_id`. Pass the body via `--body`, `--file`, or stdin (exactly one). `display_name`, `entity_type`, `show_in_getting_started`, `field_order` and `collection_id` take no `null`: the server would drop it and leave the value as it was. Two values a server would take without doing what they ask are refused before the request, judged against a probe of the server taken in the same run: `data_authority: null` before Metabase 64, which the server writes into a column that takes no `null` and fails with a bare 500 or stores something other than a withdrawal; and a medallion `data_layer` (`gold`, `silver`, `bronze`, `copper`) on a server that names its layers `final` / `internal` / `hidden`, which maps it onto one of those. A `collection_id` is checked against the table the server answers instead, because whether an update moves a table depends on the server's patch release: one that drops it leaves the table where it was, and the command fails (exit 2) after the rest of the update has been applied. +Patch a table (`PUT /api/table/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type`, `field_order`, `show_in_getting_started`, `data_authority`, `data_source`, `data_layer`, `owner_email`, `owner_user_id`, `collection_id`. Pass the body via `--body`, `--file`, or stdin (exactly one). `display_name`, `entity_type`, `show_in_getting_started`, `field_order` and `collection_id` take no `null`: the server would drop it and leave the value as it was. Two values a server would take without doing what they ask are refused before the request, judged against a probe of the server taken in the same run: `data_authority: null` before Metabase 64, which the server writes into a column that takes no `null` and fails with a bare 500 or stores something other than a withdrawal; and a medallion `data_layer` (`gold`, `silver`, `bronze`, `copper`) on a server that names its layers `final` / `internal` / `hidden`, which maps it onto one of those. A `collection_id` is checked against the table the server answers instead, because whether an update moves a table depends on the server's patch release: one that drops it applies the rest of the update and leaves the table where it was, and the command reports that partial write as a `partial-write` error (exit 1) naming the collection asked for and the one the table stayed in. ```sh mb table update 42 --body '{"display_name":"Customers"}' @@ -2029,7 +2029,7 @@ mb git-sync import --merge | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -The import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included); Metabase v63 and newer reject an import whose assertion disagrees, and older servers ignore it. On a server that grants remote sync, the command refuses with exit 2 when no branch is tracked, and when the caller is not an admin, so the setting is not readable, rather than reading it as unset. On a server without remote sync the request is sent without the assertion and the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). +The import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included); Metabase v63 and newer reject an import whose assertion disagrees, and older servers ignore it. When no branch is tracked, or the caller is not an admin, so the setting is not readable, the command refuses with exit 2 on Metabase v63 and newer with remote sync, rather than reading the setting as unset, and sends the import without the assertion anywhere else. On a server without remote sync the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). ### `mb git-sync export` @@ -2042,15 +2042,15 @@ mb git-sync export --branch main --json mb git-sync export --no-wait ``` -| Flag | Description | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given. A blank name is refused with exit 2. | -| `--message `, `-m` | Commit message for the export. | -| `--force` | Force-push / overwrite the remote branch. | -| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | -| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | -| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | -| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +| Flag | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given; with no `--branch` they export to the tracked branch, and to their own default when the setting is unset or not readable. A blank name is refused with exit 2. | +| `--message `, `-m` | Commit message for the export. | +| `--force` | Force-push / overwrite the remote branch. | +| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | +| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | ### `mb git-sync export-preflight` @@ -2379,12 +2379,12 @@ The entry and index schemas (`CommandHelpEntry`, `CommandHelpIndex`) are exporte ## Exit codes -| Code | Meaning | -| ----- | ------------------------------------------------------ | -| `0` | Success. | -| `1` | Verification or operation failed. | -| `2` | Configuration error (invalid flag, missing TTY, etc.). | -| `130` | Interactive prompt cancelled (Ctrl+C). | +| Code | Meaning | +| ----- | ----------------------------------------------------------------------------------------------- | +| `0` | Success. | +| `1` | Verification or operation failed, or the server applied a write only in part (`partial-write`). | +| `2` | Configuration error (invalid flag, missing TTY, etc.). | +| `130` | Interactive prompt cancelled (Ctrl+C). | ## Working in the repo diff --git a/docs/architecture.md b/docs/architecture.md index 1cbe7c71..2b100d52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,21 +56,21 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/known-range.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so every feature it lacks is named when the server refuses it; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a record written under older placement rules reads the way the tag places today. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so a feature it lacks is named when the server refuses it with a refusal status; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a record written under older placement rules reads the way the tag places today. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. -**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const explain = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameterFeatures?)` (a method that answers pages hands `paginatePages` its `features` instead), visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. When the call fails the way a server rejects what it lacks — a 402 premium refusal, an unrouted 404, a 400 on an enum or on a path that lands on an older route, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion`. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails or outlasts the call's own `timeoutMs`; the probe waits on the call's `signal`, and an interrupt during it surfaces as the interrupt. The call's features are the method's plus whatever `parameterFeatures` derives from its arguments, parameters first: a revision of a measure, a measure's dependency graph and a tier-named `data_layer` are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, so a baseline command with no cached probe stays socket-free. `requirements.test.ts` asserts the table names exactly the methods the client exposes, and `explain-guard.test.ts` fails a gated method a resource exports unwrapped. +**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const { explain, explainWalk } = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameters?)`, visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. `explain` takes a method answering a promise and hands back one with the same signature whose rejection is explained; `explainWalk` takes one answering an async iterable (a `Pages` walk) and has the failure of any step explained as it is thrown, so a page walk needs no plumbing of its own. `explain-guard.test.ts` fails a gated method a resource exports unwrapped, and a wrapped one with no feature to name. When the call fails the way a server rejects what it lacks — a 402 premium refusal (which a gated `/api/ee` prefix answers before routing, whether or not the route exists), an unrouted 404, a 400 on a value outside an older enum or a body an older route reads in another shape, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — and the server's own refusal as its `cause`. A 400 is also an ordinary validation failure, so it counts as the feature's refusal only when nothing in it points elsewhere: when its field errors (`fieldErrors` / `specificFieldErrors`, compared by top-level field) name a field outside the `fields` of the missing parameter features, the server's `HttpError` stands unchanged — a tier-named `data_layer` riding along with another field the server rejects reads as that field's rejection, not the tier floor. A 400 naming no field is the refusal (a v58 table update checks `data_layer` in its model and answers 400 with only a message), and so is any 400 from a server lacking a feature the method itself needs, since the call cannot succeed there whatever field it names (a v59 Python test run rejects the newer `source_tables` shape). The message of a 400-explained refusal still quotes the server's answer after the requirement; its field errors stay structured on the cause. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails or outlasts the failed request's own `timeoutMs`; the probe waits on that request's `signal`, and an interrupt during it surfaces as the interrupt. The transport records each failed request's budget against the `HttpError` it raised, so the explanation is bounded by the request that failed, never by an argument the wrapper guesses at; an error rebuilt after its request is explained under the default budget. A timeout that cuts the probe short, its own or one composed into the caller's signal (a wait schedule's), leaves the refusal standing rather than reading as an interrupt. The call's features are the method's plus those its arguments bring, parameters first. A parameter's features come from `explain`'s `parameters` callback, a small named function beside the method in its resource file, typed by the method's own arguments and returning `{ feature, fields }` entries: the feature, and the request `fields` the argument travels in as the server names them in a 400. A revision of a measure or a transform (a v58 server answers 404 for the listing and a 400 naming `entity` for a revert), a measure's dependency graph (a 400 naming `type`) and a tier-named `data_layer` (the list filter's 400 names `data-layer`; the update's names nothing) are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, and a wrapped call that needs nothing for the arguments it was handed (a revision of a card) gets its own error back at once, so a baseline command with no cached probe stays socket-free; so does a method refused before the wire, whose `CapabilityError` is no refusal to explain. `requirements.test.ts` asserts the table names exactly the methods the client exposes. -**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent: `GET /api/table` below the floor of `can-query`, `can-write`, `include-transform-targets` or `published-only`, or without the `dependencies` token for `unused-only`, lists every table (each filter applies only when `true`, so a `false` one is sent anywhere); an import or export before `remoteSyncMerge` ignores `merge`; a CSV export before `exportCsvByteOrderMark` writes no byte order mark; a field update before `fieldDataSensitivity` drops the label; a search without the `content_verification` token drops `verified` and answers unverified content as if filtered; and a dashboard subscription's `parameters` are stored without the `dashboard_subscription_filters` token but ignored at send time, so the audience gets the dashboard's own filter values. A server that names tiers rewrites a medallion `data_layer` on update (and leaves a `copper` table visible while storing it hidden), and a server without the user-value table fails a `data_authority: null` withdrawal, on a table update or a bulk edit, as a bare 500 or coerces it. Sending those would succeed wrongly or fail without a cause, so the method refuses first through `transport.requireFeatures` or a value check — against `verifiedServer`, so a stale cache can neither block a parameter the server honours nor pass one it would drop. Each such check says in a comment what the server would do. A drop that depends on the patch release rather than the major is judged from the answer instead: a table update drops `collection_id` on early patches of every major before 62 and writes it on later ones, so `table.update` compares the table the server answers with the collection asked for and fails after the request when they differ; and a `null` the update drops on every server (`display_name`, `entity_type`, `show_in_getting_started`, `field_order`, `collection_id`) is no value of `TableUpdateInput`. These checks still judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read: a git-sync import always carries `expected_branch` and an export `branch`, the tracked branch when the caller names none, which a server before `remoteSyncBranchGuard` ignores or takes as its own default. The tracked branch is read from the server's session properties through `transport.probe`, which takes that read as the client's verified profile, so the merge check and the explanation of a refusal that follow it cost no further request. Where they grant no remote sync the request goes without it and the server's refusal is explained; where they do, an unset setting, or one hidden from a caller who is not an admin, is refused before the request. The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. +**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent: `GET /api/table` below the floor of `can-query`, `can-write`, `include-transform-targets` or `published-only`, or without the `dependencies` token for `unused-only`, lists every table (each filter applies only when `true`, so a `false` one is sent anywhere); an import or export before `remoteSyncMerge` ignores `merge`; a CSV export before `exportCsvByteOrderMark` writes no byte order mark; a field update before `fieldDataSensitivity` drops the label; a search without the `content_verification` token drops `verified` and answers unverified content as if filtered; and a dashboard subscription's `parameters` are stored without the `dashboard_subscription_filters` token but ignored at send time, so the audience gets the dashboard's own filter values. A server that names tiers rewrites a medallion `data_layer` on update (and leaves a `copper` table visible while storing it hidden), and a server without the user-value table fails a `data_authority: null` withdrawal, on a table update or a bulk edit, as a bare 500 or coerces it. Sending those would succeed wrongly or fail without a cause, so the method refuses first through `transport.requireFeatures` or a value check — against `verifiedServer`, so a stale cache can neither block a parameter the server honours nor pass one it would drop. The same holds for a method whose whole answer a server without the feature would misread: `gitSync.syncedCollections` filters the collection listing on `is_remote_synced`, which a server without remote sync never sends, so it would answer an empty scope rather than refuse, and `gitSync.branch` would answer the setting an EE server without the token still shows its admin as a branch git-sync tracks. Each such check says in a comment what the server would do. A drop that depends on the patch release rather than the major is judged from the answer instead: a table update drops `collection_id` on early patches of every major before 62 and writes it on later ones, so `table.update` compares the table the server answers with the collection asked for and, when they differ, throws a `PartialWriteError` (category `partial-write`, not retryable) saying the rest of the update was applied; and a `null` the update drops on every server (`display_name`, `entity_type`, `show_in_getting_started`, `field_order`, `collection_id`) is no value of `TableUpdateInput`. These checks still judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read: a git-sync import carries `expected_branch` and an export `branch`, the tracked branch when the caller names none, which a server before `remoteSyncBranchGuard` ignores or takes as its own default. The tracked branch is read from the server's session properties through `transport.probe`, which takes that read as the client's verified profile, so the merge check, the features of the unset case, and the explanation of a refusal that follow it cost no further request. When the setting is unset, or hidden from a caller who is not an admin, the features decide: a server with both `remoteSync` and `remoteSyncBranchGuard` would reject the request without a branch, so it is refused before the request; any other gets the request without one, an older server picking its own default and a server without remote sync refusing in words the client explains. The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. **The CLI declares what a command reaches.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features for `help --json`, which reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the table. The CLI runs no check of its own: the client's explanation reaches the user as the same `CapabilityError` (exit 2). -**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the server tag and the expected fields. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`, exported through `explain`. A verifying probe in flight is awaited by `server()`, and its failure falls back to the profile handed in, so a shape read never fails on a probe it did not need. +**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the expected fields and the tag of the profile in force when the request started, the one the reader was chosen for. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`, composed through `explain` in `createClient`. `server()` answers the profile in force — the newest probe the client settled, else the one handed in — and awaits a probe in flight, whose failure falls back to that profile, so a shape read never fails on a probe it did not need. The client keeps one probe: the newest started, shared by every caller while in flight, kept once settled and dropped when it fails; only that one settles the profile in force and reaches `onServerProbed`, so an answer that lands after a later probe started never replaces it. Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade while each command needing a newer feature is sent and the server's refusal named. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, and one whose version tag or granted token features differ from the cached record rewrites it (a write that fails warns without failing the command), so a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices. A `ResponseShapeError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): when the server changed since the cache was written the error says so and the record is rewritten by the same hook; the command is not retried, because its request may have been a write. `skills list` / `skills get` filter by the cached probe without a request, and name the time of that probe and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade. A run with no cached probe learns the server only from a probe the client runs, so when its command fails with a failure skew could explain — a shape error or an unrouted 404, never a validation 4xx, a 5xx, a network error, a timeout, a credential failure, a missing row or the CLI's own refusal — `diagnoseFailure` in `commands/runtime.ts` asks the client for `verifiedServer()` on the way out, which costs nothing when the run has probed already and otherwise gives the notice its say; a command that succeeds against an out-of-range server without a cache stays silent. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, which the client calls without awaiting; the CLI queues a write of the probe to the profile record, dating it by that probe, skips one that says the same version and granted token features as the last it saved in the run, and waits on the queue before the command ends (a write that fails warns without failing the command). So a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices, and a record's probe time is the last time the server was asked. A `ResponseShapeError` whose shape was chosen under the cached profile — its tag is the cached one — earns the same `verifiedServer()` from `diagnoseFailure`: when the fresh probe differs from the cache in version or granted token features, the error says the profile was refreshed and to retry; the command is not retried, because its request may have been a write. A shape chosen under a probe the run already settled carries that probe's tag, so its error stands. The one misreading left is a probe settled mid-run with the cached tag but other token features: a shape chosen under it still reads as chosen under the cache, and the error asks for a retry it did not need. `skills list` / `skills get` filter by the cached probe without a request, and name the time of that probe and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). **Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. Content values an older server stores without complaint — a `boxplot` display, a `table` template tag, a measure smart link — are guarded by these markers only: the client sends them, and nothing refuses them. diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index aaaf66b0..d649e8af 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -610,9 +610,8 @@ describe("defineMetabaseCommand", () => { expect(stderr).toEqual([NEWER_NOTICE]); }); - it("leaves the record alone when the probe agrees with it", async () => { + it("saves a probe that agrees with the record, dating it by the probe", async () => { await seedProbedProfile("default", probeAt(63, { content_verification: true })); - const before = await readProfileRecord("default"); vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({ @@ -626,7 +625,17 @@ describe("defineMetabaseCommand", () => { await runCommand(verifiedSearchCommand(), { rawArgs: [] }); expect(process.exitCode).toBe(0); - expect(await readProfileRecord("default")).toEqual(before); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + version: { kind: "release", tag: "v0.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { content_verification: true }, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); }); }); @@ -718,9 +727,9 @@ describe("defineMetabaseCommand", () => { ); }); - it("reports the error unchanged and leaves the profile alone when the fresh probe agrees with the cache", async () => { + it("reports the error unchanged when the fresh probe agrees with the cache", async () => { await seedProbedProfile("default", probeAt(59)); - const before = await readProfileRecord("default"); + vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({}), jsonResponse({ version: { tag: "v0.59.0" }, "token-features": {} }), @@ -735,7 +744,17 @@ describe("defineMetabaseCommand", () => { "https://m.example.com/api/session/properties", ]); expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); - expect(await readProfileRecord("default")).toEqual(before); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + version: { kind: "release", tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: {}, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); }); it("reports the error unchanged when the re-probe itself fails", async () => { @@ -777,15 +796,21 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(INTERRUPT_EXIT_CODE); }); - it("never re-probes when the profile the client ran on was not the cached one", async () => { + it("reports the error unchanged when the profile the client ran on was not the cached one", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); - const capture = captureFetch([jsonResponse({})]); + const capture = captureFetch([ + jsonResponse({}), + jsonResponse({ version: { tag: "v0.63.0" }, "token-features": {} }), + ]); vi.stubGlobal("fetch", capture.fetch); const stderr = captureStderr(); await runCommand(measureListCommand(), { rawArgs: [] }); - expect(capture.calls.map((call) => call.url)).toEqual(["https://m.example.com/api/measure"]); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/measure", + "https://m.example.com/api/session/properties", + ]); expect(errorEnvelopeOf(stderr)).toEqual( shapeErrorEnvelope( "Metabase returned unexpected response shape:\n Invalid input: expected array, received object", diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index a70a1890..d0a4c2d1 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -9,6 +9,7 @@ import { MetabaseError, ResponseShapeError, } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; @@ -81,46 +82,48 @@ export function defineMetabaseCommand( assertArgv(rawArgs, def.args); const ctx = resolveCommonFlags(commonArgs); let cachedConfig: ResolvedConfig | null = null; - let cachedClient: MetabaseClient | null = null; const getResolvedConfig = async (): Promise => { if (cachedConfig === null) { cachedConfig = await resolveConfig(buildConfigFlags(ctx)); } return cachedConfig; }; - let cachedServer: CachedServer | null = null; + const run: RunServer = { client: null, cachedProbe: null, writes: Promise.resolve() }; const noticeSkew = createSkewNotifier(); // Imported here rather than at the top of the file so the resource namespaces // `createClient` composes — and the whole `domain/` layer behind them — stay off the chunk // every command loads, including `--help`, a flag error, and the commands that open no // socket at all. const getClient = async (): Promise => { - if (cachedClient === null) { - const resolved = await getResolvedConfig(); - const probe = await readCachedProbe(resolved.profile, resolved.url); - const server = probe === null ? null : createServerProfile(probe); - const { createClient } = await import("@metabase/client/client"); - cachedClient = createClient( - { url: resolved.url, credential: resolved.credential }, - { - userAgent: USER_AGENT, - ...(server !== null && { server }), - refreshCredential: createCredentialRefresher(resolved.profile), - onServerProbed: async (fresh) => { - noticeSkew(createServerProfile(fresh)); - if (probe !== null) { - await persistChangedProbe(resolved.profile, probe, fresh); - } - }, - signal: interruptSignal, + if (run.client !== null) { + return run.client; + } + const resolved = await getResolvedConfig(); + const probe = await readCachedProbe(resolved.profile, resolved.url); + const server = probe === null ? null : createServerProfile(probe); + const saveProbe = probe === null ? null : createProbeSaver(resolved.profile, probe); + const { createClient } = await import("@metabase/client/client"); + const client = createClient( + { url: resolved.url, credential: resolved.credential }, + { + userAgent: USER_AGENT, + ...(server !== null && { server }), + refreshCredential: createCredentialRefresher(resolved.profile), + onServerProbed: (fresh) => { + noticeSkew(createServerProfile(fresh)); + if (saveProbe !== null) { + run.writes = run.writes.then(() => saveProbe(fresh)); + } }, - ); - if (probe !== null && server !== null) { - cachedServer = { client: cachedClient, probe }; - noticeSkew(server); - } + signal: interruptSignal, + }, + ); + run.client = client; + run.cachedProbe = probe; + if (server !== null) { + noticeSkew(server); } - return cachedClient; + return client; }; try { await def.run({ @@ -130,8 +133,9 @@ export function defineMetabaseCommand( getResolvedConfig, }); } catch (error) { - throw await refreshProfileOnStaleError(error, cachedServer); + throw await diagnoseFailure(error, run); } finally { + await run.writes; emitPendingWarnings(); } } catch (error) { @@ -187,64 +191,92 @@ function createSkewNotifier(): SkewNotifier { }; } -// Every probe the client runs is the server's own word, so one that disagrees with the cache -// replaces it: a refusal explained today must not leave the skills filter and the shape choices of -// the next run on yesterday's server. The cache is a convenience, so a write that fails warns -// rather than failing the command the probe served. -async function persistChangedProbe( - profileName: string, - cached: ProfileLastProbe, - fresh: ServerInfo, -): Promise { - if (serverChangeNote(cached, fresh) === null) { - return; - } - try { - await writeProbeResult(profileName, { user: cached.user, server: fresh }); - } catch (error) { - warn( - `Could not save the server's new probe to profile "${profileName}": ${errorMessage(error)}`, - ); - } +type ProbeSaver = (fresh: ServerInfo) => Promise; + +// Every probe the client runs is the server's own word, and saving it dates the cache by the last +// time the server was asked, so the skills filter and the next run's shape choices read the server +// as it was then. A probe that says what the last one saved in this run said is not saved again. +// The cache is a convenience, so a write that fails warns rather than failing the command the probe +// served. +function createProbeSaver(profileName: string, cached: ProfileLastProbe): ProbeSaver { + let lastSaved: ServerInfo | null = null; + return async (fresh) => { + if (lastSaved !== null && serverChangeNote(lastSaved, fresh) === null) { + return; + } + lastSaved = fresh; + try { + await writeProbeResult(profileName, { user: cached.user, server: fresh }); + } catch (error) { + warn( + `Could not save the server's new probe to profile "${profileName}": ${errorMessage(error)}`, + ); + } + }; } -// The client and the cached probe it was built on, kept for the one re-probe a shape error earns. -interface CachedServer { - client: MetabaseClient; - probe: ProfileLastProbe; +// What a run knows of its server: the client once built, the probe the profile cached for it, and +// the cache writes the client's probes have queued, which the run waits on before it ends. +interface RunServer { + client: MetabaseClient | null; + cachedProbe: ProfileLastProbe | null; + writes: Promise; } -// A shape error under a cached profile may mean the server changed since the probe, and the shape -// was chosen for the old one. One fresh probe settles it: the client writes a changed server back -// and the error says so. The command is not retried — its request may have been a write. -async function refreshProfileOnStaleError( - error: unknown, - cached: CachedServer | null, -): Promise { - if (cached === null) { +// A failure that skew could explain — a shape the server answers differently, a route it does not +// serve — earns the probe that names the server, on the way out. It is free when the run has +// probed already, because the client keeps its probe. With no cached profile the probe only lets +// the hook name a server outside the known range; any other failure is not worth a request. Under +// a cached profile only a shape chosen under that profile earns it: when the server changed since +// the cache was written the error says so, and the hook has saved the server as it is. The command +// is not retried, because its request may have been a write. +async function diagnoseFailure(error: unknown, run: RunServer): Promise { + const { client, cachedProbe } = run; + if (client === null || !isSkewSymptom(error)) { return error; } - if (!(error instanceof ResponseShapeError)) { + if (cachedProbe === null) { + await probeOnTheWayOut(client); return error; } - const note = await refreshChangedProbe(cached); + if (!(error instanceof ResponseShapeError) || shapeChosenFor(error) !== cachedProbe.version.tag) { + return error; + } + const fresh = await probeOnTheWayOut(client); + if (fresh === null) { + return error; + } + const note = serverChangeNote(cachedProbe, fresh); return note === null ? error : new ProfileRefreshedError(error, note); } -async function refreshChangedProbe(cached: CachedServer): Promise { - let fresh: ServerProfile; +function isSkewSymptom(error: unknown): error is MetabaseError { + if (error instanceof ResponseShapeError) { + return true; + } + return error instanceof HttpError && error.kind === "route-missing"; +} + +// The tag of the profile the failed read chose its shape by. The same tag on a probe that settled +// mid-run with other premium features reads as the cached profile, which can only add a needless +// "retry" to the error. +function shapeChosenFor(error: ResponseShapeError): string | null { + const detail = error.developerDetail; + return detail.kind === "zod" ? detail.serverTag : null; +} + +// A diagnosis on the way out: a server that cannot be reached or answered now must not displace +// the error the user is here for. An interrupt is the user's own and ends the command as one; +// anything else is a bug and surfaces. +async function probeOnTheWayOut(client: MetabaseClient): Promise { try { - fresh = await cached.client.verifiedServer(); + return await client.verifiedServer(); } catch (error) { - // A diagnosis on the way out: a server that cannot be reached or answered now must not - // displace the shape error the user is here for. An interrupt is the user's own and ends the - // command as one; anything else is a bug and surfaces. if (error instanceof MetabaseError && !(error instanceof AbortError)) { return null; } throw error; } - return serverChangeNote(cached.probe, fresh); } // The error report takes the format the flags ask for, so it is resolved before argv is checked. diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index 407c9ef8..89660249 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -58,7 +58,7 @@ describe("skewNotice", () => { it("points an older server at upgrading Metabase and names the oldest major it supports", () => { const below = KNOWN_RANGE.min - 1; expect(skewNotice(createServerProfile(serverAt(below)))).toBe( - `Metabase v0.${below}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`, + `Metabase v0.${below}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command relying on a newer feature may fail. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`, ); }); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index 454d313a..e2bca3e3 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -57,7 +57,7 @@ export function skewNotice(profile: ServerProfile): string | null { return null; } case "older-than-known": { - return `Metabase ${describeVersion(profile.version)} is older than this CLI supports (v${min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${min} or later.`; + return `Metabase ${describeVersion(profile.version)} is older than this CLI supports (v${min}+); a command relying on a newer feature may fail. Upgrade Metabase to v${min} or later.`; } case "newer-than-known": { return `Metabase ${describeVersion(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were v${max + 1}. Run \`mb upgrade\` for a newer CLI.`; diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index 304c79d3..2fd347c7 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -71,6 +71,7 @@ const CATEGORIES: ReadonlyArray = [ "timeout", "config", "capability", + "partial-write", "abort", "internal", "unknown", @@ -86,6 +87,7 @@ const EXPECTED_EXIT_CODES: Record = { timeout: 1, config: 2, capability: 2, + "partial-write": 1, abort: 130, internal: 1, unknown: 1, diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index ac405588..5b3b1648 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -40,6 +40,7 @@ export function exitCodeFor(category: ErrorCategory): number { case "http": case "validation": case "response-shape": + case "partial-write": case "timeout": case "internal": case "unknown": { diff --git a/packages/client/README.md b/packages/client/README.md index d26e8fc8..fe3ff0e7 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -418,11 +418,11 @@ Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo`: the version, the edition the tag stamps, the build date and hash, and the token-feature map, each but the version `null` when the server does not report it. `parseTag(tag)` reads a tag into a `ServerVersion`, a `ReleaseVersion` (`{ kind: "release", tag, major, patch }`) or a `DevelopmentBuild` (`{ kind: "development", tag }`); `describeVersion(version)` words it for a message (the tag, or `development build `); `StoredServerVersion` reads a persisted version back by re-deriving it from its tag; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info)` is called with every `ServerInfo` the client probes, so a consumer that passes a cached profile can keep its cache current: the calls waiting on the probe resume once the hook returns, and the profile is settled before the hook is called, so a call the hook itself makes, or one that starts while it runs, reads that profile without waiting — except a git-sync branch read, which reads the session properties afresh and so calls the hook again — and a hook that throws fails the calls waiting on the probe while the profile stays settled; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info)` is called with the `ServerInfo` of every probe the client runs, so a consumer that passes a cached profile can keep its cache current: only the newest probe started reaches it, so it never sees an older answer after a newer one; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not kept. A shape error names the profile in force when its request started — the newest probe settled, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(major, tokenFeatures)` evaluates it for a `RuleMajor` (a release major, or `"development"` for a build ahead of every release), `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. -`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on an enum or on a path that lands on an older route), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check. +`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on a value outside an older enum or a body an older route reads in another shape), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A 400 is also an ordinary validation failure, so it stands as the server's own `HttpError` when its field errors (`fieldErrors`, `specificFieldErrors`) name a field the missing parameter feature does not gate: a `mb.table.update` carrying a tier-named `data_layer` to a server that speaks medallions, alongside another field that server rejects, throws that field's rejection rather than the tier floor. A 400 that names no field, or one from a server lacking a feature the method itself needs, is the refusal. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check. A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `fieldDetailSchema(features)` reads a `FieldDetail`, which is a `FieldWithDataSensitivity` (its `data_sensitivity` `null` for an unlabelled field) on a generation with the label and a plain `Field` without the key elsewhere, told apart by `hasDataSensitivity`, `fieldRemappedValueSchema(features, sameField)` reads a `FieldRemappedValue` from the first cells of a row a generation pads with display columns, `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index a9ab2f9c..865d7df4 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -12,6 +12,7 @@ export type ErrorCategory = | "timeout" | "config" | "capability" + | "partial-write" | "abort" | "internal" | "unknown"; @@ -48,6 +49,9 @@ export interface ZodResponseShapeDetail { url: string; status: number; zodIssues: ZodError["issues"]; + // The server the reader was chosen for: the profile in force when the request started, which a + // caller compares with a fresh probe to tell a stale choice from a shape the server changed. + // `null` when no profile was in force and nothing else named the server. serverTag: string | null; serverSkew: Skew | null; } @@ -264,6 +268,28 @@ export class UnknownError extends MetabaseError { } } +export interface PartialWriteDetail { + method: string; + path: string; + field: string; + requested: number; + answered: number | null | undefined; +} + +// The server answered success having written all but one field, so the request is neither a +// failure nor a success; repeating it rewrites what landed and still leaves the field as it was. +export class PartialWriteError extends MetabaseError { + readonly category = "partial-write"; + readonly isRetryable = false; + readonly developerDetail: PartialWriteDetail; + + constructor(message: string, developerDetail: PartialWriteDetail) { + super(message); + this.name = "PartialWriteError"; + this.developerDetail = developerDetail; + } +} + export function toMetabaseError(error: unknown): MetabaseError { if (error instanceof MetabaseError) { return error; diff --git a/packages/client/src/http/errors.ts b/packages/client/src/http/errors.ts index be032036..b104cf5f 100644 --- a/packages/client/src/http/errors.ts +++ b/packages/client/src/http/errors.ts @@ -96,6 +96,7 @@ const FieldErrorTree = z.record(z.string(), FieldErrorNodeSchema); export type FieldErrors = Record; const FIELD_MESSAGE_SEPARATOR = "; "; +export const FIELD_PATH_SEPARATOR = "."; const MAX_EXTRACTED_MESSAGE_LEN = 500; const ELLIPSIS = "…"; @@ -496,7 +497,7 @@ function formatLeafEntry(entry: LeafEntry): string { function collectLeafEntries(value: unknown, path: ReadonlyArray): LeafEntry[] { if (typeof value === "string") { const trimmed = value.trim(); - return trimmed === "" ? [] : [{ path: path.join("."), message: trimmed }]; + return trimmed === "" ? [] : [{ path: path.join(FIELD_PATH_SEPARATOR), message: trimmed }]; } if (Array.isArray(value)) { const messages = value.filter( @@ -505,7 +506,9 @@ function collectLeafEntries(value: unknown, path: ReadonlyArray): LeafEn if (messages.length === 0) { return []; } - return [{ path: path.join("."), message: messages.join(FIELD_MESSAGE_SEPARATOR) }]; + return [ + { path: path.join(FIELD_PATH_SEPARATOR), message: messages.join(FIELD_MESSAGE_SEPARATOR) }, + ]; } if (isPlainObject(value)) { return Object.entries(value).flatMap(([key, child]) => diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 6e39f386..e319f1eb 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -25,7 +25,7 @@ import { CapabilityError } from "../version/preflight-error"; import { SessionProperties } from "../domain/session-properties"; import { PROBE_PATH, type ServerInfo } from "../version/probe"; import { KNOWN_RANGE } from "../version/known-range"; -import { createServerProfile } from "../version/profile"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CONFIG: ClientCredentials = { url: "https://m.example.com", @@ -767,8 +767,7 @@ describe("createTransport.verifiedServer", () => { userAgent: TEST_USER_AGENT, fetchImpl: captureFetch([probeResponse(UPGRADED_63)]).fetch, server: CACHED_58, - onServerProbed: async (info) => { - await delay(1); + onServerProbed: (info) => { seen.push(info); }, }); @@ -786,23 +785,22 @@ describe("createTransport.verifiedServer", () => { ]); }); - it("settles the profile before the hook, so a hook calling back into the client does not wait on itself", async () => { + it("lets a hook call back into the client without waiting on itself", async () => { const fakeFetch = captureFetch([probeResponse(UPGRADED_63)]); - const readByHook: string[] = []; + const readByHook: Promise[] = []; const client: Transport = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, server: CACHED_58, - onServerProbed: async () => { - const [verified, read] = await Promise.all([client.verifiedServer(), client.server()]); - readByHook.push(verified.version.tag, read.version.tag); + onServerProbed: () => { + readByHook.push(Promise.all([client.verifiedServer(), client.server()])); }, }); const verified = await client.verifiedServer(); expect(verified).toEqual(UPGRADED_63); - expect(readByHook).toEqual(["v1.63.0", "v1.63.0"]); + expect(await Promise.all(readByHook)).toEqual([[UPGRADED_63, UPGRADED_63]]); expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); }); @@ -873,9 +871,12 @@ describe("createTransport.explainRefusal", () => { return { transport, urls, stop: () => controller.abort(new Error("test over")) }; } - async function failedRequest(transport: Transport): Promise { + async function failedRequest( + transport: Transport, + budget: Pick = {}, + ): Promise { return transport - .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { retries: 0 }) + .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { ...budget, retries: 0 }) .catch((caught: unknown) => caught); } @@ -886,7 +887,10 @@ describe("createTransport.explainRefusal", () => { ); const refusal = await failedRequest(transport); - const explained = await transport.explainRefusal(["remoteSync"], refusal); + const explained = await transport.explainRefusal( + { parameters: [], method: ["remoteSync"] }, + refusal, + ); assert(explained instanceof CapabilityError, "expected CapabilityError"); expect(explained.developerDetail).toEqual({ @@ -911,7 +915,9 @@ describe("createTransport.explainRefusal", () => { ); const refusal = await failedRequest(transport); - expect(await transport.explainRefusal(["remoteSync"], refusal)).toBe(refusal); + expect( + await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), + ).toBe(refusal); }); it("never blames a missing row on a feature, and asks the server nothing", async () => { @@ -921,7 +927,9 @@ describe("createTransport.explainRefusal", () => { ); const missing = await failedRequest(transport); - expect(await transport.explainRefusal(["remoteSync"], missing)).toBe(missing); + expect( + await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, missing), + ).toBe(missing); expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); }); @@ -932,7 +940,9 @@ describe("createTransport.explainRefusal", () => { ); const failure = await failedRequest(transport); - expect(await transport.explainRefusal(["remoteSync"], failure)).toBe(failure); + expect( + await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, failure), + ).toBe(failure); expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); }); @@ -943,26 +953,28 @@ describe("createTransport.explainRefusal", () => { ); const refusal = await failedRequest(transport); - expect(await transport.explainRefusal(["remoteSync"], refusal)).toBe(refusal); + expect( + await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), + ).toBe(refusal); }); it("lets the refusal stand once the explaining probe outlasts the call's own timeout", async () => { const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); - const refusal = await failedRequest(transport); + const refusal = await failedRequest(transport, { timeoutMs: 20 }); - expect(await transport.explainRefusal(["remoteSync"], refusal, { timeoutMs: 20 })).toBe( - refusal, - ); + expect( + await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), + ).toBe(refusal); stop(); }); it("surfaces the caller's interrupt during the explaining probe rather than the refusal", async () => { const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); - const refusal = await failedRequest(transport); const controller = new AbortController(); + const refusal = await failedRequest(transport, { signal: controller.signal }); const pending = transport - .explainRefusal(["remoteSync"], refusal, { signal: controller.signal }) + .explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal) .catch((caught: unknown) => caught); controller.abort(new Error("caller interrupt")); @@ -977,7 +989,7 @@ describe("createTransport.explainRefusal", () => { const refusal = await failedRequest(transport); const pending = transport - .explainRefusal(["remoteSync"], refusal) + .explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal) .catch((caught: unknown) => caught); stop(); @@ -993,7 +1005,7 @@ describe("createTransport.explainRefusal", () => { ); const refusal = await failedRequest(transport); - expect(await transport.explainRefusal([], refusal)).toBe(refusal); + expect(await transport.explainRefusal({ parameters: [], method: [] }, refusal)).toBe(refusal); expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); }); }); diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index 5f13caf4..e83e79f4 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -8,8 +8,8 @@ import { normalizeUrl } from "../url"; import type { FeatureName } from "../version/features"; import { CapabilityError } from "../version/preflight-error"; import { probeProperties, serverInfoFromProperties, type ServerInfo } from "../version/probe"; -import { createServerProfile, type ServerProfile, type Skew } from "../version/profile"; -import { checkFeatures } from "../version/requirement-check"; +import { createServerProfile, type ServerProfile } from "../version/profile"; +import { type CallRequirement, callFeatures, checkFeatures } from "../version/requirement-check"; import { assertCredentialHeaderSafe, @@ -19,7 +19,7 @@ import { type CredentialRefresher, } from "../auth/credential"; -import { HttpError, isRetryableStatus } from "./errors"; +import { FIELD_PATH_SEPARATOR, HttpError, isRetryableStatus } from "./errors"; import { buildNetworkError, isConnectionClosed } from "./network-error"; import { parseJsonResponse } from "./response-shape"; import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries, type RetryOutcome } from "./retry"; @@ -27,10 +27,15 @@ import type { RedactionContext } from "./sanitize"; const UNAUTHORIZED_STATUS = 401; -// What a server answers for a route it does not serve, or serves only under a token feature it -// lacks: unrouted, a verb the route lacks included (404), a path that lands on an older route's -// typed id (400), or the premium refusal (402). +// What a server answers for what it does not serve: unrouted, a verb the route lacks included +// (404); the premium refusal, which a gated prefix answers before routing (402); and a 400 for a +// value outside an older enum (a revert's `entity`, a graph's `type`, the `data-layer` filter), a +// body an older route reads in another shape (a Python test run's `source_tables`), or a model check +// that names no field (a tier-named `data_layer` on an update). A 400 is also an ordinary validation +// failure, so it is the feature's refusal only when nothing in it points elsewhere +// (`pointsElsewhere`), and an explained one quotes the server. const REFUSAL_STATUSES: ReadonlySet = new Set([400, 402, 404]); +const BAD_REQUEST_STATUS = 400; export type HttpMethod = "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ExpectedContentType = "json" | "text" | "binary"; @@ -68,36 +73,48 @@ export interface Transport { requestParsed(schema: ZodType, path: string, opts?: TransportRequestOptions): Promise; requestRaw(path: string, opts?: TransportRequestOptions): Promise; requestStream(path: string, opts?: TransportRequestOptions): Promise>; - // The profile handed in at construction, or else the one the first call probes and every later - // call shares. `signal` ends this caller's wait; the probe itself is the client's and is - // cancelled only by the client's own signal, so a later call still finds it settled. + // The profile a read chooses its shape by: the newest probe this client ran, else the one handed + // in, else one probed now. A probe in flight is awaited, and its failure falls back to the profile + // it would have replaced, so a read never fails on a probe it did not need. `signal` ends this + // caller's wait; the probe itself is the client's and is cancelled only by the client's own + // signal, so a later call still finds it settled. server(options?: WaitOptions): Promise; // A profile this client probed itself, never one handed in, for a verdict the server cannot give: // a profile cached before an upgrade or a license change would refuse what the server allows. + // Every caller shares the probe in flight and the newest one settled; a failed one is not kept. verifiedServer(options?: WaitOptions): Promise; - // The session properties read afresh through `reader` and taken as this client's verified - // profile, so a resource needing a setting they carry pays one request for both. The read is the - // caller's, so it runs under the caller's `timeoutMs` and `retries` as any request does; `signal` - // ends only this caller's wait, because the profile the read settles is the client's. + // The session properties read afresh through `reader`, as a probe `verifiedServer` then shares, + // so a resource needing a setting they carry pays one request for both. The read is the caller's, + // so it runs under the caller's `timeoutMs` and `retries` as any request does; `signal` ends only + // this caller's wait, because the profile the read settles is the client's. probe(reader: ZodType, options?: ProbeReadOptions): Promise; // Throws `CapabilityError` before the wire for features a method needs only because of the // parameters it was handed, where a server without them would drop the parameter and answer as - // if it had never been sent. An empty list resolves without consulting the server. + // if it had never been sent. Judged against `verifiedServer`, never a profile handed in: a cache + // that outlived a license would pass a parameter the server now drops, which is the silent answer + // this check exists to prevent. An empty list resolves without consulting the server. requireFeatures(features: readonly FeatureName[], options?: WaitOptions): Promise; - // The error a request failed with, as a `CapabilityError` when the server refused it for lacking - // one of `features`, and unchanged otherwise. The explaining probe waits no longer than the - // failed call's own `timeoutMs`, and the error stands when it runs out. - explainRefusal( - features: readonly FeatureName[], - error: unknown, - options?: ExplainOptions, - ): Promise; + // The error a call failed with, as a `CapabilityError` carrying the server's answer when the + // server refused it for lacking one of the features `call` needs, and unchanged otherwise. The explaining probe + // runs under the failed request's own budget — no longer than its `timeoutMs`, and ending with its + // `signal` — or the default one for an error rebuilt after the request; the error stands when + // time runs out, and only an abort that is no timeout surfaces as an interrupt. + explainRefusal(call: CallRequirement, error: unknown): Promise; } export type WaitOptions = Pick; -export type ExplainOptions = Pick; export type ProbeReadOptions = Pick; +// The caller's own bound on a request, kept for a failure the request ends in so that explaining it +// waits no longer than the request was allowed to. The client-wide signal is not the caller's: the +// probe honours it on its own. +interface RequestBudget { + signal: AbortSignal | undefined; + timeoutMs: number; +} + +const DEFAULT_BUDGET: RequestBudget = { signal: undefined, timeoutMs: DEFAULT_TIMEOUT_MS }; + interface Probed { properties: T; profile: ServerProfile; @@ -119,6 +136,7 @@ interface PreparedRequest { retries: number; idempotent: boolean; timeoutMs: number; + callerSignal: AbortSignal | undefined; cancelSignal: AbortSignal; } @@ -137,18 +155,18 @@ export interface ClientOptions { // A profile the caller already holds — from its own cache, or from a probe it ran to verify the // credential — so the client never asks the server what the caller can tell it. server?: ServerProfile; - // Names the server in a shape error. Defaults to the tag of `server`, or of the profile a probe - // has settled on; a caller that resolves the tag some other way passes its own. + // Names the server in an HTTP error, and in a shape error read with no profile in force. Defaults + // to the tag of the profile in force; a caller that resolves the tag some other way passes its + // own. A shape error read under a profile names that profile, the one its reader was chosen for. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; - // Called with every probe this client runs, so a caller that handed in a cached profile can keep - // its cache as current as the server's last answer. The calls waiting on the probe resume once - // the hook returns; the profile is settled before it is called, so a call the hook makes, or one - // that starts while it runs, reads that profile without waiting. A call that reads the session - // properties afresh (git-sync's tracked branch) is a probe of its own and calls the hook again, - // so the hook makes none. A hook that throws fails the calls waiting on the probe and leaves its - // profile settled, so one doing best-effort work catches its own failures. - onServerProbed?: (info: ServerInfo) => Promise | void; + // Called with the answer of every probe this client runs, so a caller that handed in a cached + // profile can keep its cache as current as the server's last answer. Only the newest probe started + // reaches it, so it never sees an older answer after a newer one. It is called before the calls + // waiting on the probe resume and is not awaited: a caller with slow work to do (a cache write) + // keeps the promise and settles it itself. A hook that throws fails the probe, which is then not + // kept. + onServerProbed?: (info: ServerInfo) => void; // Cancels every request this client makes; composed with the per-request `signal` and the // timeout. A process-level interrupt is the caller's to own and to hand over here. signal?: AbortSignal; @@ -158,15 +176,17 @@ export function createTransport(config: ClientCredentials, options: ClientOption const baseUrl = normalizeUrl(config.url); assertCredentialHeaderSafe(config.credential); const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); - // The server every error names: the profile handed in, or else the one a probe has settled on. - // A pending probe is never awaited here: a shape error inside the probe would wait on itself. - let settledProfile: ServerProfile | null = options.server ?? null; - const getServerTag = options.getServerTag ?? (async () => tagOf(settledProfile)); - const serverSkew = (): Skew | null => (settledProfile === null ? null : settledProfile.skew); + // The profile in force: the newest probe this client settled, else the one handed in. Errors name + // it without awaiting a pending probe, because a shape error inside the probe would wait on itself. + let inForce: ServerProfile | null = options.server ?? null; + // This client's newest probe, in flight or settled, and dropped when it fails. + let newest: Promise> | null = null; + const getServerTag = options.getServerTag ?? (async () => tagOf(inForce)); const refreshCredential = options.refreshCredential; let credential = config.credential; const knownSecrets = new Set(credentialSecrets(credential)); const redactionContext: RedactionContext = { knownSecrets }; + const failedRequestBudgets = new WeakMap(); // Single-flight: concurrent 401s (e.g. verify's parallel user+probe requests) must share one // refresh. The server rotates refresh tokens, so a second concurrent refresh would replay the @@ -272,6 +292,12 @@ export function createTransport(config: ClientCredentials, options: ClientOption // A cancellation during the backoff sleep surfaces as the timer's own rejection, which // carries none of the taxonomy; the signal's reason does. throwIfAborted(prepared.cancelSignal); + if (error instanceof HttpError) { + failedRequestBudgets.set(error, { + signal: prepared.callerSignal, + timeoutMs: prepared.timeoutMs, + }); + } throw error; } } @@ -326,73 +352,61 @@ export function createTransport(config: ClientCredentials, options: ClientOption retries: opts.retries ?? DEFAULT_MAX_RETRIES, idempotent: opts.idempotent ?? IDEMPOTENT_METHODS.has(method), timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + callerSignal: opts.signal, cancelSignal: combineAborts(opts.signal, options.signal), }; } - const handedIn = options.server === undefined ? null : Promise.resolve(options.server); - // The profile the newest settled probe read, and the newest probe a caller is waiting on until - // one has. Probes are numbered as they start, so one that settles after a later one has cannot - // put the older answer back. - let verified: ServerProfile | null = null; - let probing: Promise | null = null; - let startedProbes = 0; - let settledProbe = 0; - - // The profile settles before the hook runs, so a call the hook makes reads it instead of waiting - // on the hook, while a call already waiting on the probe resumes only once the hook has returned. - // A probe whose request fails is not memoized: the next call asks again rather than replaying - // one transient failure for the life of the client. A hook that throws has seen the profile, so - // the profile stays settled and the hook is not called for it again. + // The newest probe started is the only one that may settle the profile in force and reach the + // hook, so an answer that lands after a later probe started never replaces it. A failed probe is + // dropped, so the next call asks again rather than replaying one transient failure for the life of + // the client. function startProbe( reader: ZodType, - budget: Pick = {}, + budget: Pick, ): Promise> { - startedProbes += 1; - const sequence = startedProbes; - const probed = probeProperties(transport, reader, budget).then(async (properties) => { + const probed = probeProperties(transport, reader, budget).then((properties) => { const info = serverInfoFromProperties(properties); const profile = createServerProfile(info); - if (sequence > settledProbe) { - settledProbe = sequence; - verified = profile; - settledProfile = profile; + if (newest === probed) { + options.onServerProbed?.(info); + inForce = profile; } - await options.onServerProbed?.(info); return { properties, profile }; }); - const profile = probed.then((settled) => settled.profile); - const release = (): void => { - if (probing === profile) { - probing = null; + newest = probed; + probed.then(undefined, () => { + if (newest === probed) { + newest = null; } - }; - profile.then(release, release); - probing = profile; + }); return probed; } - function verifyingProbe(): Promise { - if (verified !== null) { - return Promise.resolve(verified); + function verifying(): Promise { + const probing = newest ?? startProbe(SessionProperties, {}); + return probing.then((probed) => probed.profile); + } + + function profileInForce(): Promise { + const current = inForce; + if (current === null) { + return verifying(); + } + if (newest === null) { + return Promise.resolve(current); } - return probing ?? startProbe(SessionProperties).then((probed) => probed.profile); + return newest.then((probed) => probed.profile, fallBackTo(current)); } - // A verifying probe in flight is awaited so a shape read sees the newest server, but its failure - // falls back to the profile handed in rather than failing a read that profile can serve. async function server(wait: WaitOptions = {}): Promise { throwIfAborted(wait.signal); - if (handedIn === null || verified !== null) { - return untilAborted(verifyingProbe(), wait.signal); - } - const settled = probing === null ? handedIn : probing.catch(fallBackToHandedIn(handedIn)); - return untilAborted(settled, wait.signal); + return untilAborted(profileInForce(), wait.signal); } async function verifiedServer(wait: WaitOptions = {}): Promise { throwIfAborted(wait.signal); - return untilAborted(verifyingProbe(), wait.signal); + return untilAborted(verifying(), wait.signal); } async function probe( @@ -423,29 +437,33 @@ export function createTransport(config: ClientCredentials, options: ClientOption // The server's own refusal is the verdict; the profile only names what was missing. A server that // turns out to have every feature failed for some other reason, and its error stands. - async function explainRefusal( - features: readonly FeatureName[], - error: unknown, - explain: ExplainOptions = {}, - ): Promise { + async function explainRefusal(call: CallRequirement, error: unknown): Promise { + const features = callFeatures(call); if (features.length === 0 || !isRefusal(error)) { return error; } - const timeout = - explain.timeoutMs === undefined ? undefined : AbortSignal.timeout(explain.timeoutMs); + // An `HttpError` rebuilt after its request (`chainRequestFailure`) is still the server's + // refusal, so it is explained under the budget every request starts with. + const budget = failedRequestBudgets.get(error) ?? DEFAULT_BUDGET; + const wait = combineAborts(budget.signal, AbortSignal.timeout(budget.timeoutMs)); let profile: ServerProfile; try { - profile = await verifiedServer({ signal: combineAborts(explain.signal, timeout) }); + profile = await verifiedServer({ signal: wait }); } catch (probeError) { - // A probe that cannot answer leaves the refusal unexplained; an interrupt is the caller's. - const isInterrupt = probeError instanceof AbortError && timeout?.aborted !== true; + // A probe that cannot answer leaves the refusal unexplained, and so does one a timeout cut + // short — this one's, or one the caller composed into its signal, as a wait schedule does. Any + // other abort is an interrupt, and the caller's. + const isInterrupt = probeError instanceof AbortError && !timedOut(wait); if (probeError instanceof MetabaseError && !isInterrupt) { return error; } throw probeError; } const failure = checkFeatures(features, profile); - return failure === null ? error : new CapabilityError(failure); + if (failure === null || pointsElsewhere(call, profile, error)) { + return error; + } + return new CapabilityError(failure, error); } const transport: Transport = { @@ -458,6 +476,9 @@ export function createTransport(config: ClientCredentials, options: ClientOption return (await executeWithAuthRefresh(path, opts ?? {})).response; }, async requestParsed(schema, path, opts) { + // A resource chooses its reader by the profile in force just before it calls here, so a + // shape error names that profile, whatever a probe settles while the request runs. + const chosenFor = inForce; const { response, prepared } = await executeWithAuthRefresh(path, { ...opts, expectContentType: "json", @@ -467,8 +488,8 @@ export function createTransport(config: ClientCredentials, options: ClientOption method: prepared.method, url: prepared.url, status: response.status, - getServerTag, - serverSkew: serverSkew(), + getServerTag: chosenFor === null ? getServerTag : async () => chosenFor.version.tag, + serverSkew: chosenFor === null ? null : chosenFor.skew, }); }, async requestStream(path, opts) { @@ -490,19 +511,59 @@ export function createTransport(config: ClientCredentials, options: ClientOption } // A 404 for a missing row comes from a route the server serves, so no feature can be what it lacks. -function isRefusal(error: unknown): boolean { +function isRefusal(error: unknown): error is HttpError { if (!(error instanceof HttpError) || error.kind === "resource-missing") { return false; } return REFUSAL_STATUSES.has(error.status); } -function fallBackToHandedIn( - handedIn: Promise, -): (error: unknown) => Promise { +// A 400 names the fields it rejected, and one naming a field the missing parameter features do not +// gate rejected the call for that field: an argument needing a feature the server lacks rode along +// with a value the server refuses anyway. A server lacking a feature the method itself needs cannot +// serve the call whatever field it names, so nothing points elsewhere; nor does a 400 that names no +// field at all. +function pointsElsewhere( + call: CallRequirement, + profile: ServerProfile, + refusal: HttpError, +): boolean { + if (refusal.status !== BAD_REQUEST_STATUS || checkFeatures(call.method, profile) !== null) { + return false; + } + const named = rejectedFields(refusal); + const gated = new Set( + call.parameters + .filter((parameter) => checkFeatures([parameter.feature], profile) !== null) + .flatMap((parameter) => parameter.fields), + ); + return named.some((field) => !gated.has(field)); +} + +// The top-level request field of every rejection the server listed; a nested one is keyed by its +// dot-joined path. +function rejectedFields(refusal: HttpError): string[] { + return [refusal.fieldErrors, refusal.specificFieldErrors].flatMap((errors) => + errors === null ? [] : Object.keys(errors).map(topLevelField), + ); +} + +function topLevelField(path: string): string { + const end = path.indexOf(FIELD_PATH_SEPARATOR); + return end === -1 ? path : path.slice(0, end); +} + +function timedOut(signal: AbortSignal): boolean { + const reason: unknown = signal.reason; + return reason instanceof DOMException && reason.name === "TimeoutError"; +} + +// A probe that fails is no reason to fail a read the profile it would have replaced can serve; an +// interrupt still ends the read. +function fallBackTo(profile: ServerProfile): (error: unknown) => ServerProfile { return (error) => { if (error instanceof MetabaseError && !(error instanceof AbortError)) { - return handedIn; + return profile; } throw error; }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c57f463b..8b03f8c1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -30,6 +30,7 @@ export { isFileNotFoundError, MetabaseError, NetworkError, + PartialWriteError, ResponseShapeError, TimeoutError, toMetabaseError, @@ -41,6 +42,7 @@ export type { ErrorCategory, HttpTimeoutDetail, NetworkErrorDetail, + PartialWriteDetail, PollingTimeoutDetail, ResponseShapeErrorDetail, TimeoutErrorDetail, diff --git a/packages/client/src/paginate.test.ts b/packages/client/src/paginate.test.ts index 7f82bd19..fe4f27b6 100644 --- a/packages/client/src/paginate.test.ts +++ b/packages/client/src/paginate.test.ts @@ -2,14 +2,8 @@ import { assert, describe, expect, it } from "vitest"; import { z, type ZodType } from "zod"; import { AbortError, ConfigError, errorMessage, InternalError } from "./errors"; -import { - type ClientCredentials, - createTransport, - type ExplainOptions, - type Transport, -} from "./http/transport"; -import { captureFetch, premiumRefusalResponse, TEST_USER_AGENT } from "./testing/fetch-capture"; -import type { FeatureName } from "./version/features"; +import { type Transport, type ClientCredentials, createTransport } from "./http/transport"; +import { TEST_USER_AGENT } from "./testing/fetch-capture"; import { type Page, type PaginateOptions, paginatePages } from "./paginate"; const CONFIG: ClientCredentials = { @@ -430,32 +424,6 @@ describe("paginate", () => { ]); }); - it("explains a refused page by the walk's features, waiting on the caller's own signal", async () => { - const transport = createTransport(CONFIG, { - userAgent: TEST_USER_AGENT, - fetchImpl: captureFetch([premiumRefusalResponse("Transforms")]).fetch, - }); - const asked: Array<{ features: readonly FeatureName[]; options: ExplainOptions | undefined }> = - []; - const explained = new Error("explained"); - const client: Transport = { - ...transport, - async explainRefusal(features, _error, options) { - asked.push({ features, options }); - return explained; - }, - }; - const controller = new AbortController(); - - const error = await collect(client, "/api/transform/run", Card, { - signal: controller.signal, - features: ["transforms"], - }).catch((caught: unknown) => caught); - - expect(error).toBe(explained); - expect(asked).toEqual([{ features: ["transforms"], options: { signal: controller.signal } }]); - }); - it("reports the server total on every page it yields", async () => { const handle = makeFakeFetch([ { body: { data: makeItems(4).slice(0, 2), total: 42 } }, diff --git a/packages/client/src/paginate.ts b/packages/client/src/paginate.ts index 9b436fa9..926d3bde 100644 --- a/packages/client/src/paginate.ts +++ b/packages/client/src/paginate.ts @@ -2,7 +2,6 @@ import { z, type ZodType } from "zod"; import { ConfigError, InternalError } from "./errors"; import type { Transport, QueryValue } from "./http/transport"; -import type { FeatureName } from "./version/features"; export const DEFAULT_PAGE_SIZE = 50; @@ -12,9 +11,6 @@ export interface PaginateOptions { offset?: number; max?: number; signal?: AbortSignal; - // What the listing needs from the server, so a page the server rejects for lacking one of them - // reads as that feature. - features?: readonly FeatureName[]; } export interface Page { @@ -49,19 +45,10 @@ export async function* paginatePages( const remaining = max - taken; const limit = Math.min(pageSize, remaining); const offset = start + taken; - const wait = opts.signal === undefined ? {} : { signal: opts.signal }; - let envelope: PaginatedEnvelope; - try { - envelope = await client.requestParsed(envelopeSchema, path, { - query: { ...opts.query, limit, offset }, - ...wait, - }); - } catch (error) { - if (opts.features === undefined) { - throw error; - } - throw await client.explainRefusal(opts.features, error, wait); - } + const envelope = await client.requestParsed(envelopeSchema, path, { + query: { ...opts.query, limit, offset }, + ...(opts.signal !== undefined && { signal: opts.signal }), + }); const items = envelope.data.length > remaining ? envelope.data.slice(0, remaining) : envelope.data; diff --git a/packages/client/src/resources/content-translation.ts b/packages/client/src/resources/content-translation.ts index 4190891d..1f2a3597 100644 --- a/packages/client/src/resources/content-translation.ts +++ b/packages/client/src/resources/content-translation.ts @@ -7,7 +7,7 @@ import { buildCsvFormData, type CsvFile } from "./csv-upload"; const CONTENT_TRANSLATION_PATH = "/api/ee/content-translation"; export function contentTranslationResource(transport: Transport) { - const explain = explainer(transport, "contentTranslation"); + const { explain } = explainer(transport, "contentTranslation"); /** Download the complete content translation dictionary as CSV. */ async function download(options: RequestOptions = {}): Promise> { diff --git a/packages/client/src/resources/dependency.ts b/packages/client/src/resources/dependency.ts index 96865024..a269684a 100644 --- a/packages/client/src/resources/dependency.ts +++ b/packages/client/src/resources/dependency.ts @@ -14,8 +14,8 @@ import type { SortDirection } from "../domain/query"; import type { QueryValue, RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; -import { methodRequirements } from "../version/requirements"; import { explainer } from "../version/refusal"; +import type { ParameterRequirement } from "../version/requirement-check"; const DependencyNodeApiList = z.array(DependencyNode); const DependencyEntityApiList = z.array(DependencyEntity); @@ -43,8 +43,14 @@ export interface DependencyItemListParams { export type DependencyItemPageOptions = Omit; +// A server older than measure graphs rejects a measure as the starting entity with a 400 naming +// `type`. +function graphTypeFeatures(type: DependencyType): ParameterRequirement[] { + return type === "measure" ? [{ feature: "measureDependencyGraph", fields: ["type"] }] : []; +} + export function dependencyResource(transport: Transport) { - const explain = explainer(transport, "dependency"); + const { explain, explainWalk } = explainer(transport, "dependency"); /** * The upstream dependency graph of one entity. `nodes` holds the starting entity plus every @@ -149,7 +155,6 @@ export function dependencyResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), - features: methodRequirements("dependency.unreferencedPages"), }); } @@ -174,18 +179,15 @@ export function dependencyResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), - features: methodRequirements("dependency.breakingPages"), }); } return { - graph: explain("graph", graph, (type) => - type === "measure" ? ["measureDependencyGraph"] : [], - ), + graph: explain("graph", graph, graphTypeFeatures), dependents: explain("dependents", dependents), broken: explain("broken", broken), - unreferencedPages: unreferencedPages, - breakingPages: breakingPages, + unreferencedPages: explainWalk("unreferencedPages", unreferencedPages), + breakingPages: explainWalk("breakingPages", breakingPages), }; } diff --git a/packages/client/src/resources/document.ts b/packages/client/src/resources/document.ts index 4cd08333..33b3fcdf 100644 --- a/packages/client/src/resources/document.ts +++ b/packages/client/src/resources/document.ts @@ -15,7 +15,7 @@ import { explainer } from "../version/refusal"; const DocumentApiList = z.object({ items: z.array(Document) }).loose(); export function documentResource(transport: Transport) { - const explain = explainer(transport, "document"); + const { explain } = explainer(transport, "document"); /** Get existing documents. */ async function list(options: RequestOptions = {}): Promise> { diff --git a/packages/client/src/resources/erd.ts b/packages/client/src/resources/erd.ts index 064f214c..3f1951f4 100644 --- a/packages/client/src/resources/erd.ts +++ b/packages/client/src/resources/erd.ts @@ -11,7 +11,7 @@ export interface ErdParams { } export function erdResource(transport: Transport) { - const explain = explainer(transport, "erd"); + const { explain } = explainer(transport, "erd"); /** * Get the entity relationship diagram of a database: the readable tables as nodes, each with its diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index 93faaefa..bb75afbb 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -340,23 +340,26 @@ describe("git-sync resource wire requests", () => { ]); }); - it("refuses an import for a caller who cannot read the setting on a server granting remote sync", async () => { + it("refuses an import for a caller who cannot read the setting on a server guarding the branch", async () => { const { mb, capture } = clientOver([ - sessionProperties({ "token-features": { remote_sync: true } }), + sessionProperties({ "token-features": { remote_sync: true } }, SERVER_WITH_PREFLIGHT), ]); const error = await thrownBy(() => mb.gitSync.import()); assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe( - "the remote-sync-branch setting is not readable: it is visible to admins only, and absent on a server without the remote-sync module", + "the remote-sync-branch setting is not readable: it is visible to admins only", ); expect(capture.calls).toEqual([PROPERTIES_READ]); }); - it("refuses an import when the server tracks no branch", async () => { + it("refuses an import when a server guarding the branch tracks none", async () => { const { mb, capture } = clientOver([ - sessionProperties({ "remote-sync-branch": null, "token-features": { remote_sync: true } }), + sessionProperties( + { "remote-sync-branch": null, "token-features": { remote_sync: true } }, + SERVER_WITH_PREFLIGHT, + ), ]); const error = await thrownBy(() => mb.gitSync.import()); @@ -740,11 +743,15 @@ describe("git-sync resource wire requests", () => { }); it("reads the synced collections off the collection listing", async () => { - const { mb, capture } = clientOver([jsonResponse([])]); + const { mb, capture } = clientOver([ + sessionProperties({ "token-features": { remote_sync: true } }), + jsonResponse([]), + ]); await mb.gitSync.syncedCollections(); expect(capture.calls).toEqual([ + PROPERTIES_READ, { url: "https://mb.example.com/metabase/api/collection", method: "GET", @@ -757,6 +764,7 @@ describe("git-sync resource wire requests", () => { it("keeps only the collections the server flagged for sync", async () => { const synced = { id: 4, name: "Ops", is_remote_synced: true }; const { mb } = clientOver([ + sessionProperties({ "token-features": { remote_sync: true } }), jsonResponse([ { id: 51, name: "Data", is_remote_synced: false }, synced, @@ -770,6 +778,7 @@ describe("git-sync resource wire requests", () => { it("reads the sync scope past collections whose enum fields carry unpinned values", async () => { const { mb } = clientOver([ + sessionProperties({ "token-features": { remote_sync: true } }), jsonResponse([ { ...UNPINNED_ENUM_FIELDS, id: 51, name: "Workspace", is_remote_synced: false }, { ...UNPINNED_ENUM_FIELDS, id: 4, name: "Ops", is_remote_synced: true }, @@ -832,7 +841,7 @@ describe("git-sync resource wire requests", () => { }); it("reads the tracked branch off the session properties", async () => { - const { mb, capture } = clientOver([sessionProperties({ "remote-sync-branch": "main" })]); + const { mb, capture } = clientOver([trackedBranchResponse("main")]); await mb.gitSync.branch(); @@ -847,26 +856,30 @@ describe("git-sync resource wire requests", () => { }); it("answers the effective branch, an environment-set one included", async () => { - const { mb } = clientOver([sessionProperties({ "remote-sync-branch": "main" })]); + const { mb } = clientOver([trackedBranchResponse("main")]); expect(await mb.gitSync.branch()).toBe("main"); }); it("reads an unconfigured branch setting's null as no branch", async () => { - const { mb } = clientOver([sessionProperties({ "remote-sync-branch": null })]); + const { mb } = clientOver([ + sessionProperties({ "remote-sync-branch": null, "token-features": { remote_sync: true } }), + ]); expect(await mb.gitSync.branch()).toBeNull(); }); it("refuses rather than reading a setting the caller may not see as unset", async () => { - const { mb } = clientOver([sessionProperties({ "site-name": "Metabase" })]); + const { mb } = clientOver([ + sessionProperties({ "site-name": "Metabase", "token-features": { remote_sync: true } }), + ]); const error = await thrownBy(() => mb.gitSync.branch()); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe( - "the remote-sync-branch setting is not readable: it is visible to admins only, and absent on a server without the remote-sync module", + "the remote-sync-branch setting is not readable: it is visible to admins only", ); }); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 4d04db9f..4e1377f7 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -19,6 +19,7 @@ import { HttpError } from "../http/errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type PollOptions, pollUntil } from "../poll"; +import type { Features } from "../version/features"; import { explainer } from "../version/refusal"; import { listCollectionsAs } from "./collection"; @@ -70,8 +71,7 @@ const RemoteSyncBranchProperty = SessionProperties.extend({ type RemoteSyncBranchProperty = z.infer; const BRANCH_UNREADABLE_MESSAGE = - "the remote-sync-branch setting is not readable: it is visible to admins only, " + - "and absent on a server without the remote-sync module"; + "the remote-sync-branch setting is not readable: it is visible to admins only"; const BRANCH_UNSET_MESSAGE = "git-sync tracks no branch: the remote-sync-branch setting is unset"; const FORBIDDEN_STATUS = 403; @@ -124,7 +124,7 @@ export interface SyncCreateBranchParams { } export function gitSyncResource(transport: Transport) { - const explain = explainer(transport, "gitSync"); + const { explain } = explainer(transport, "gitSync"); /** Get the running or most recently finished sync task, or null when the server has none. */ async function currentTask(options: RequestOptions = {}): Promise { @@ -174,9 +174,11 @@ export function gitSyncResource(transport: Transport) { * pass `wait` to poll that task until it reaches a terminal status. A server already up to date * answers no task id, and there is then nothing to poll. A server that guards the tracked branch * refuses with 409 when `expected_branch` disagrees with the setting; left out, it is the tracked - * branch as read just before the request, which a server without the guard ignores. `merge` folds - * the remote's changes into local content by a three-way merge and keeps un-pushed local changes, - * where a plain import refuses on a dirty instance. + * branch as read just before the request, which a server without the guard ignores. A guarding + * server granting remote sync is refused before the request when the setting is unset or hidden + * from the caller; any other server then gets no expected branch. `merge` folds the remote's + * changes into local content by a three-way merge and keeps un-pushed local changes, where a + * plain import refuses on a dirty instance. */ async function importFromRemote( params: SyncImportParams = {}, @@ -200,13 +202,13 @@ export function gitSyncResource(transport: Transport) { return { message, task_id: started.task_id, final: await settle(params.wait, options) }; } - // The tracked branch, which a server guarding it requires on an import and an export, and an - // older one ignores or takes as its own default. Where the server grants no remote sync there is - // no branch to send, and its own refusal — a route it lacks, a premium feature it lacks — says - // why. Where it does, an unset setting leaves nothing to sync, and a setting it does not show the - // caller means a caller who is not an admin, which every remote-sync route requires, while a - // guarding server would reject the missing branch before saying so. The merge check waits for the - // branch read, which is the probe it judges by. + // The tracked branch, sent on every server whenever the setting names one: a server guarding it + // requires it on an import and an export, and an older one ignores it or takes it as its own + // default. A guarding server granting remote sync would reject a request without it, so there an + // unset setting, or one hidden from a caller who is not an admin, is refused before the wire. + // Anywhere else the request goes without it: an older server picks its own default, and one + // without remote sync refuses in its own words. The merge check and the features both read the + // profile the branch read settles, so neither costs a request. async function syncBranch( given: string | undefined, merge: boolean | undefined, @@ -217,7 +219,11 @@ export function gitSyncResource(transport: Transport) { if (merge === true) { await transport.requireFeatures(["remoteSyncMerge"], options); } - return properties === null ? given : defaultBranch(properties); + if (properties === null) { + return given; + } + const { features } = await transport.verifiedServer(options); + return defaultBranch(properties["remote-sync-branch"], features); } /** @@ -225,9 +231,11 @@ export function gitSyncResource(transport: Transport) { * `wait` to poll that task until it reaches a terminal status. A server that guards the tracked * branch refuses with 409 when `branch` is not the tracked one; left out, it is the tracked * branch as read just before the request, which is the default of a server without the guard. - * When the remote has moved past the last sync, a plain export ends in a `conflict` task where - * `merge` is available, and is refused with 400 before any task elsewhere; `merge` folds the - * remote's changes in by a three-way merge instead, and `force` overwrites them. + * A guarding server granting remote sync is refused before the request when the setting is unset + * or hidden from the caller; any other server then gets no branch and picks its own. When the + * remote has moved past the last sync, a plain export ends in a `conflict` task where `merge` is + * available, and is refused with 400 before any task elsewhere; `merge` folds the remote's + * changes in by a three-way merge instead, and `force` overwrites them. */ async function exportToRemote( params: SyncExportParams = {}, @@ -334,6 +342,9 @@ export function gitSyncResource(transport: Transport) { async function syncedCollections( options: RequestOptions = {}, ): Promise> { + // A server without remote sync lists its collections without `is_remote_synced`, which would + // read as an empty sync scope rather than as a server with no git-sync at all. + await transport.requireFeatures(["remoteSync"], options); const data = await listCollectionsAs(transport, SyncScopeCollection, options); return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; } @@ -357,12 +368,16 @@ export function gitSyncResource(transport: Transport) { } /** - * The branch git-sync tracks, or null when none is configured. Refuses when the caller may not - * read the setting (it is admin-only) or the server has no remote-sync module, rather than - * reading either as unset. + * The branch git-sync tracks, or null when none is configured. Refuses when the server has no + * remote sync or the caller may not read the setting (it is admin-only), rather than reading + * either as unset. */ async function branch(options: RequestOptions = {}): Promise { const properties = await transport.probe(RemoteSyncBranchProperty, options); + // An EE server without the remote_sync token still shows its admin the setting, which would + // read as a branch git-sync tracks; the profile the read just settled refuses it without + // another request. + await transport.requireFeatures(["remoteSync"], options); const tracked = properties["remote-sync-branch"]; if (tracked === undefined) { throw new ConfigError(BRANCH_UNREADABLE_MESSAGE); @@ -370,7 +385,10 @@ export function gitSyncResource(transport: Transport) { return tracked; } - /** The branch git-sync tracks, refused when none is configured or the caller may not read it. */ + /** + * The branch git-sync tracks, refused when none is configured, the server has no remote sync, or + * the caller may not read it. + */ async function trackedBranch(options: RequestOptions = {}): Promise { const tracked = await branch(options); if (tracked === null) { @@ -419,13 +437,14 @@ export function gitSyncResource(transport: Transport) { }; } -function defaultBranch(properties: RemoteSyncBranchProperty): string | undefined { - const tracked = properties["remote-sync-branch"]; +function defaultBranch( + tracked: RemoteSyncBranchProperty["remote-sync-branch"], + features: Features, +): string | undefined { if (typeof tracked === "string") { return tracked; } - const grantsRemoteSync = properties["token-features"]?.["remote_sync"] === true; - if (!grantsRemoteSync) { + if (!features.remoteSync || !features.remoteSyncBranchGuard) { return undefined; } throw new ConfigError(tracked === null ? BRANCH_UNSET_MESSAGE : BRANCH_UNREADABLE_MESSAGE); diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts index b3a36ad5..60b797e1 100644 --- a/packages/client/src/resources/library.ts +++ b/packages/client/src/resources/library.ts @@ -26,7 +26,7 @@ const LIBRARY_DATA_TYPE = "library-data"; const PublishTablesResponse = z.object({ target_collection: Collection.nullable() }); export function libraryResource(transport: Transport) { - const explain = explainer(transport, "library"); + const { explain } = explainer(transport, "library"); /** Get the Library root and its child collections, or `null` on an instance that has none. */ async function get(options: RequestOptions = {}): Promise { diff --git a/packages/client/src/resources/measure.ts b/packages/client/src/resources/measure.ts index 3f896a9e..faf861f7 100644 --- a/packages/client/src/resources/measure.ts +++ b/packages/client/src/resources/measure.ts @@ -14,7 +14,7 @@ export interface MeasureArchiveParams { } export function measureResource(transport: Transport) { - const explain = explainer(transport, "measure"); + const { explain } = explainer(transport, "measure"); /** List measures. */ async function list(options: RequestOptions = {}): Promise> { diff --git a/packages/client/src/resources/metric.ts b/packages/client/src/resources/metric.ts index f13a2e1d..4f686f42 100644 --- a/packages/client/src/resources/metric.ts +++ b/packages/client/src/resources/metric.ts @@ -15,7 +15,7 @@ export interface MetricDimensionListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function metricResource(transport: Transport) { - const explain = explainer(transport, "metric"); + const { explain } = explainer(transport, "metric"); /** * Run a metric definition and return the query result envelope: one metric or measure, or an diff --git a/packages/client/src/resources/moderation-review.ts b/packages/client/src/resources/moderation-review.ts index 2d5f0228..a5b16177 100644 --- a/packages/client/src/resources/moderation-review.ts +++ b/packages/client/src/resources/moderation-review.ts @@ -5,7 +5,7 @@ import { explainer } from "../version/refusal"; import { parseRequestBody } from "./request-body"; export function moderationReviewResource(transport: Transport) { - const explain = explainer(transport, "moderationReview"); + const { explain } = explainer(transport, "moderationReview"); /** * Create a moderation review on a card or dashboard. `status: "verified"` marks the item diff --git a/packages/client/src/resources/replacement.ts b/packages/client/src/resources/replacement.ts index 66e242c9..c177d0ce 100644 --- a/packages/client/src/resources/replacement.ts +++ b/packages/client/src/resources/replacement.ts @@ -21,7 +21,7 @@ export interface ReplacementRunListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function replacementResource(transport: Transport) { - const explain = explainer(transport, "replacement"); + const { explain } = explainer(transport, "replacement"); /** * Check whether every usage of a source entity could be rewritten to read a target entity diff --git a/packages/client/src/resources/revision.ts b/packages/client/src/resources/revision.ts index 2a9d55b0..5700dfd9 100644 --- a/packages/client/src/resources/revision.ts +++ b/packages/client/src/resources/revision.ts @@ -11,6 +11,7 @@ import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; import { explainer } from "../version/refusal"; +import type { ParameterRequirement } from "../version/requirement-check"; // `GET /api/revision/{entity}/{id}` answers a bare array, newest first, that the server does not // count. @@ -23,22 +24,22 @@ const RevisionApiRevert: z.ZodType = z.union([ RevisionRow.transform((revision) => ({ outcome: "unchanged" as const, revision })), ]); -// An older server rejects an entity kind it does not revision (an unrouted 404, or a 400 on the -// enum), and the rejection is explained by the feature that brought the kind. +// An older server rejects an entity kind it does not revision: an unrouted 404 for the listing, a +// 400 naming `entity` for a revert. The rejection is explained by the feature that brought the kind. const ENTITY_FEATURES: Partial> = { measure: "measures", transform: "transforms", }; -function entityFeatures(entity: RevisionEntity): FeatureName[] { +function entityFeatures(entity: RevisionEntity): ParameterRequirement[] { const feature = ENTITY_FEATURES[entity]; - return feature === undefined ? [] : [feature]; + return feature === undefined ? [] : [{ feature, fields: ["entity"] }]; } // Every path parameter here is an enum member or a numeric id, so no fragment needs // `encodeURIComponent`. export function revisionResource(transport: Transport) { - const explain = explainer(transport, "revision"); + const { explain } = explainer(transport, "revision"); /** List the revisions of an entity the caller may read, newest first, each with its diff. */ async function list( diff --git a/packages/client/src/resources/table.test.ts b/packages/client/src/resources/table.test.ts index 06b71c3c..1cbf4ef8 100644 --- a/packages/client/src/resources/table.test.ts +++ b/packages/client/src/resources/table.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; -import { ConfigError } from "../errors"; +import { ConfigError, PartialWriteError } from "../errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, @@ -86,13 +86,19 @@ const SERVER_60 = createServerProfile({ const PROBE_URL = `https://mb.example.com/metabase${PROBE_PATH}`; // What a medallion-era server answers a tier name with: its enum rejects the value. -function dataLayerRejected(): Response { +// A medallion server checks an update's `data_layer` in the model rather than the request schema, +// so its 400 names no field. +function dataLayerUpdateRejected(value: string): Response { return jsonResponse( - { errors: { data_layer: "nullable enum of gold, silver, bronze, copper" } }, + { message: `Invalid value ${value}. Must be one of gold, silver, bronze, copper`, value }, 400, ); } +function dataLayerFilterRejected(): Response { + return jsonResponse({ errors: { "data-layer": "enum of gold, silver, bronze, copper" } }, 400); +} + const SERVER_58 = createServerProfile({ edition: "oss", version: { kind: "release", tag: "v0.58.0", major: 58, patch: 0 }, @@ -389,7 +395,10 @@ describe("table resource wire requests", () => { }); it("explains a rejected tier name for data_layer on a server that speaks medallions", async () => { - const { mb, capture } = clientOver([dataLayerRejected(), probeResponse(SERVER_58)], SERVER_58); + const { mb, capture } = clientOver( + [dataLayerUpdateRejected("final"), probeResponse(SERVER_58)], + SERVER_58, + ); const error = await thrownBy(() => mb.table.update(11, { data_layer: "final" })); @@ -465,9 +474,9 @@ describe("table resource wire requests", () => { const error = await thrownBy(() => mb.table.update(11, { collection_id: 7 })); - assert(error instanceof ConfigError, "expected ConfigError"); + assert(error instanceof PartialWriteError, "expected PartialWriteError"); expect(error.message).toBe( - "the server applied the update but left table 11 in no collection: it does not move a table to a collection through an update", + "the server applied the rest of the update to table 11 but not collection_id: the table stays in no collection, because this server does not move a table to a collection through an update", ); expect(capture.calls.map((call) => call.url)).toEqual([TABLE_URL]); }); @@ -483,7 +492,10 @@ describe("table resource wire requests", () => { }); it("explains a rejected tier name in the data-layer filter on a server that speaks medallions", async () => { - const { mb, capture } = clientOver([dataLayerRejected(), probeResponse(SERVER_58)], SERVER_58); + const { mb, capture } = clientOver( + [dataLayerFilterRejected(), probeResponse(SERVER_58)], + SERVER_58, + ); const error = await thrownBy(() => mb.table.list({ "data-layer": "hidden" })); diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 378344d6..f5dc5956 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -16,11 +16,12 @@ import { type TableUpdateInput, } from "../domain/table"; import type { UploadUpdateAction, UploadUpdateResult } from "../domain/upload"; -import { ConfigError } from "../errors"; +import { ConfigError, PartialWriteError } from "../errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; import { explainer } from "../version/refusal"; +import type { ParameterRequirement } from "../version/requirement-check"; import { buildCsvFormData, type CsvFile } from "./csv-upload"; import { fetchOptionalParsed } from "./optional-parsed"; @@ -74,11 +75,15 @@ function listParamFeatures(params: TableListParams): FeatureName[] { ); } -// A server older than the tier vocabulary rejects a tier name; the rejection names the feature. -function dataLayerFeatures(value: TableDataLayer | null | undefined): FeatureName[] { - return value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success - ? ["tableDataLayerTiers"] - : []; +// A server older than the tier vocabulary rejects a tier name with a 400 naming the request field +// it travels in: `data-layer` on the list filter, `data_layer` on an update. +function dataLayerFeatures( + value: TableDataLayer | null | undefined, + field: string, +): ParameterRequirement[] { + const isTierName = + value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success; + return isTierName ? [{ feature: "tableDataLayerTiers", fields: [field] }] : []; } const UPLOAD_UPDATE_PATHS: Record = { @@ -87,7 +92,7 @@ const UPLOAD_UPDATE_PATHS: Record = { }; export function tableResource(transport: Transport) { - const explain = explainer(transport, "table"); + const { explain } = explainer(transport, "table"); /** * List every table the caller can see, across all databases. `term` matches names and display @@ -133,18 +138,20 @@ export function tableResource(transport: Transport) { ): Promise
{ await refuseRewrittenDataLayer(params.data_layer, options); await requireDataAuthorityNull(params.data_authority, options); - const updated = await transport.requestParsed(Table, `/api/table/${id}`, { + const path = `/api/table/${id}`; + const updated = await transport.requestParsed(Table, path, { ...options, method: "PUT", body: params, }); - assertCollectionWritten(params.collection_id, updated); + assertCollectionWritten(path, params.collection_id, updated); return updated; } // A server that names tiers maps a medallion name onto one on update without a word, and for // `copper` leaves the table visible while storing it hidden, so the name is refused by value - // before the wire. A tier name on an older server is that server's own 400. + // before the wire. A tier name on an older server is that server's own 400. The list filter needs + // no such check: every server validates it against its own vocabulary and answers 400 naming it. async function refuseRewrittenDataLayer( value: TableDataLayer | null | undefined, options: RequestOptions, @@ -359,9 +366,13 @@ export function tableResource(transport: Transport) { } return { - list: explain("list", list, (params) => dataLayerFeatures(params?.["data-layer"])), + list: explain("list", list, (params) => + dataLayerFeatures(params?.["data-layer"], "data-layer"), + ), get, - update: explain("update", update, (_id, params) => dataLayerFeatures(params.data_layer)), + update: explain("update", update, (_id, params) => + dataLayerFeatures(params.data_layer, "data_layer"), + ), queryMetadata, fks, syncSchema, @@ -379,15 +390,19 @@ export function tableResource(transport: Transport) { // A server whose update predates publishing a table takes `collection_id` in its open body, leaves // it out of the columns it writes, and answers 200 with the table where it was. Which servers do // is a matter of patch release, not major, so the table the server answers is the judge. -function assertCollectionWritten(requested: TableUpdateInput["collection_id"], table: Table): void { +function assertCollectionWritten( + path: string, + requested: TableUpdateInput["collection_id"], + table: Table, +): void { if (requested === undefined || table.collection_id === requested) { return; } + const answered = table.collection_id; const stayed = - table.collection_id === null || table.collection_id === undefined - ? "in no collection" - : `in collection ${table.collection_id}`; - throw new ConfigError( - `the server applied the update but left table ${table.id} ${stayed}: it does not move a table to a collection through an update`, + answered === null || answered === undefined ? "in no collection" : `in collection ${answered}`; + throw new PartialWriteError( + `the server applied the rest of the update to table ${table.id} but not collection_id: the table stays ${stayed}, because this server does not move a table to a collection through an update`, + { method: "PUT", path, field: "collection_id", requested, answered }, ); } diff --git a/packages/client/src/resources/transform-dag-run.ts b/packages/client/src/resources/transform-dag-run.ts index 079ec8ab..83e4c46a 100644 --- a/packages/client/src/resources/transform-dag-run.ts +++ b/packages/client/src/resources/transform-dag-run.ts @@ -10,7 +10,7 @@ const TransformMemberRunList = z.array(TransformMemberRun); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformDagRunResource(transport: Transport) { - const explain = explainer(transport, "transformDagRun"); + const { explain } = explainer(transport, "transformDagRun"); /** * List the transform runs a DAG run coordinated so far. A member run is inserted when its diff --git a/packages/client/src/resources/transform-inspector.ts b/packages/client/src/resources/transform-inspector.ts index 8114c08f..3344515a 100644 --- a/packages/client/src/resources/transform-inspector.ts +++ b/packages/client/src/resources/transform-inspector.ts @@ -13,7 +13,7 @@ function lensPath(id: number, lensId: string): string { } export function transformInspectorResource(transport: Transport) { - const explain = explainer(transport, "transformInspector"); + const { explain } = explainer(transport, "transformInspector"); /** * Discover what the inspector can show for a transform: its source and target tables with field diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts index e157b115..f0b05733 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -21,7 +21,7 @@ export interface TransformJobRunParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformJobResource(transport: Transport) { - const explain = explainer(transport, "transformJob"); + const { explain } = explainer(transport, "transformJob"); /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { diff --git a/packages/client/src/resources/transform-python.ts b/packages/client/src/resources/transform-python.ts index d5af0c02..59c37a49 100644 --- a/packages/client/src/resources/transform-python.ts +++ b/packages/client/src/resources/transform-python.ts @@ -27,7 +27,7 @@ function libraryPath(path: string): string { } export function transformPythonResource(transport: Transport) { - const explain = explainer(transport, "transformPython"); + const { explain } = explainer(transport, "transformPython"); /** Get the Python library at a path, the user module every Python transform can import. */ async function getLibrary(path: string, options: RequestOptions = {}): Promise { diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts index e0407335..f3b4df85 100644 --- a/packages/client/src/resources/transform-tag.ts +++ b/packages/client/src/resources/transform-tag.ts @@ -15,7 +15,7 @@ const TransformTagApiList = z.array(TransformTag); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformTagResource(transport: Transport) { - const explain = explainer(transport, "transformTag"); + const { explain } = explainer(transport, "transformTag"); /** List every transform tag the caller can see, built-in tags included. */ async function list(options: RequestOptions = {}): Promise> { diff --git a/packages/client/src/resources/transform-test.ts b/packages/client/src/resources/transform-test.ts index aa8448df..3e8b0957 100644 --- a/packages/client/src/resources/transform-test.ts +++ b/packages/client/src/resources/transform-test.ts @@ -20,7 +20,7 @@ export interface TransformTestListParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformTestResource(transport: Transport) { - const explain = explainer(transport, "transformTest"); + const { explain } = explainer(transport, "transformTest"); /** List the transform tests, optionally only those of one transform. */ async function list( diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index 6144c9c1..6f74febe 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -26,7 +26,6 @@ import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; import { type PollOptions, pollUntil } from "../poll"; import type { Features } from "../version/features"; -import { methodRequirements } from "../version/requirements"; import { explainer } from "../version/refusal"; // `GET /api/transform` and `GET /api/transform/{id}/dependencies` both answer a bare array rather @@ -82,7 +81,7 @@ const TransformDagTransformList = z.array(TransformDagTransform); // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { - const explain = explainer(transport, "transform"); + const { explain, explainWalk } = explainer(transport, "transform"); async function requestTransformList( path: string, @@ -179,7 +178,6 @@ export function transformResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), - features: methodRequirements("transform.runPages"), }); } @@ -206,7 +204,6 @@ export function transformResource(transport: Transport) { ...(options.max !== undefined && { max: options.max }), ...(options.pageSize !== undefined && { pageSize: options.pageSize }), ...(options.signal !== undefined && { signal: options.signal }), - features: methodRequirements("transform.runSummaryPages"), }); } @@ -323,8 +320,8 @@ export function transformResource(transport: Transport) { dependencies: explain("dependencies", dependencies), cancel: explain("cancel", cancel), getRun: explain("getRun", getRun), - runPages: runPages, - runSummaryPages: runSummaryPages, + runPages: explainWalk("runPages", runPages), + runSummaryPages: explainWalk("runSummaryPages", runSummaryPages), resetCheckpoint: explain("resetCheckpoint", resetCheckpoint), runDag: explain("runDag", runDag), dagTransforms: explain("dagTransforms", dagTransforms), diff --git a/packages/client/src/testing/fake-client.ts b/packages/client/src/testing/fake-client.ts index 0fefa9da..01979f57 100644 --- a/packages/client/src/testing/fake-client.ts +++ b/packages/client/src/testing/fake-client.ts @@ -119,7 +119,7 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { async requireFeatures(features) { requiredFeatures.push({ features, precedingRequests: calls.length }); }, - async explainRefusal(_features, error) { + async explainRefusal(_call, error) { return error; }, }; diff --git a/packages/client/src/version/explain-guard.test.ts b/packages/client/src/version/explain-guard.test.ts index ee7a64a8..d7ca4a49 100644 --- a/packages/client/src/version/explain-guard.test.ts +++ b/packages/client/src/version/explain-guard.test.ts @@ -9,36 +9,32 @@ import { isMethodKey, METHOD_KEYS, methodRequirements } from "./requirements"; const RESOURCES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "resources"); const NAMESPACE = /explainer\(transport, "(\w+)"\)/; -// `explain("", ` and whether a parameter-feature callback follows the method. -const EXPLAINED = /\bexplain\(\s*"(\w+)",\s*\w+\s*(,)?/g; -const PAGED = /methodRequirements\("(\w+\.\w+)"\)/g; +// `explain("", ` or `explainWalk("", `, and whether a parameter-feature +// callback follows the method. +const EXPLAINED = /\bexplain(?:Walk)?\(\s*"(\w+)",\s*\w+\s*(,)?/g; -// Methods whose whole requirement guards a parameter a server without it would drop, so they refuse -// before the request through `requireFeatures` and the server never gets to refuse them. -const REFUSED_BEFORE_THE_REQUEST: ReadonlySet = new Set(["field.setDataSensitivity"]); +// Methods whose whole requirement guards an answer a server without it would give wrongly without +// a word, so they refuse before the request through `requireFeatures` and the server never gets to +// refuse them. +const REFUSED_BEFORE_THE_REQUEST: ReadonlySet = new Set([ + "field.setDataSensitivity", + "gitSync.syncedCollections", + "gitSync.branch", + "gitSync.trackedBranch", +]); interface Wiring { explained: Set; explainedWithParameters: Set; - paged: Set; } function readWiring(): Wiring { - const wiring: Wiring = { - explained: new Set(), - explainedWithParameters: new Set(), - paged: new Set(), - }; + const wiring: Wiring = { explained: new Set(), explainedWithParameters: new Set() }; for (const file of readdirSync(RESOURCES)) { if (!file.endsWith(".ts") || file.endsWith(".test.ts")) { continue; } const source = readFileSync(resolve(RESOURCES, file), "utf8"); - for (const [, key] of source.matchAll(PAGED)) { - if (key !== undefined) { - wiring.paged.add(key); - } - } const namespace = NAMESPACE.exec(source)?.[1]; if (namespace === undefined) { continue; @@ -62,12 +58,11 @@ describe("the explanation of gated methods", () => { // An unwrapped gated method still works; its refusal just reaches the caller as a bare HTTP // error instead of the feature the server lacks. - it("wraps every method that needs a feature, or hands its features to the page walk", () => { + it("wraps every method that needs a feature", () => { const unexplained = METHOD_KEYS.filter( (key) => methodRequirements(key).length > 0 && !wiring.explained.has(key) && - !wiring.paged.has(key) && !REFUSED_BEFORE_THE_REQUEST.has(key), ); expect(unexplained).toEqual([]); diff --git a/packages/client/src/version/preflight-error.ts b/packages/client/src/version/preflight-error.ts index 43dbc7cb..5b0db516 100644 --- a/packages/client/src/version/preflight-error.ts +++ b/packages/client/src/version/preflight-error.ts @@ -1,9 +1,14 @@ import { z } from "zod"; import { MetabaseError } from "../errors"; +import type { HttpError } from "../http/errors"; import { FEATURE_NAMES } from "./features"; +// The one refusal status that is also an ordinary validation failure: a server lacking a parameter's +// feature rejects the unknown parameter with it, and so does any server rejecting a bad value. +const BAD_REQUEST_STATUS = 400; + export const RequirementReason = z.enum(["version-too-old", "missing-token-feature"]); export type RequirementReason = z.infer; @@ -28,14 +33,25 @@ export function missingTokenFeatureMessage(tokenFeature: string): string { return `This operation requires the '${tokenFeature}' premium feature (not enabled on this server).`; } +// An explained refusal carries the server's answer as its `cause`. A 400 may have rejected the call +// for a reason other than the missing feature, so its message also quotes what the server said; a +// 402 or an unrouted 404 says nothing the requirement does not. The field errors stay structured on +// the cause rather than being re-rendered here. export class CapabilityError extends MetabaseError { readonly category = "capability"; readonly isRetryable = false; readonly developerDetail: RequirementFailure; - constructor(failure: RequirementFailure) { - super(failure.detail); + constructor(failure: RequirementFailure, refusal: HttpError | null = null) { + super(capabilityMessage(failure, refusal), refusal === null ? undefined : { cause: refusal }); this.name = "CapabilityError"; this.developerDetail = failure; } } + +function capabilityMessage(failure: RequirementFailure, refusal: HttpError | null): string { + if (refusal === null || refusal.status !== BAD_REQUEST_STATUS) { + return failure.detail; + } + return `${failure.detail}\nMetabase answered ${refusal.status}: ${refusal.message}`; +} diff --git a/packages/client/src/version/refusal.test.ts b/packages/client/src/version/refusal.test.ts index 3bd297bc..031bd6a5 100644 --- a/packages/client/src/version/refusal.test.ts +++ b/packages/client/src/version/refusal.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from "vitest"; -import type { ExplainOptions, RequestOptions, Transport } from "../http/transport"; -import type { PollOptions } from "../poll"; +import type { RequestOptions, Transport } from "../http/transport"; -import type { FeatureName } from "./features"; import { explainer } from "./refusal"; +import type { CallRequirement } from "./requirement-check"; interface ExplainCall { - features: readonly FeatureName[]; + call: CallRequirement; error: unknown; - options: ExplainOptions | undefined; } const EXPLAINED = new Error("explained"); @@ -29,8 +27,8 @@ function recordingTransport(): { transport: Transport; calls: ExplainCall[] } { verifiedServer: notOnThisPath, probe: notOnThisPath, requireFeatures: notOnThisPath, - async explainRefusal(features, error, options) { - calls.push({ features, error, options }); + async explainRefusal(call, error) { + calls.push({ call, error }); return EXPLAINED; }, }; @@ -46,7 +44,7 @@ async function refuse(_kind: string, _id: number, _options: RequestOptions = {}) describe("explainer", () => { it("answers what the wrapped method answers and explains nothing", async () => { const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "erd"); + const { explain } = explainer(transport, "erd"); const get = explain("get", async (id: number) => ({ id })); @@ -56,73 +54,30 @@ describe("explainer", () => { it("explains a failure by the method's own requirements, and throws what the transport answers", async () => { const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "transformJob"); + const { explain } = explainer(transport, "erd"); - const setActive = explain("setActive", refuse); + const get = explain("get", refuse); - await expect(setActive("job", 1)).rejects.toBe(EXPLAINED); - expect(calls).toEqual([ - { features: ["transformJobActivation", "transforms"], error: REFUSED, options: {} }, - ]); + await expect(get("job", 1)).rejects.toBe(EXPLAINED); + expect(calls).toEqual([{ call: { parameters: [], method: ["erd"] }, error: REFUSED }]); }); it("puts the features the arguments bring ahead of the method's own", async () => { const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "dependency"); + const { explain } = explainer(transport, "dependency"); const graph = explain("graph", refuse, (kind) => - kind === "measure" ? ["measureDependencyGraph"] : [], + kind === "measure" ? [{ feature: "measureDependencyGraph", fields: ["type"] }] : [], ); await expect(graph("measure", 1)).rejects.toBe(EXPLAINED); await expect(graph("card", 1)).rejects.toBe(EXPLAINED); - expect(calls.map((call) => call.features)).toEqual([ - ["measureDependencyGraph", "dependencyGraph"], - ["dependencyGraph"], - ]); - }); - - it("hands the explanation the caller's signal and timeout from the trailing options", async () => { - const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "erd"); - const controller = new AbortController(); - - const get = explain("get", refuse); - - await expect( - get("x", 1, { signal: controller.signal, timeoutMs: 250, retries: 0 }), - ).rejects.toBe(EXPLAINED); - expect(calls.map((call) => call.options)).toEqual([ - { signal: controller.signal, timeoutMs: 250 }, + expect(calls.map((recorded) => recorded.call)).toEqual([ + { + parameters: [{ feature: "measureDependencyGraph", fields: ["type"] }], + method: ["dependencyGraph"], + }, + { parameters: [], method: ["dependencyGraph"] }, ]); }); - - it("bounds the explanation by a trailing wait schedule's signal and overall timeout", async () => { - const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "gitSync"); - const controller = new AbortController(); - - const waitForTask = explain("waitForTask", async (_wait: PollOptions) => { - throw REFUSED; - }); - - await expect( - waitForTask({ intervalMs: 10, timeoutMs: 600_000, signal: controller.signal }), - ).rejects.toBe(EXPLAINED); - expect(calls.map((call) => call.options)).toEqual([ - { signal: controller.signal, timeoutMs: 600_000 }, - ]); - }); - - it("reads no options from a trailing params object", async () => { - const { transport, calls } = recordingTransport(); - const explain = explainer(transport, "erd"); - - const get = explain("get", async (_params: { "database-id": number }) => { - throw REFUSED; - }); - - await expect(get({ "database-id": 1 })).rejects.toBe(EXPLAINED); - expect(calls.map((call) => call.options)).toEqual([{}]); - }); }); diff --git a/packages/client/src/version/refusal.ts b/packages/client/src/version/refusal.ts index 3de41d11..774f800d 100644 --- a/packages/client/src/version/refusal.ts +++ b/packages/client/src/version/refusal.ts @@ -1,70 +1,72 @@ -import type { ExplainOptions, Transport } from "../http/transport"; +import { InternalError } from "../errors"; +import type { Transport } from "../http/transport"; -import type { FeatureName } from "./features"; +import type { CallRequirement, ParameterRequirement } from "./requirement-check"; import { isMethodKey, type MethodKey, methodRequirements } from "./requirements"; // The features a call needs only because of the arguments it was handed, where a server without // them rejects the request rather than dropping the argument. -export type ParameterFeatures = (...args: A) => readonly FeatureName[]; +export type ParameterFeatures = ( + ...args: A +) => readonly ParameterRequirement[]; type NamespaceOf = K extends `${infer N}.${string}` ? N : never; type MethodNamespace = NamespaceOf; type NameIn = K extends `${N}.${infer M}` ? M : never; type MethodName = NameIn; -type Explain = ( - name: MethodName, - method: (...args: A) => Promise, - // The method alone sets the exposed signature; a callback reading fewer arguments must not drop - // the rest from it. - parameterFeatures?: NoInfer>, -) => (...args: A) => Promise; - // Wraps one resource's gated methods as the client exposes them: the server decides whether the -// call is allowed, and a refusal it answers with reads as the feature it lacks. Parameter features -// come first, since a parameter's floor sits above its method's. -export function explainer( - transport: Transport, - namespace: N, -): Explain { - return (name, method, parameterFeatures) => { +// call is allowed, and a refusal it answers with reads as the feature it lacks. `explain` wraps a +// method answering a promise, `explainWalk` one answering an async iterable, whose failure at any +// step is explained as it is thrown. The method alone sets the exposed signature, so `parameters` +// is `NoInfer`: a callback reading fewer arguments must not drop the rest from it. +export function explainer(transport: Transport, namespace: N) { + function explain( + name: MethodName, + method: (...args: A) => Promise, + parameters?: NoInfer>, + ): (...args: A) => Promise { const key = methodKey(namespace, name); return async (...args) => { try { return await method(...args); } catch (error) { - const features = [ - ...(parameterFeatures === undefined ? [] : parameterFeatures(...args)), - ...methodRequirements(key), - ]; - throw await transport.explainRefusal(features, error, explainOptionsOf(args)); + throw await transport.explainRefusal(callRequirement(key, parameters, args), error); } }; - }; + } + + function explainWalk( + name: MethodName, + method: (...args: A) => AsyncIterable, + parameters?: NoInfer>, + ): (...args: A) => AsyncIterable { + const key = methodKey(namespace, name); + return async function* (...args) { + try { + yield* method(...args); + } catch (error) { + throw await transport.explainRefusal(callRequirement(key, parameters, args), error); + } + }; + } + + return { explain, explainWalk }; } function methodKey(namespace: string, name: string): MethodKey { const key = `${namespace}.${name}`; if (!isMethodKey(key)) { - throw new Error(`no requirements entry for ${key}`); + throw new InternalError(`no requirements entry for ${key}`); } return key; } -// Every resource method takes its `RequestOptions` last, and a call that leaves them out ends on -// its params instead. Metabase names no field `signal` or `timeoutMs`, so a trailing argument -// holding either is the caller's own budget for the call: its `RequestOptions`, or a wait schedule -// (`PollOptions`) whose signal and overall timeout bound the explanation as they bound the wait. -function explainOptionsOf(args: readonly unknown[]): ExplainOptions { - const last = args.at(-1); - if (typeof last !== "object" || last === null) { - return {}; - } - const signal = "signal" in last && last.signal instanceof AbortSignal ? last.signal : null; - const timeoutMs = - "timeoutMs" in last && typeof last.timeoutMs === "number" ? last.timeoutMs : null; - return { - ...(signal !== null && { signal }), - ...(timeoutMs !== null && { timeoutMs }), - }; +function callRequirement( + key: MethodKey, + parameters: ParameterFeatures | undefined, + args: A, +): CallRequirement { + const brought = parameters === undefined ? [] : parameters(...args); + return { parameters: brought, method: methodRequirements(key) }; } diff --git a/packages/client/src/version/requirement-check.ts b/packages/client/src/version/requirement-check.ts index abc79f3b..f7fcfb4d 100644 --- a/packages/client/src/version/requirement-check.ts +++ b/packages/client/src/version/requirement-check.ts @@ -6,6 +6,25 @@ import { } from "./preflight-error"; import { featureGap, type ServerProfile } from "./profile"; import { describeVersion } from "./tag"; + +// A feature a call needs only because of an argument it was handed, with the request fields that +// argument travels in, as a server rejecting it names them. +export interface ParameterRequirement { + readonly feature: FeatureName; + readonly fields: readonly string[]; +} + +// What one call needs from the server: the features its arguments brought, then the method's own. +export interface CallRequirement { + readonly parameters: readonly ParameterRequirement[]; + readonly method: readonly FeatureName[]; +} + +/** Every feature `call` needs, parameters first, since a parameter's floor sits above its method's. */ +export function callFeatures(call: CallRequirement): FeatureName[] { + return [...call.parameters.map((parameter) => parameter.feature), ...call.method]; +} + /** The first of `features` the profile lacks, or `null` when it has every one. */ export function checkFeatures( features: readonly FeatureName[], diff --git a/packages/client/src/version/requirements.ts b/packages/client/src/version/requirements.ts index 429a39a0..b39c2849 100644 --- a/packages/client/src/version/requirements.ts +++ b/packages/client/src/version/requirements.ts @@ -74,10 +74,10 @@ export const METHOD_REQUIREMENTS = { "gitSync.branches": ["remoteSync"], "gitSync.createBranch": ["remoteSync"], "gitSync.setCollectionSynced": ["remoteSync"], - "gitSync.syncedCollections": [], + "gitSync.syncedCollections": ["remoteSync"], "gitSync.remoteUrl": [], - "gitSync.branch": [], - "gitSync.trackedBranch": [], + "gitSync.branch": ["remoteSync"], + "gitSync.trackedBranch": ["remoteSync"], "gitSync.waitForTask": ["remoteSync"], "glossary.list": [], "glossary.create": [], diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index f6a52d35..0b2ad542 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -190,7 +190,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); expect(entry.requires).toEqual({ methods: ["gitSync.exportPreflight", "gitSync.trackedBranch"], - features: ["remoteSyncExportPreflight"], + features: ["remoteSyncExportPreflight", "remoteSync"], }); }); }); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 8c13571f..d78edbb2 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -31,7 +31,7 @@ const BEYOND_KNOWN = KNOWN_RANGE.max + 5; const BELOW_KNOWN = KNOWN_RANGE.min - 1; const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max + 1}. Run \`mb upgrade\` for a newer CLI.`; -const OLDER_NOTICE = `Metabase v0.${BELOW_KNOWN}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command needing a newer feature is sent, and the server's refusal names it. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`; +const OLDER_NOTICE = `Metabase v0.${BELOW_KNOWN}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); a command relying on a newer feature may fail. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`; describe("server-decided gating e2e", () => { const tempDirs: string[] = []; From a364e162d6aeaad61327ee03fdd663b03a3278db Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 19:46:27 -0400 Subject: [PATCH 03/11] address full review of server-authoritative gates - git-sync: name the tracked branch only to a server with the branch guard; an older server takes an export's branch as a switch - probe cache: save only when the server changed; serialize profiles-file writes within a process so a probe save never restores a rotated token - shape error after a write no longer tells the user to retry - explain a refusal under the probe's own bound; drop the request-budget side table - move refusal classification into version/refusal.ts; rename preflight-error to capability-error - keep --skip-preflight / MB_CLI_SKIP_PREFLIGHT as a warning no-op - fix README, architecture, CLAUDE.md and skill drift --- .claude/skills/add-e2e-test/SKILL.md | 2 +- .claude/skills/add-resource-command/SKILL.md | 4 +- .claude/skills/review/SKILL.md | 4 +- CLAUDE.md | 2 +- README.md | 37 +-- docs/architecture.md | 10 +- packages/cli/src/commands/flags.ts | 7 + packages/cli/src/commands/runtime.ts | 80 +++--- packages/cli/src/commands/skills/get.test.ts | 2 +- packages/cli/src/commands/skills/list.test.ts | 4 +- .../cli/src/core/auth/server-summary.test.ts | 14 +- packages/cli/src/core/auth/server-summary.ts | 35 ++- packages/cli/src/core/auth/storage.ts | 230 ++++++++++-------- packages/cli/src/core/env.ts | 2 + packages/cli/src/core/skills.ts | 2 +- packages/cli/src/output/error.test.ts | 2 +- packages/cli/src/output/error.ts | 2 +- packages/cli/src/output/skill-list.test.ts | 2 +- packages/cli/src/output/skill-list.ts | 9 +- packages/client/README.md | 2 +- packages/client/package.json | 2 +- packages/client/src/errors.ts | 3 +- packages/client/src/http/transport.test.ts | 2 +- packages/client/src/http/transport.ts | 124 ++-------- packages/client/src/index.ts | 2 +- .../client/src/resources/dependency.test.ts | 2 +- .../client/src/resources/document.test.ts | 2 +- packages/client/src/resources/erd.test.ts | 2 +- packages/client/src/resources/field.test.ts | 2 +- .../client/src/resources/git-sync.test.ts | 2 +- packages/client/src/resources/git-sync.ts | 32 +-- packages/client/src/resources/metric.test.ts | 2 +- .../src/resources/moderation-review.test.ts | 2 +- packages/client/src/resources/pulse.test.ts | 2 +- .../client/src/resources/replacement.test.ts | 2 +- .../client/src/resources/revision.test.ts | 2 +- packages/client/src/resources/search.test.ts | 2 +- packages/client/src/resources/table.test.ts | 2 +- packages/client/src/resources/table.ts | 7 +- .../src/resources/transform-dag-run.test.ts | 2 +- .../src/resources/transform-inspector.test.ts | 2 +- .../src/resources/transform-python.test.ts | 2 +- .../src/resources/transform-test.test.ts | 2 +- .../client/src/resources/transform.test.ts | 2 +- ...preflight-error.ts => capability-error.ts} | 2 +- packages/client/src/version/refusal.ts | 89 ++++++- .../client/src/version/requirement-check.ts | 2 +- tests/e2e/server-gate.ts | 2 +- tests/e2e/skills.e2e.test.ts | 2 +- 49 files changed, 403 insertions(+), 352 deletions(-) rename packages/client/src/version/{preflight-error.ts => capability-error.ts} (98%) diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md index 3bc67650..41efe6d5 100644 --- a/.claude/skills/add-e2e-test/SKILL.md +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -164,7 +164,7 @@ Which package a schema belongs to is a boundary question, not a stylistic one. A **5b. Assertion strictness.** Every assertion is exact: - **Parsed payloads:** one `toEqual({ ...full expected... })` over the parsed object — never a sequence of `expect(parsed.id).toBe(...)`, `expect(parsed.name).toBe(...)`. The whole point of `parseJson` is that the structure is now in your hands; pinning each field individually leaves the rest untested and lets a regression that flips an unchecked field pass silently. -- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `packages/client/src/errors.ts`: `ConfigError` → 2, `CapabilityError` (`packages/client/src/version/preflight-error.ts`) → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all". +- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `packages/client/src/errors.ts`: `ConfigError` → 2, `CapabilityError` (`packages/client/src/version/capability-error.ts`) → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all". - **Error strings:** the harness is never a TTY, so the CLI renders errors as a JSON envelope on stderr rather than plain text. Assert through `cliErrorMessage(result.stderr)` from `./cli-error`, which unwraps that envelope and hands back the human message: ```ts diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index cac6dfe4..c42406a2 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -419,7 +419,7 @@ A comprehensive suite for a typical list/get pair covers, at minimum: Assertions are exact at every level — these are not stylistic preferences, they are hard rules from CLAUDE.md and the `add-e2e-test` skill: -- **Exit codes** — always the exact integer (`toBe(0)`, `toBe(1)`, `toBe(2)`, `toBe(130)`). Never `.not.toBe(0)`. The `packages/client/src/errors.ts` taxonomy is fixed: `ConfigError`=2, `CapabilityError` (`packages/client/src/version/preflight-error.ts`)=2, `AbortError`=130, all others=1. +- **Exit codes** — always the exact integer (`toBe(0)`, `toBe(1)`, `toBe(2)`, `toBe(130)`). Never `.not.toBe(0)`. The `packages/client/src/errors.ts` taxonomy is fixed: `ConfigError`=2, `CapabilityError` (`packages/client/src/version/capability-error.ts`)=2, `AbortError`=130, all others=1. - **Error strings** — always `toContain("")` or `toBe("")`. Never `toMatch(/.../i)`. Look the literal up in `packages/` and pin it. A regex with `\d+` or `.*` for a dynamic part is FAIL — build the expected string from the same source the production code consumed and assert with `toBe`. - **Parsed payloads** — always one full `toEqual({ ... })`. Field-by-field `toBe` after `parseJson` is FAIL. @@ -509,7 +509,7 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and the namespace composed in `createClient` through `explain("", …)`. +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and each gated method exported through the `explain` / `explainWalk` of `explainer(transport, "")`. - [ ] **Requirements** — every method has its `"."` entry in `METHOD_REQUIREMENTS`; a gated route has a behaviour-named rule in `FEATURE_RULES` verified against the Metabase release branches; a shape that differs by generation has its `WireV` + converter + exported reader, with converter unit tests per generation. - [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. - [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 6d7ac364..66199e5c 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -37,7 +37,7 @@ packages/client/src/ auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts version/ tag.ts, probe.ts, known-range.ts (KNOWN_RANGE), profile.ts (ServerProfile), features.ts (FEATURE_RULES — the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS), - requirement-check.ts, preflight-error.ts + requirement-check.ts, capability-error.ts domain/ Metabase API resource Zod schemas + inferred types errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage list.ts ListResult = { data, total } — what a one-response list method returns @@ -223,7 +223,7 @@ For each axis below, output `PASS`, `FAIL`, or `UNKNOWN`. For every `FAIL` and ` 12. **Type drift** — a hand-written `interface`/`type` whose keys mirror an in-repo value's shape (could be `typeof X`, mapped over `keyof typeof X`, or a library generic like `ParsedArgs`) is FAIL. Hand-maintained parallel definitions drift silently. 13. **Catch-all directories** — new files under `_shared/`, `_helpers/`, `_utils/`, `common/`, `lib/`, `misc/`, or any other bucket-name directory is FAIL. Files must be named by purpose. 14. **Test quality** — assertions over a structure must be full (`toEqual()` / `toEqual()`) — single-property pokes when the whole object is observable is FAIL (e.g. a sequence of `expect(parsed.id).toBe(...)` / `expect(parsed.name).toBe(...)` after `parseJson` collapses to one `toEqual({...})`). Redundant assertions (e.g. asserting `.length` before `toEqual` of the array) is FAIL. Error assertions that check only the type or only the message — never both — is FAIL. Fixture fields that no test reads (and are not structurally required to satisfy a type) is FAIL. - - **Exit codes:** `expect(.exitCode).not.toBe(0)` is FAIL. Assert the exact integer — `toBe(2)` for `ConfigError`, `toBe(130)` for `AbortError`, `toBe(2)` for `CapabilityError`, `toBe(1)` for everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`). The taxonomy lives in `packages/client/src/errors.ts` (`CapabilityError` in `packages/client/src/version/preflight-error.ts`); if you can't tell which code is right, the test isn't asserting the contract you think it is. + - **Exit codes:** `expect(.exitCode).not.toBe(0)` is FAIL. Assert the exact integer — `toBe(2)` for `ConfigError`, `toBe(130)` for `AbortError`, `toBe(2)` for `CapabilityError`, `toBe(1)` for everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`). The taxonomy lives in `packages/client/src/errors.ts` (`CapabilityError` in `packages/client/src/version/capability-error.ts`); if you can't tell which code is right, the test isn't asserting the contract you think it is. - **Error strings:** `expect().toMatch(/.../i)` for stderr / error messages is FAIL. Use `toContain("")` or `toBe("")`. The narrow exception is asserting _absence_ of a pattern (`expect(out).not.toMatch(/pattern/)`). Where the message contains a dynamic value (a byte count, a generated id, a path), build the expected string from the same data the production code consumed and assert with `toBe`. Replacing the dynamic part with `\d+` or `.*` in a regex is FAIL. - **e2e stderr:** the harness is never a TTY, so the CLI writes a JSON error envelope to stderr. `expect(.stderr).toBe("")` can never match and is FAIL — stderr carries the envelope plus any leading `warn()` lines. Assert through `cliErrorMessage(.stderr)` from `tests/e2e/cli-error.ts`. A raw `expect(.stderr).toContain(...)` is acceptable for a plain substring, but is FAIL when the expected message contains a quote, backslash, or newline — the envelope JSON-escapes those, so the raw substring is absent and only `cliErrorMessage` sees the real text. 15. **README parity** — the root `README.md` is the user-facing command reference and must document every command in `packages/cli/src/commands/` (each group exported as a top-level subcommand). Adding a new command, renaming one, removing one, or changing a flag's name/description without a matching root `README.md` update is FAIL. Adding/removing a flag in `GLOBAL_FLAG_ARGS` (`packages/cli/src/commands/global-flags.ts`, composed from `outputFlags` / `profileFlag` / `connectionFlags` in `packages/cli/src/commands/flags.ts`) without updating the common-flags section is FAIL. A change to a root script (`bun run check`, the `e2e:*` family) without updating the "Working in the repo" section is FAIL. `packages/client/README.md` is the client's own reference, governed by the barrel exports in `packages/client/src/index.ts` and the `domain/` pair contract — adding, removing, or renaming a barrel export, or changing a documented ``/`Compact` sample so it diverges from the schema, without updating it is FAIL. `packages/cli/README.md` is a pointer to the root one for the npm page — a diff that grows it into a second command reference is FAIL. diff --git a/CLAUDE.md b/CLAUDE.md index 639aca79..53983b78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Every resource lives in `packages/client/src/domain/.ts` and - `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". - Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. - Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE.min … max + 1` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. -- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameters?)`, or `explainWalk(…)` for a method answering an async iterable, from `const { explain, explainWalk } = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace; `explain-guard.test.ts` fails a gated method exported unwrapped. When the server rejects the call, `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError` that carries the server's own answer. A parameter an older server rejects is named through `parameters`, a small named function beside the method returning `{ feature, fields }` entries: the feature and the request fields the argument travels in; a 400 naming a field outside those is the server's own error, not the feature's refusal. Only a call an unsupporting server would answer wrongly without a word — a parameter it drops or rewrites, or a method whose answer it would read differently — is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. +- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameters?)`, or `explainWalk(…)` for a method answering an async iterable, from `const { explain, explainWalk } = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace; `explain-guard.test.ts` fails a gated method exported unwrapped, except the methods it names in `REFUSED_BEFORE_THE_REQUEST`, which refuse before the wire (below). When the server rejects the call, `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError` that carries the server's own answer. A parameter an older server rejects is named through `parameters`, a small named function beside the method returning `{ feature, fields }` entries: the feature and the request fields the argument travels in; a 400 naming a field outside those is the server's own error, not the feature's refusal. Only a call an unsupporting server would answer wrongly without a word — a parameter it drops or rewrites, or a method whose answer it would read differently — is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature; where an older server acts on it instead (a git-sync export's `branch` switches the tracked branch), send it only where the feature holds. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. - List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. diff --git a/README.md b/README.md index 133d4d23..e2eb4922 100644 --- a/README.md +++ b/README.md @@ -2029,7 +2029,7 @@ mb git-sync import --merge | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -The import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included); Metabase v63 and newer reject an import whose assertion disagrees, and older servers ignore it. When no branch is tracked, or the caller is not an admin, so the setting is not readable, the command refuses with exit 2 on Metabase v63 and newer with remote sync, rather than reading the setting as unset, and sends the import without the assertion anywhere else. On a server without remote sync the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). +On Metabase v63 and newer the import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included), and the server rejects an import whose assertion disagrees. When no branch is tracked, or the caller is not an admin, so the setting is not readable, the command refuses with exit 2 there rather than reading the setting as unset. Older servers ignore the assertion, so they get none. On a server without remote sync the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). ### `mb git-sync export` @@ -2042,15 +2042,15 @@ mb git-sync export --branch main --json mb git-sync export --no-wait ``` -| Flag | Description | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given; with no `--branch` they export to the tracked branch, and to their own default when the setting is unset or not readable. A blank name is refused with exit 2. | -| `--message `, `-m` | Commit message for the export. | -| `--force` | Force-push / overwrite the remote branch. | -| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | -| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | -| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | -| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +| Flag | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given; with no `--branch` the request names none and the server exports to the branch it tracks. A blank name is refused with exit 2. | +| `--message `, `-m` | Commit message for the export. | +| `--force` | Force-push / overwrite the remote branch. | +| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | +| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | ### `mb git-sync export-preflight` @@ -2349,14 +2349,15 @@ Exit codes: `0` success (a skill the server cannot use is reported, not refused) ## Environment variables -| Variable | Effect | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `MB_URL` | Default URL for `auth login` and config resolution. | -| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | -| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | -| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | +| Variable | Effect | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `MB_URL` | Default URL for `auth login` and config resolution. | +| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | +| `MB_CLI_SKIP_PREFLIGHT` | Deprecated, no effect beyond a warning, like `--skip-preflight`: every call goes to the server, which decides whether it is allowed. | The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. diff --git a/docs/architecture.md b/docs/architecture.md index 2b100d52..1f70c2ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,17 +60,19 @@ The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packa **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. -**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const { explain, explainWalk } = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameters?)`, visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. `explain` takes a method answering a promise and hands back one with the same signature whose rejection is explained; `explainWalk` takes one answering an async iterable (a `Pages` walk) and has the failure of any step explained as it is thrown, so a page walk needs no plumbing of its own. `explain-guard.test.ts` fails a gated method a resource exports unwrapped, and a wrapped one with no feature to name. When the call fails the way a server rejects what it lacks — a 402 premium refusal (which a gated `/api/ee` prefix answers before routing, whether or not the route exists), an unrouted 404, a 400 on a value outside an older enum or a body an older route reads in another shape, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — and the server's own refusal as its `cause`. A 400 is also an ordinary validation failure, so it counts as the feature's refusal only when nothing in it points elsewhere: when its field errors (`fieldErrors` / `specificFieldErrors`, compared by top-level field) name a field outside the `fields` of the missing parameter features, the server's `HttpError` stands unchanged — a tier-named `data_layer` riding along with another field the server rejects reads as that field's rejection, not the tier floor. A 400 naming no field is the refusal (a v58 table update checks `data_layer` in its model and answers 400 with only a message), and so is any 400 from a server lacking a feature the method itself needs, since the call cannot succeed there whatever field it names (a v59 Python test run rejects the newer `source_tables` shape). The message of a 400-explained refusal still quotes the server's answer after the requirement; its field errors stay structured on the cause. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails or outlasts the failed request's own `timeoutMs`; the probe waits on that request's `signal`, and an interrupt during it surfaces as the interrupt. The transport records each failed request's budget against the `HttpError` it raised, so the explanation is bounded by the request that failed, never by an argument the wrapper guesses at; an error rebuilt after its request is explained under the default budget. A timeout that cuts the probe short, its own or one composed into the caller's signal (a wait schedule's), leaves the refusal standing rather than reading as an interrupt. The call's features are the method's plus those its arguments bring, parameters first. A parameter's features come from `explain`'s `parameters` callback, a small named function beside the method in its resource file, typed by the method's own arguments and returning `{ feature, fields }` entries: the feature, and the request `fields` the argument travels in as the server names them in a 400. A revision of a measure or a transform (a v58 server answers 404 for the listing and a 400 naming `entity` for a revert), a measure's dependency graph (a 400 naming `type`) and a tier-named `data_layer` (the list filter's 400 names `data-layer`; the update's names nothing) are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, and a wrapped call that needs nothing for the arguments it was handed (a revision of a card) gets its own error back at once, so a baseline command with no cached probe stays socket-free; so does a method refused before the wire, whose `CapabilityError` is no refusal to explain. `requirements.test.ts` asserts the table names exactly the methods the client exposes. +**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const { explain, explainWalk } = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameters?)`, visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. `explain` takes a method answering a promise and hands back one with the same signature whose rejection is explained; `explainWalk` takes one answering an async iterable (a `Pages` walk) and has the failure of any step explained as it is thrown, so a page walk needs no plumbing of its own. `explain-guard.test.ts` fails a gated method a resource exports unwrapped, except the ones it names as refused before the wire, and a wrapped one with no feature to name. When the call fails the way a server rejects what it lacks — a 402 premium refusal (which a gated `/api/ee` prefix answers before routing, whether or not the route exists), an unrouted 404, a 400 on a value outside an older enum or a body an older route reads in another shape, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` (classifying through `mayBeRefusal` and `explainedFailure` in `version/refusal.ts`) reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — and the server's own refusal as its `cause`. A 400 is also an ordinary validation failure, so it counts as the feature's refusal only when nothing in it points elsewhere: when its field errors (`fieldErrors` / `specificFieldErrors`, compared by top-level field) name a field outside the `fields` of the missing parameter features, the server's `HttpError` stands unchanged — a tier-named `data_layer` riding along with another field the server rejects reads as that field's rejection, not the tier floor. A 400 naming no field is the refusal (a v58 table update checks `data_layer` in its model and answers 400 with only a message), and so is any 400 from a server lacking a feature the method itself needs, since the call cannot succeed there whatever field it names (a v59 Python test run rejects the newer `source_tables` shape). The message of a 400-explained refusal still quotes the server's answer after the requirement; its field errors stay structured on the cause. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails. The probe is bounded by its own timeout and cancelled by the client's signal, so an interrupt during it surfaces as the interrupt. The call's features are the method's plus those its arguments bring, parameters first. A parameter's features come from `explain`'s `parameters` callback, a small named function beside the method in its resource file, typed by the method's own arguments and returning `{ feature, fields }` entries: the feature, and the request `fields` the argument travels in as the server names them in a 400. A revision of a measure or a transform (a v58 server answers 404 for the listing and a 400 naming `entity` for a revert), a measure's dependency graph (a 400 naming `type`) and a tier-named `data_layer` (the list filter's 400 names `data-layer`; the update's names nothing) are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, and a wrapped call that needs nothing for the arguments it was handed (a revision of a card) gets its own error back at once, so a baseline command with no cached probe stays socket-free; so does a method refused before the wire, whose `CapabilityError` is no refusal to explain. `requirements.test.ts` asserts the table names exactly the methods the client exposes. -**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent: `GET /api/table` below the floor of `can-query`, `can-write`, `include-transform-targets` or `published-only`, or without the `dependencies` token for `unused-only`, lists every table (each filter applies only when `true`, so a `false` one is sent anywhere); an import or export before `remoteSyncMerge` ignores `merge`; a CSV export before `exportCsvByteOrderMark` writes no byte order mark; a field update before `fieldDataSensitivity` drops the label; a search without the `content_verification` token drops `verified` and answers unverified content as if filtered; and a dashboard subscription's `parameters` are stored without the `dashboard_subscription_filters` token but ignored at send time, so the audience gets the dashboard's own filter values. A server that names tiers rewrites a medallion `data_layer` on update (and leaves a `copper` table visible while storing it hidden), and a server without the user-value table fails a `data_authority: null` withdrawal, on a table update or a bulk edit, as a bare 500 or coerces it. Sending those would succeed wrongly or fail without a cause, so the method refuses first through `transport.requireFeatures` or a value check — against `verifiedServer`, so a stale cache can neither block a parameter the server honours nor pass one it would drop. The same holds for a method whose whole answer a server without the feature would misread: `gitSync.syncedCollections` filters the collection listing on `is_remote_synced`, which a server without remote sync never sends, so it would answer an empty scope rather than refuse, and `gitSync.branch` would answer the setting an EE server without the token still shows its admin as a branch git-sync tracks. Each such check says in a comment what the server would do. A drop that depends on the patch release rather than the major is judged from the answer instead: a table update drops `collection_id` on early patches of every major before 62 and writes it on later ones, so `table.update` compares the table the server answers with the collection asked for and, when they differ, throws a `PartialWriteError` (category `partial-write`, not retryable) saying the rest of the update was applied; and a `null` the update drops on every server (`display_name`, `entity_type`, `show_in_getting_started`, `field_order`, `collection_id`) is no value of `TableUpdateInput`. These checks still judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read: a git-sync import carries `expected_branch` and an export `branch`, the tracked branch when the caller names none, which a server before `remoteSyncBranchGuard` ignores or takes as its own default. The tracked branch is read from the server's session properties through `transport.probe`, which takes that read as the client's verified profile, so the merge check, the features of the unset case, and the explanation of a refusal that follow it cost no further request. When the setting is unset, or hidden from a caller who is not an admin, the features decide: a server with both `remoteSync` and `remoteSyncBranchGuard` would reject the request without a branch, so it is refused before the request; any other gets the request without one, an older server picking its own default and a server without remote sync refusing in words the client explains. The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. +**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent, rewrite it, or fail it without a cause: a `GET /api/table` filter below its floor lists every table, a search without the `content_verification` token drops `verified`, an import or export before `remoteSyncMerge` ignores `merge`. Such a method refuses first through `transport.requireFeatures` or a value check on `transport.verifiedServer()`, never against the cached profile, and says in a comment beside the check what the server would do; those comments are the list. The same holds for a method whose whole answer a server without the feature would misread (`gitSync.syncedCollections`, `gitSync.branch`), and those methods are the ones `explain-guard.test.ts` names in `REFUSED_BEFORE_THE_REQUEST`. A drop that depends on the patch release rather than the major is judged from the answer instead: `table.update` compares the collection the server answers with the one asked for and, when they differ, throws a `PartialWriteError` (category `partial-write`, not retryable) saying the rest of the update was applied. A `null` the update drops on every server is no value of `TableUpdateInput`. These checks judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. + +**Git-sync names the tracked branch only to a server that guards it.** Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read. A git-sync import's `expected_branch` and an export's `branch` are the exception. When the caller names none, the client reads the tracked branch from the session properties through `transport.probe`, which settles the verified profile in the same request, so the merge check and the explanation of a refusal that follow cost nothing more. It sends that branch only to a server with `remoteSync` and `remoteSyncBranchGuard`, which requires it; there an unset setting, or one hidden from a caller who is not an admin, is refused before the request. An older server reads the setting itself within the request. It ignores an import's assertion, but it takes an export's branch as a switch, so a branch read a moment earlier would undo a switch another admin made in between. **The CLI declares what a command reaches.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features for `help --json`, which reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the table. The CLI runs no check of its own: the client's explanation reaches the user as the same `CapabilityError` (exit 2). -**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the expected fields and the tag of the profile in force when the request started, the one the reader was chosen for. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`, composed through `explain` in `createClient`. `server()` answers the profile in force — the newest probe the client settled, else the one handed in — and awaits a probe in flight, whose failure falls back to that profile, so a shape read never fails on a probe it did not need. The client keeps one probe: the newest started, shared by every caller while in flight, kept once settled and dropped when it fails; only that one settles the profile in force and reaches `onServerProbed`, so an answer that lands after a later probe started never replaces it. +**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the expected fields and the tag of the profile in force when the request started, the one the reader was chosen for. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`, exported through `explain`. `server()` answers the profile in force — the newest probe the client settled, else the one handed in — and awaits a probe in flight, whose failure falls back to that profile, so a shape read never fails on a probe it did not need. The client keeps one probe: the newest started, shared by every caller while in flight, kept once settled and dropped when it fails; only that one settles the profile in force and reaches `onServerProbed`, so an answer that lands after a later probe started never replaces it. Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade. A run with no cached probe learns the server only from a probe the client runs, so when its command fails with a failure skew could explain — a shape error or an unrouted 404, never a validation 4xx, a 5xx, a network error, a timeout, a credential failure, a missing row or the CLI's own refusal — `diagnoseFailure` in `commands/runtime.ts` asks the client for `verifiedServer()` on the way out, which costs nothing when the run has probed already and otherwise gives the notice its say; a command that succeeds against an out-of-range server without a cache stays silent. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, which the client calls without awaiting; the CLI queues a write of the probe to the profile record, dating it by that probe, skips one that says the same version and granted token features as the last it saved in the run, and waits on the queue before the command ends (a write that fails warns without failing the command). So a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices, and a record's probe time is the last time the server was asked. A `ResponseShapeError` whose shape was chosen under the cached profile — its tag is the cached one — earns the same `verifiedServer()` from `diagnoseFailure`: when the fresh probe differs from the cache in version or granted token features, the error says the profile was refreshed and to retry; the command is not retried, because its request may have been a write. A shape chosen under a probe the run already settled carries that probe's tag, so its error stands. The one misreading left is a probe settled mid-run with the cached tag but other token features: a shape chosen under it still reads as chosen under the cache, and the error asks for a retry it did not need. `skills list` / `skills get` filter by the cached probe without a request, and name the time of that probe and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade. A run with no cached probe learns the server only from a probe the client runs, so when its command fails with a failure skew could explain — a shape error or an unrouted 404, never a validation 4xx, a 5xx, a network error, a timeout, a credential failure, a missing row or the CLI's own refusal — `diagnoseFailure` in `commands/runtime.ts` asks the client for `verifiedServer()` on the way out, which costs nothing when the run has probed already and otherwise gives the notice its say; a command that succeeds against an out-of-range server without a cache stays silent. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, which the client calls without awaiting; the CLI queues a write of the probe to the profile record only when it differs from the cached one in version or granted token features, and waits on the queue before the command ends (a write that fails warns without failing the command). So a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices, and a run that learns nothing new leaves the profiles file alone. Under the file backend that file also holds OAuth tokens, and every read-modify-write of it within a process runs alone (`exclusively` in `core/auth/storage.ts`), so a probe save never restores a refresh token the server has already rotated away. A record's probe time is when the profile last recorded the server: a login, a listing, or a change. A `ResponseShapeError` whose shape was chosen under the cached profile — its tag is the cached one — earns the same `verifiedServer()` from `diagnoseFailure`: when the fresh probe differs from the cache in version or granted token features, the error says the profile was refreshed and, for a read, to retry; for a write it says the request may have been applied and to check before retrying. The command is not retried. A shape chosen under a probe the run already settled carries that probe's tag, so its error stands. The one misreading left is a probe settled mid-run with the cached tag but other token features: a shape chosen under it still reads as chosen under the cache, and the error carries a note it did not need. `skills list` / `skills get` filter by the cached probe without a request, and name when the profile last recorded the server and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). **Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. Content values an older server stores without complaint — a `boxplot` display, a `table` template tag, a measure smart link — are guarded by these markers only: the client sends them, and nothing refuses them. diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index c6926eef..c4eed0d2 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -72,4 +72,11 @@ export const profileFlag = { export const connectionFlags = { url: { type: "string", description: "Metabase URL" }, apiKey: { type: "string", description: "API key", alias: "api-key" }, + // Accepted so a script written for a release that checked the server before sending still runs; + // the server decides every call, so it only warns. + skipPreflight: { + type: "boolean", + description: "Deprecated, no effect: the server decides every call", + alias: "skip-preflight", + }, } as const; diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index d0a4c2d1..969f6fc0 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -16,7 +16,7 @@ import { type MethodKey, methodRequirements } from "@metabase/client/version/req import type { ProfileLastProbe } from "../core/auth/profile-record"; import { ProfileRefreshedError } from "../core/profile-refreshed-error"; -import { serverChangeNote, skewNotice } from "../core/auth/server-summary"; +import { sameServer, serverChangeNote, skewNotice } from "../core/auth/server-summary"; import { readCachedProbe } from "../core/auth/cached-server"; import { consumeKeyringDowngradeWarning, @@ -29,7 +29,7 @@ import { type ConfigFlags, type ResolvedConfig, } from "../core/config"; -import { consumeLegacyEnvWarnings } from "../core/env"; +import { consumeLegacyEnvWarnings, ENV_SKIP_PREFLIGHT, readEnv } from "../core/env"; import { USER_AGENT } from "../core/user-agent"; import { reportError } from "../output/error"; import { warn } from "../output/notice"; @@ -80,6 +80,9 @@ export function defineMetabaseCommand( try { reportFormat = resolveReportFormat(commonArgs, rawArgs, def.args); assertArgv(rawArgs, def.args); + if (args["skipPreflight"] === true || readEnv(ENV_SKIP_PREFLIGHT) !== undefined) { + warn(SKIP_PREFLIGHT_NOTICE); + } const ctx = resolveCommonFlags(commonArgs); let cachedConfig: ResolvedConfig | null = null; const getResolvedConfig = async (): Promise => { @@ -88,15 +91,15 @@ export function defineMetabaseCommand( } return cachedConfig; }; - const run: RunServer = { client: null, cachedProbe: null, writes: Promise.resolve() }; + const run: RunState = { server: null, writes: Promise.resolve() }; const noticeSkew = createSkewNotifier(); // Imported here rather than at the top of the file so the resource namespaces // `createClient` composes — and the whole `domain/` layer behind them — stay off the chunk // every command loads, including `--help`, a flag error, and the commands that open no // socket at all. const getClient = async (): Promise => { - if (run.client !== null) { - return run.client; + if (run.server !== null) { + return run.server.client; } const resolved = await getResolvedConfig(); const probe = await readCachedProbe(resolved.profile, resolved.url); @@ -109,8 +112,8 @@ export function defineMetabaseCommand( userAgent: USER_AGENT, ...(server !== null && { server }), refreshCredential: createCredentialRefresher(resolved.profile), - onServerProbed: (fresh) => { - noticeSkew(createServerProfile(fresh)); + onServerProbed: (fresh, profile) => { + noticeSkew(profile); if (saveProbe !== null) { run.writes = run.writes.then(() => saveProbe(fresh)); } @@ -118,8 +121,7 @@ export function defineMetabaseCommand( signal: interruptSignal, }, ); - run.client = client; - run.cachedProbe = probe; + run.server = { client, cachedProbe: probe }; if (server !== null) { noticeSkew(server); } @@ -154,6 +156,8 @@ export function defineMetabaseCommand( return cmd; } +const SKIP_PREFLIGHT_NOTICE = `--skip-preflight and ${ENV_SKIP_PREFLIGHT} have no effect and will be removed: every call goes to the server, which decides whether it is allowed.`; + function deriveRequirements(methods: readonly MethodKey[]): CommandRequirements { const features = [...new Set(methods.flatMap((key) => methodRequirements(key)))]; return { methods, features }; @@ -193,18 +197,17 @@ function createSkewNotifier(): SkewNotifier { type ProbeSaver = (fresh: ServerInfo) => Promise; -// Every probe the client runs is the server's own word, and saving it dates the cache by the last -// time the server was asked, so the skills filter and the next run's shape choices read the server -// as it was then. A probe that says what the last one saved in this run said is not saved again. -// The cache is a convenience, so a write that fails warns rather than failing the command the probe -// served. +// A probe that finds the server changed since the cache was written is saved, so the skills filter +// and the next run's shape choices read the server as it is; one that agrees with the cache is not, +// so a run that probes rewrites the profiles file only when it has something new to say. The cache +// is a convenience, so a write that fails warns rather than failing the command the probe served. function createProbeSaver(profileName: string, cached: ProfileLastProbe): ProbeSaver { - let lastSaved: ServerInfo | null = null; + let current: ServerInfo = cached; return async (fresh) => { - if (lastSaved !== null && serverChangeNote(lastSaved, fresh) === null) { + if (sameServer(current, fresh)) { return; } - lastSaved = fresh; + current = fresh; try { await writeProbeResult(profileName, { user: cached.user, server: fresh }); } catch (error) { @@ -215,38 +218,41 @@ function createProbeSaver(profileName: string, cached: ProfileLastProbe): ProbeS }; } -// What a run knows of its server: the client once built, the probe the profile cached for it, and -// the cache writes the client's probes have queued, which the run waits on before it ends. +// What a run knows of its server once the client is built, and the cache writes the client's probes +// have queued, which the run waits on before it ends. +interface RunState { + server: RunServer | null; + writes: Promise; +} + interface RunServer { - client: MetabaseClient | null; + client: MetabaseClient; cachedProbe: ProfileLastProbe | null; - writes: Promise; } -// A failure that skew could explain — a shape the server answers differently, a route it does not -// serve — earns the probe that names the server, on the way out. It is free when the run has -// probed already, because the client keeps its probe. With no cached profile the probe only lets -// the hook name a server outside the known range; any other failure is not worth a request. Under -// a cached profile only a shape chosen under that profile earns it: when the server changed since -// the cache was written the error says so, and the hook has saved the server as it is. The command -// is not retried, because its request may have been a write. -async function diagnoseFailure(error: unknown, run: RunServer): Promise { - const { client, cachedProbe } = run; - if (client === null || !isSkewSymptom(error)) { +// A failure that skew could explain earns the probe that names the server, on the way out; it is +// free once the run has probed, because the client keeps its probe. With no cached profile the +// probe only lets the skew notice speak. Under one, only a shape read under that profile's tag is +// worth it: a probe settled mid-run with the same tag at worst adds a needless note. The command is +// never retried, because its request may have been a write. +async function diagnoseFailure(error: unknown, run: RunState): Promise { + if (run.server === null || !isSkewSymptom(error)) { return error; } + const { client, cachedProbe } = run.server; if (cachedProbe === null) { await probeOnTheWayOut(client); return error; } - if (!(error instanceof ResponseShapeError) || shapeChosenFor(error) !== cachedProbe.version.tag) { + const detail = error instanceof ResponseShapeError ? error.developerDetail : null; + if (detail === null || detail.kind !== "zod" || detail.serverTag !== cachedProbe.version.tag) { return error; } const fresh = await probeOnTheWayOut(client); if (fresh === null) { return error; } - const note = serverChangeNote(cachedProbe, fresh); + const note = serverChangeNote(cachedProbe, fresh, detail.method); return note === null ? error : new ProfileRefreshedError(error, note); } @@ -257,14 +263,6 @@ function isSkewSymptom(error: unknown): error is MetabaseError { return error instanceof HttpError && error.kind === "route-missing"; } -// The tag of the profile the failed read chose its shape by. The same tag on a probe that settled -// mid-run with other premium features reads as the cached profile, which can only add a needless -// "retry" to the error. -function shapeChosenFor(error: ResponseShapeError): string | null { - const detail = error.developerDetail; - return detail.kind === "zod" ? detail.serverTag : null; -} - // A diagnosis on the way out: a server that cannot be reached or answered now must not displace // the error the user is here for. An interrupt is the user's own and ends the command as one; // anything else is a bug and surfaces. diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts index fbbc001a..8ddb18d8 100644 --- a/packages/cli/src/commands/skills/get.test.ts +++ b/packages/cli/src/commands/skills/get.test.ts @@ -163,7 +163,7 @@ describe("skills get command", () => { expect(stdout.chunks).toEqual([]); expect(stderr.chunks.join("")).toBe( 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + - `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, + `Skills are filtered by profile "default"'s server as last recorded at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index 403260ac..2684ffe3 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -136,7 +136,7 @@ describe("skills list command", () => { ); expect(stderr.chunks.join("")).toBe( 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + - `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, + `Skills are filtered by profile "default"'s server as last recorded at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); @@ -161,7 +161,7 @@ describe("skills list command", () => { ); expect(stderr.chunks.join("")).toBe( 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n' + - `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).\n`, + `Skills are filtered by profile "default"'s server as last recorded at ${probedAt} (run \`mb auth list\` to probe it again).\n`, ); }); diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index 89660249..d3ed1c3c 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -76,39 +76,39 @@ describe("skewNotice", () => { describe("serverChangeNote", () => { it("names both tags when the version changed", () => { - expect(serverChangeNote(serverAt(59), serverAt(63))).toBe( + expect(serverChangeNote(serverAt(59), serverAt(63), "GET")).toBe( "The server's version changed since the last probe (was v0.59.0, now v0.63.0); the profile was refreshed — retry the command.", ); }); it("names a development build that replaced a release", () => { - expect(serverChangeNote(serverAt(59), DEVELOPMENT)).toBe( + expect(serverChangeNote(serverAt(59), DEVELOPMENT, "GET")).toBe( "The server's version changed since the last probe (was v0.59.0, now development build vUNKNOWN); the profile was refreshed — retry the command.", ); }); it("reports a premium feature the server started granting under the same version", () => { expect( - serverChangeNote(serverAt(59, { library: false }), serverAt(59, { library: true })), + serverChangeNote(serverAt(59, { library: false }), serverAt(59, { library: true }), "GET"), ).toBe( "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", ); }); it("reports a premium feature the server stopped granting", () => { - expect(serverChangeNote(serverAt(59, { library: true }), serverAt(59, null))).toBe( + expect(serverChangeNote(serverAt(59, { library: true }), serverAt(59, null), "GET")).toBe( "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", ); }); it("treats an unreported map, an empty one, and a denied feature as the same grants", () => { - expect(serverChangeNote(serverAt(59, null), serverAt(59, {}))).toBeNull(); - expect(serverChangeNote(serverAt(59, {}), serverAt(59, { library: false }))).toBeNull(); + expect(serverChangeNote(serverAt(59, null), serverAt(59, {}), "GET")).toBeNull(); + expect(serverChangeNote(serverAt(59, {}), serverAt(59, { library: false }), "GET")).toBeNull(); }); it("is null when tag and grants agree even though the build moved", () => { const cached = serverAt(59, { library: true }); const fresh: ServerInfo = { ...cached, date: "2026-09-16", hash: "abc1234" }; - expect(serverChangeNote(cached, fresh)).toBeNull(); + expect(serverChangeNote(cached, fresh, "GET")).toBeNull(); }); }); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index e2bca3e3..6057377b 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -65,23 +65,40 @@ export function skewNotice(profile: ServerProfile): string | null { } } -const PROFILE_REFRESHED_REMEDY = "the profile was refreshed — retry the command."; +const PROFILE_REFRESHED = "the profile was refreshed"; +const SAFE_METHODS: ReadonlySet = new Set(["GET", "HEAD", "OPTIONS"]); // What a feature switch reads off a probe. type ServerIdentity = Pick; -// What a fresh probe says that the cached one did not, or `null` when the two agree on everything -// a feature switch reads: the version tag and the premium features. -export function serverChangeNote(cached: ServerIdentity, fresh: ServerIdentity): string | null { +/** Whether two probes agree on everything a feature switch reads: the version tag and the premium features. */ +export function sameServer(a: ServerIdentity, b: ServerIdentity): boolean { + return ( + describeVersion(a.version) === describeVersion(b.version) && + sameTokenFeatures(a.tokenFeatures, b.tokenFeatures) + ); +} + +// What a fresh probe says that the cached one did not, with what to do about a request of `method` +// read under the cached one, or `null` when the two are the same server. A read is safe to repeat; +// a write the server answered may have landed, and repeating it could apply it twice. +export function serverChangeNote( + cached: ServerIdentity, + fresh: ServerIdentity, + method: string, +): string | null { + if (sameServer(cached, fresh)) { + return null; + } + const remedy = SAFE_METHODS.has(method) + ? `${PROFILE_REFRESHED} — retry the command.` + : `${PROFILE_REFRESHED}, but the request may have been applied — check before retrying.`; const before = describeVersion(cached.version); const after = describeVersion(fresh.version); if (before !== after) { - return `The server's version changed since the last probe (was ${before}, now ${after}); ${PROFILE_REFRESHED_REMEDY}`; - } - if (!sameTokenFeatures(cached.tokenFeatures, fresh.tokenFeatures)) { - return `The server's premium features changed since the last probe; ${PROFILE_REFRESHED_REMEDY}`; + return `The server's version changed since the last probe (was ${before}, now ${after}); ${remedy}`; } - return null; + return `The server's premium features changed since the last probe; ${remedy}`; } // Only a granted feature turns a switch on, so a map the server did not report, an empty one, and diff --git a/packages/cli/src/core/auth/storage.ts b/packages/cli/src/core/auth/storage.ts index 71dfccc4..4682a3ce 100644 --- a/packages/cli/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -210,6 +210,20 @@ async function fileExists(path: string): Promise { } } +// One read-modify-write of the profiles file at a time within this process: a probe saved while a +// token refresh runs would otherwise rename back a file it read before the rotated token landed, +// and the two would share one temp path. +let profilesFileQueue: Promise = Promise.resolve(); + +function exclusively(update: () => Promise): Promise { + const result = profilesFileQueue.then(update); + profilesFileQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + async function writeProfilesFile(file: ProfilesFile): Promise { const path = profilesFilePath(); if (file.profiles.length === 0) { @@ -220,7 +234,7 @@ async function writeProfilesFile(file: ProfilesFile): Promise { await fs.mkdir(dirname(path), { recursive: true, mode: PROFILES_DIR_MODE }); // Write to a per-process temp file and atomically rename it into place. A crash or a concurrent // writer can then never leave a half-written profiles.json — which readProfilesFile would treat - // as corrupt and discard, silently wiping every stored profile. (A concurrent writer may still + // as corrupt and discard, silently wiping every stored profile. (Another process may still // overwrite a just-rotated token, but the client's reactive 401-refresh recovers that.) const tmpPath = `${path}.${process.pid}.tmp`; await fs.writeFile(tmpPath, JSON.stringify(file, null, 2) + "\n", { mode: PROFILES_FILE_MODE }); @@ -383,29 +397,31 @@ export async function writeProfile( profile: Profile, name: string = DEFAULT_PROFILE, ): Promise { - const location = persistSecret(account.profileApiKey(name), profile.apiKey); - const inlineApiKey = location.backend === "file" ? profile.apiKey : null; - - const file = await readProfilesFile(); - const existing = findRecord(file, name); - // Switching a profile to an API key clears any prior OAuth credential it held. - flagResidualIfUnconfirmed(existing, "oauth", [ - removeKeyringEntry(account.profileOAuthAccess(name)), - removeKeyringEntry(account.profileOAuthRefresh(name)), - ]); - const updated: ProfileRecord = - existing === null - ? { - name, - url: profile.url, - apiKey: inlineApiKey, - oauth: null, - lastProbe: null, - lastFailure: null, - } - : { ...existing, url: profile.url, apiKey: inlineApiKey, oauth: null }; - await upsertRecord(file, name, updated); - return location; + return exclusively(async () => { + const location = persistSecret(account.profileApiKey(name), profile.apiKey); + const inlineApiKey = location.backend === "file" ? profile.apiKey : null; + + const file = await readProfilesFile(); + const existing = findRecord(file, name); + // Switching a profile to an API key clears any prior OAuth credential it held. + flagResidualIfUnconfirmed(existing, "oauth", [ + removeKeyringEntry(account.profileOAuthAccess(name)), + removeKeyringEntry(account.profileOAuthRefresh(name)), + ]); + const updated: ProfileRecord = + existing === null + ? { + name, + url: profile.url, + apiKey: inlineApiKey, + oauth: null, + lastProbe: null, + lastFailure: null, + } + : { ...existing, url: profile.url, apiKey: inlineApiKey, oauth: null }; + await upsertRecord(file, name, updated); + return location; + }); } export async function writeOAuthProfile( @@ -413,60 +429,66 @@ export async function writeOAuthProfile( credential: OAuthCredential, name: string = DEFAULT_PROFILE, ): Promise { - const accessKey = account.profileOAuthAccess(name); - const refreshKey = account.profileOAuthRefresh(name); - const accessLocation = persistSecret(accessKey, credential.accessToken); - const refreshLocation = persistSecret(refreshKey, credential.refreshToken); - const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file"; - - const oauth: ProfileOAuth = { - accessToken: onFile ? credential.accessToken : null, - refreshToken: onFile ? credential.refreshToken : null, - expiresAt: credential.expiresAt, - clientId: credential.clientId, - }; - const file = await readProfilesFile(); - const existing = findRecord(file, name); - // A credential that was keyring-backed but now lands on file (keychain hiccup during refresh) is - // a silent downgrade to plaintext — flag it so the command shell can warn. - if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) { - keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey)); - } - // Switching a profile to OAuth clears any prior API key it held. - flagResidualIfUnconfirmed(existing, "apiKey", [removeKeyringEntry(account.profileApiKey(name))]); - const updated: ProfileRecord = - existing === null - ? { name, url, apiKey: null, oauth, lastProbe: null, lastFailure: null } - : { ...existing, url, apiKey: null, oauth }; - await upsertRecord(file, name, updated); - return onFile - ? fileLocation(accessKey) - : { backend: "keyring", service: KEYRING_SERVICE, account: accessKey }; + return exclusively(async () => { + const accessKey = account.profileOAuthAccess(name); + const refreshKey = account.profileOAuthRefresh(name); + const accessLocation = persistSecret(accessKey, credential.accessToken); + const refreshLocation = persistSecret(refreshKey, credential.refreshToken); + const onFile = accessLocation.backend === "file" || refreshLocation.backend === "file"; + + const oauth: ProfileOAuth = { + accessToken: onFile ? credential.accessToken : null, + refreshToken: onFile ? credential.refreshToken : null, + expiresAt: credential.expiresAt, + clientId: credential.clientId, + }; + const file = await readProfilesFile(); + const existing = findRecord(file, name); + // A credential that was keyring-backed but now lands on file (keychain hiccup during refresh) is + // a silent downgrade to plaintext — flag it so the command shell can warn. + if (onFile && existing !== null && sideIsKeyringBacked(existing, "oauth")) { + keyringDowngradeNotice = keyringFallbackWarning(fileLocation(accessKey)); + } + // Switching a profile to OAuth clears any prior API key it held. + flagResidualIfUnconfirmed(existing, "apiKey", [ + removeKeyringEntry(account.profileApiKey(name)), + ]); + const updated: ProfileRecord = + existing === null + ? { name, url, apiKey: null, oauth, lastProbe: null, lastFailure: null } + : { ...existing, url, apiKey: null, oauth }; + await upsertRecord(file, name, updated); + return onFile + ? fileLocation(accessKey) + : { backend: "keyring", service: KEYRING_SERVICE, account: accessKey }; + }); } export async function writeProbeResult( name: string, input: ProbeWriteInput, ): Promise { - const probe = ProfileLastProbe.parse({ - at: new Date().toISOString(), - version: input.server.version, - edition: input.server.edition, - date: input.server.date, - hash: input.server.hash, - tokenFeatures: input.server.tokenFeatures, - user: input.user, + return exclusively(async () => { + const probe = ProfileLastProbe.parse({ + at: new Date().toISOString(), + version: input.server.version, + edition: input.server.edition, + date: input.server.date, + hash: input.server.hash, + tokenFeatures: input.server.tokenFeatures, + user: input.user, + }); + const file = await readProfilesFile(); + const existing = findRecord(file, name); + if (existing === null) { + return null; + } + const profiles = file.profiles.map((entry) => + entry.name === name ? { ...entry, lastProbe: probe, lastFailure: null } : entry, + ); + await writeProfilesFile({ ...file, profiles }); + return probe; }); - const file = await readProfilesFile(); - const existing = findRecord(file, name); - if (existing === null) { - return null; - } - const profiles = file.profiles.map((entry) => - entry.name === name ? { ...entry, lastProbe: probe, lastFailure: null } : entry, - ); - await writeProfilesFile({ ...file, profiles }); - return probe; } interface ProbeFailureInput { @@ -478,40 +500,44 @@ export async function writeProbeFailure( name: string, input: ProbeFailureInput, ): Promise { - const failure = ProfileLastFailure.parse({ - at: new Date().toISOString(), - kind: input.kind, - reason: input.reason, + return exclusively(async () => { + const failure = ProfileLastFailure.parse({ + at: new Date().toISOString(), + kind: input.kind, + reason: input.reason, + }); + const file = await readProfilesFile(); + const existing = findRecord(file, name); + if (existing === null) { + return null; + } + const profiles = file.profiles.map((entry) => + entry.name === name ? { ...entry, lastFailure: failure } : entry, + ); + await writeProfilesFile({ ...file, profiles }); + return failure; }); - const file = await readProfilesFile(); - const existing = findRecord(file, name); - if (existing === null) { - return null; - } - const profiles = file.profiles.map((entry) => - entry.name === name ? { ...entry, lastFailure: failure } : entry, - ); - await writeProfilesFile({ ...file, profiles }); - return failure; } export async function clearProfile(name: string = DEFAULT_PROFILE): Promise { - const file = await readProfilesFile(); - const existing = findRecord(file, name); - const removals = [ - removeKeyringEntry(account.profileApiKey(name)), - removeKeyringEntry(account.profileOAuthAccess(name)), - removeKeyringEntry(account.profileOAuthRefresh(name)), - ]; - if (existing !== null) { - flagResidualIfUnconfirmed(existing, existing.oauth !== null ? "oauth" : "apiKey", removals); - } - if (existing === null) { - return false; - } - await writeProfilesFile({ - ...file, - profiles: file.profiles.filter((entry) => entry.name !== name), + return exclusively(async () => { + const file = await readProfilesFile(); + const existing = findRecord(file, name); + const removals = [ + removeKeyringEntry(account.profileApiKey(name)), + removeKeyringEntry(account.profileOAuthAccess(name)), + removeKeyringEntry(account.profileOAuthRefresh(name)), + ]; + if (existing !== null) { + flagResidualIfUnconfirmed(existing, existing.oauth !== null ? "oauth" : "apiKey", removals); + } + if (existing === null) { + return false; + } + await writeProfilesFile({ + ...file, + profiles: file.profiles.filter((entry) => entry.name !== name), + }); + return true; }); - return true; } diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts index 043ed5d0..923f274a 100644 --- a/packages/cli/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -5,6 +5,8 @@ export const ENV_URL = "MB_URL"; export const ENV_API_KEY = "MB_API_KEY"; export const ENV_PROFILE = "MB_PROFILE"; export const ENV_VERBOSE = "MB_VERBOSE"; +// Read only to warn that it no longer does anything. +export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; diff --git a/packages/cli/src/core/skills.ts b/packages/cli/src/core/skills.ts index 039164bf..97742ba4 100644 --- a/packages/cli/src/core/skills.ts +++ b/packages/cli/src/core/skills.ts @@ -10,7 +10,7 @@ import { type Features, isFeatureName, } from "@metabase/client/version/features"; -import type { RequirementFailure } from "@metabase/client/version/preflight-error"; +import type { RequirementFailure } from "@metabase/client/version/capability-error"; import type { ServerProfile } from "@metabase/client/version/profile"; import { checkFeatures } from "@metabase/client/version/requirement-check"; diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index 2fd347c7..954134d6 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -13,7 +13,7 @@ import { ValidationError, } from "@metabase/client/errors"; import { HttpError } from "@metabase/client/http/errors"; -import { CapabilityError } from "@metabase/client/version/preflight-error"; +import { CapabilityError } from "@metabase/client/version/capability-error"; import { createServerProfile } from "@metabase/client/version/profile"; import { checkFeatures } from "@metabase/client/version/requirement-check"; import { ProfileRefreshedError } from "../core/profile-refreshed-error"; diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index 5b3b1648..2af7f25c 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -1,6 +1,6 @@ import { AbortError, toMetabaseError } from "@metabase/client/errors"; import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; -import type { RequirementFailure } from "@metabase/client/version/preflight-error"; +import type { RequirementFailure } from "@metabase/client/version/capability-error"; import { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; diff --git a/packages/cli/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts index 4cba999f..30c63110 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -133,7 +133,7 @@ describe("skillFilterNotices", () => { it("names each skipped skill with the client's reason and the way around it", () => { expect(skillFilterNotices(skipped, { profileName: "default", cached: probed })).toEqual([ 'Skipped skill "transform": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.', - 'Skills are filtered by profile "default"\'s server as probed at 2026-01-02T03:04:05.000Z (run `mb auth list` to probe it again).', + 'Skills are filtered by profile "default"\'s server as last recorded at 2026-01-02T03:04:05.000Z (run `mb auth list` to probe it again).', ]); expect(skillFilterNotices([], { profileName: "default", cached: probed })).toEqual([]); }); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index 777ee8cf..97802448 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { RequirementFailure } from "@metabase/client/version/preflight-error"; +import { RequirementFailure } from "@metabase/client/version/capability-error"; import type { CachedProbeMiss, CachedServerProfile } from "../core/auth/cached-server"; import type { UnavailableSkill } from "../core/skills"; @@ -59,10 +59,11 @@ export function skillFilterNotices( : [unfilteredNotice(context.profileName, context.cached)]; } -// The filter reads the server as it was at the last probe, and a license activated since would still -// read as missing, so the notice says when that was and how to look again. +// The filter reads the server as the profile last recorded it, when it was logged in, listed, or +// seen to have changed; a license activated since would still read as missing, so the notice says +// when that was and how to look again. function probeAgeNotice(profileName: string, probedAt: string): string { - return `Skills are filtered by profile "${profileName}"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).`; + return `Skills are filtered by profile "${profileName}"'s server as last recorded at ${probedAt} (run \`mb auth list\` to probe it again).`; } function unfilteredNotice(profileName: string, miss: CachedProbeMiss): string { diff --git a/packages/client/README.md b/packages/client/README.md index fe3ff0e7..2f9795a6 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -418,7 +418,7 @@ Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo`: the version, the edition the tag stamps, the build date and hash, and the token-feature map, each but the version `null` when the server does not report it. `parseTag(tag)` reads a tag into a `ServerVersion`, a `ReleaseVersion` (`{ kind: "release", tag, major, patch }`) or a `DevelopmentBuild` (`{ kind: "development", tag }`); `describeVersion(version)` words it for a message (the tag, or `development build `); `StoredServerVersion` reads a persisted version back by re-deriving it from its tag; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info)` is called with the `ServerInfo` of every probe the client runs, so a consumer that passes a cached profile can keep its cache current: only the newest probe started reaches it, so it never sees an older answer after a newer one; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not kept. A shape error names the profile in force when its request started — the newest probe settled, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile a read chooses its shape by: the newest probe this client ran, else the one passed as `ClientOptions.server`, else one probed now; a probe in flight is awaited, and its failure falls back to the profile it would have replaced; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info, profile)` is called with the `ServerInfo` of every probe the client runs and the `ServerProfile` derived from it, so a consumer that passes a cached profile can keep its cache current: only the newest probe started reaches it, so it never sees an older answer after a newer one; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not kept. A shape error names the profile in force when its request started — the newest probe settled, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(major, tokenFeatures)` evaluates it for a `RuleMajor` (a release major, or `"development"` for a build ahead of every release), `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. diff --git a/packages/client/package.json b/packages/client/package.json index 885d2309..16d35084 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -39,7 +39,7 @@ "./version/features": "./dist/version/features.js", "./version/known-range": "./dist/version/known-range.js", "./version/profile": "./dist/version/profile.js", - "./version/preflight-error": "./dist/version/preflight-error.js", + "./version/capability-error": "./dist/version/capability-error.js", "./version/probe": "./dist/version/probe.js", "./version/requirement-check": "./dist/version/requirement-check.js", "./version/requirements": "./dist/version/requirements.js", diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 865d7df4..07e7461a 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -273,7 +273,8 @@ export interface PartialWriteDetail { path: string; field: string; requested: number; - answered: number | null | undefined; + // `null` for a table the server answered in no collection, the field left out included. + answered: number | null; } // The server answered success having written all but one field, so the request is neither a diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index e319f1eb..2b2e7651 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -21,7 +21,7 @@ import { probeResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { SessionProperties } from "../domain/session-properties"; import { PROBE_PATH, type ServerInfo } from "../version/probe"; import { KNOWN_RANGE } from "../version/known-range"; diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index e83e79f4..8dab7c64 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -6,10 +6,11 @@ import { JSON_CONTENT_TYPE } from "../json"; import { combineAborts, throwIfAborted, untilAborted } from "../signal"; import { normalizeUrl } from "../url"; import type { FeatureName } from "../version/features"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { probeProperties, serverInfoFromProperties, type ServerInfo } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; -import { type CallRequirement, callFeatures, checkFeatures } from "../version/requirement-check"; +import { explainedFailure, mayBeRefusal } from "../version/refusal"; +import { type CallRequirement, checkFeatures } from "../version/requirement-check"; import { assertCredentialHeaderSafe, @@ -19,7 +20,7 @@ import { type CredentialRefresher, } from "../auth/credential"; -import { FIELD_PATH_SEPARATOR, HttpError, isRetryableStatus } from "./errors"; +import { HttpError, isRetryableStatus } from "./errors"; import { buildNetworkError, isConnectionClosed } from "./network-error"; import { parseJsonResponse } from "./response-shape"; import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries, type RetryOutcome } from "./retry"; @@ -27,16 +28,6 @@ import type { RedactionContext } from "./sanitize"; const UNAUTHORIZED_STATUS = 401; -// What a server answers for what it does not serve: unrouted, a verb the route lacks included -// (404); the premium refusal, which a gated prefix answers before routing (402); and a 400 for a -// value outside an older enum (a revert's `entity`, a graph's `type`, the `data-layer` filter), a -// body an older route reads in another shape (a Python test run's `source_tables`), or a model check -// that names no field (a tier-named `data_layer` on an update). A 400 is also an ordinary validation -// failure, so it is the feature's refusal only when nothing in it points elsewhere -// (`pointsElsewhere`), and an explained one quotes the server. -const REFUSAL_STATUSES: ReadonlySet = new Set([400, 402, 404]); -const BAD_REQUEST_STATUS = 400; - export type HttpMethod = "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ExpectedContentType = "json" | "text" | "binary"; @@ -95,26 +86,15 @@ export interface Transport { // this check exists to prevent. An empty list resolves without consulting the server. requireFeatures(features: readonly FeatureName[], options?: WaitOptions): Promise; // The error a call failed with, as a `CapabilityError` carrying the server's answer when the - // server refused it for lacking one of the features `call` needs, and unchanged otherwise. The explaining probe - // runs under the failed request's own budget — no longer than its `timeoutMs`, and ending with its - // `signal` — or the default one for an error rebuilt after the request; the error stands when - // time runs out, and only an abort that is no timeout surfaces as an interrupt. + // server refused it for lacking one of the features `call` needs, and unchanged otherwise. The + // explaining probe is bounded by its own timeout and the client's signal; the error stands when + // the probe fails, and an interrupt surfaces as one. explainRefusal(call: CallRequirement, error: unknown): Promise; } export type WaitOptions = Pick; export type ProbeReadOptions = Pick; -// The caller's own bound on a request, kept for a failure the request ends in so that explaining it -// waits no longer than the request was allowed to. The client-wide signal is not the caller's: the -// probe honours it on its own. -interface RequestBudget { - signal: AbortSignal | undefined; - timeoutMs: number; -} - -const DEFAULT_BUDGET: RequestBudget = { signal: undefined, timeoutMs: DEFAULT_TIMEOUT_MS }; - interface Probed { properties: T; profile: ServerProfile; @@ -136,7 +116,6 @@ interface PreparedRequest { retries: number; idempotent: boolean; timeoutMs: number; - callerSignal: AbortSignal | undefined; cancelSignal: AbortSignal; } @@ -160,13 +139,13 @@ export interface ClientOptions { // own. A shape error read under a profile names that profile, the one its reader was chosen for. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; - // Called with the answer of every probe this client runs, so a caller that handed in a cached - // profile can keep its cache as current as the server's last answer. Only the newest probe started + // Called with the answer of every probe this client runs and the profile derived from it, so a + // caller that handed in a cached profile can keep its cache as current as the server's last answer. Only the newest probe started // reaches it, so it never sees an older answer after a newer one. It is called before the calls // waiting on the probe resume and is not awaited: a caller with slow work to do (a cache write) // keeps the promise and settles it itself. A hook that throws fails the probe, which is then not // kept. - onServerProbed?: (info: ServerInfo) => void; + onServerProbed?: (info: ServerInfo, profile: ServerProfile) => void; // Cancels every request this client makes; composed with the per-request `signal` and the // timeout. A process-level interrupt is the caller's to own and to hand over here. signal?: AbortSignal; @@ -186,7 +165,6 @@ export function createTransport(config: ClientCredentials, options: ClientOption let credential = config.credential; const knownSecrets = new Set(credentialSecrets(credential)); const redactionContext: RedactionContext = { knownSecrets }; - const failedRequestBudgets = new WeakMap(); // Single-flight: concurrent 401s (e.g. verify's parallel user+probe requests) must share one // refresh. The server rotates refresh tokens, so a second concurrent refresh would replay the @@ -292,12 +270,6 @@ export function createTransport(config: ClientCredentials, options: ClientOption // A cancellation during the backoff sleep surfaces as the timer's own rejection, which // carries none of the taxonomy; the signal's reason does. throwIfAborted(prepared.cancelSignal); - if (error instanceof HttpError) { - failedRequestBudgets.set(error, { - signal: prepared.callerSignal, - timeoutMs: prepared.timeoutMs, - }); - } throw error; } } @@ -352,7 +324,6 @@ export function createTransport(config: ClientCredentials, options: ClientOption retries: opts.retries ?? DEFAULT_MAX_RETRIES, idempotent: opts.idempotent ?? IDEMPOTENT_METHODS.has(method), timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - callerSignal: opts.signal, cancelSignal: combineAborts(opts.signal, options.signal), }; } @@ -369,7 +340,7 @@ export function createTransport(config: ClientCredentials, options: ClientOption const info = serverInfoFromProperties(properties); const profile = createServerProfile(info); if (newest === probed) { - options.onServerProbed?.(info); + options.onServerProbed?.(info, profile); inForce = profile; } return { properties, profile }; @@ -435,35 +406,22 @@ export function createTransport(config: ClientCredentials, options: ClientOption } } - // The server's own refusal is the verdict; the profile only names what was missing. A server that - // turns out to have every feature failed for some other reason, and its error stands. + // The server's own refusal is the verdict; the profile only names what was missing. async function explainRefusal(call: CallRequirement, error: unknown): Promise { - const features = callFeatures(call); - if (features.length === 0 || !isRefusal(error)) { + if (!mayBeRefusal(call, error)) { return error; } - // An `HttpError` rebuilt after its request (`chainRequestFailure`) is still the server's - // refusal, so it is explained under the budget every request starts with. - const budget = failedRequestBudgets.get(error) ?? DEFAULT_BUDGET; - const wait = combineAborts(budget.signal, AbortSignal.timeout(budget.timeoutMs)); let profile: ServerProfile; try { - profile = await verifiedServer({ signal: wait }); + profile = await verifiedServer(); } catch (probeError) { - // A probe that cannot answer leaves the refusal unexplained, and so does one a timeout cut - // short — this one's, or one the caller composed into its signal, as a wait schedule does. Any - // other abort is an interrupt, and the caller's. - const isInterrupt = probeError instanceof AbortError && !timedOut(wait); - if (probeError instanceof MetabaseError && !isInterrupt) { + if (probeError instanceof MetabaseError && !(probeError instanceof AbortError)) { return error; } throw probeError; } - const failure = checkFeatures(features, profile); - if (failure === null || pointsElsewhere(call, profile, error)) { - return error; - } - return new CapabilityError(failure, error); + const failure = explainedFailure(call, profile, error); + return failure === null ? error : new CapabilityError(failure, error); } const transport: Transport = { @@ -510,54 +468,6 @@ export function createTransport(config: ClientCredentials, options: ClientOption return transport; } -// A 404 for a missing row comes from a route the server serves, so no feature can be what it lacks. -function isRefusal(error: unknown): error is HttpError { - if (!(error instanceof HttpError) || error.kind === "resource-missing") { - return false; - } - return REFUSAL_STATUSES.has(error.status); -} - -// A 400 names the fields it rejected, and one naming a field the missing parameter features do not -// gate rejected the call for that field: an argument needing a feature the server lacks rode along -// with a value the server refuses anyway. A server lacking a feature the method itself needs cannot -// serve the call whatever field it names, so nothing points elsewhere; nor does a 400 that names no -// field at all. -function pointsElsewhere( - call: CallRequirement, - profile: ServerProfile, - refusal: HttpError, -): boolean { - if (refusal.status !== BAD_REQUEST_STATUS || checkFeatures(call.method, profile) !== null) { - return false; - } - const named = rejectedFields(refusal); - const gated = new Set( - call.parameters - .filter((parameter) => checkFeatures([parameter.feature], profile) !== null) - .flatMap((parameter) => parameter.fields), - ); - return named.some((field) => !gated.has(field)); -} - -// The top-level request field of every rejection the server listed; a nested one is keyed by its -// dot-joined path. -function rejectedFields(refusal: HttpError): string[] { - return [refusal.fieldErrors, refusal.specificFieldErrors].flatMap((errors) => - errors === null ? [] : Object.keys(errors).map(topLevelField), - ); -} - -function topLevelField(path: string): string { - const end = path.indexOf(FIELD_PATH_SEPARATOR); - return end === -1 ? path : path.slice(0, end); -} - -function timedOut(signal: AbortSignal): boolean { - const reason: unknown = signal.reason; - return reason instanceof DOMException && reason.name === "TimeoutError"; -} - // A probe that fails is no reason to fail a read the profile it would have replaced can serve; an // interrupt still ends the read. function fallBackTo(profile: ServerProfile): (error: unknown) => ServerProfile { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8b03f8c1..46d07ccc 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -99,7 +99,7 @@ export { } from "./version/requirements"; export type { MethodKey } from "./version/requirements"; export { checkFeatures } from "./version/requirement-check"; -export { CapabilityError, RequirementFailure, RequirementReason } from "./version/preflight-error"; +export { CapabilityError, RequirementFailure, RequirementReason } from "./version/capability-error"; export type { Page, PaginateOptions } from "./paginate"; export { pollUntil } from "./poll"; diff --git a/packages/client/src/resources/dependency.test.ts b/packages/client/src/resources/dependency.test.ts index a74e7afb..a0075333 100644 --- a/packages/client/src/resources/dependency.test.ts +++ b/packages/client/src/resources/dependency.test.ts @@ -13,7 +13,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/document.test.ts b/packages/client/src/resources/document.test.ts index fab32aaf..9442ecef 100644 --- a/packages/client/src/resources/document.test.ts +++ b/packages/client/src/resources/document.test.ts @@ -10,7 +10,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/erd.test.ts b/packages/client/src/resources/erd.test.ts index 7ec67f13..ce3db105 100644 --- a/packages/client/src/resources/erd.test.ts +++ b/packages/client/src/resources/erd.test.ts @@ -12,7 +12,7 @@ import { thrownBy, } from "../testing/fetch-capture"; import { PROBE_PATH } from "../version/probe"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { diff --git a/packages/client/src/resources/field.test.ts b/packages/client/src/resources/field.test.ts index 748b3056..02dc2175 100644 --- a/packages/client/src/resources/field.test.ts +++ b/packages/client/src/resources/field.test.ts @@ -9,7 +9,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index bb75afbb..2a8510d2 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -13,7 +13,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 4e1377f7..d5cf88ff 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -174,9 +174,9 @@ export function gitSyncResource(transport: Transport) { * pass `wait` to poll that task until it reaches a terminal status. A server already up to date * answers no task id, and there is then nothing to poll. A server that guards the tracked branch * refuses with 409 when `expected_branch` disagrees with the setting; left out, it is the tracked - * branch as read just before the request, which a server without the guard ignores. A guarding - * server granting remote sync is refused before the request when the setting is unset or hidden - * from the caller; any other server then gets no expected branch. `merge` folds the remote's + * branch as read just before the request, and the request is refused before the wire when the + * setting is unset or hidden from the caller. A server without the guard ignores the assertion, + * so it gets none unless the caller names one. `merge` folds the remote's * changes into local content by a three-way merge and keeps un-pushed local changes, where a * plain import refuses on a dirty instance. */ @@ -202,13 +202,13 @@ export function gitSyncResource(transport: Transport) { return { message, task_id: started.task_id, final: await settle(params.wait, options) }; } - // The tracked branch, sent on every server whenever the setting names one: a server guarding it - // requires it on an import and an export, and an older one ignores it or takes it as its own - // default. A guarding server granting remote sync would reject a request without it, so there an - // unset setting, or one hidden from a caller who is not an admin, is refused before the wire. - // Anywhere else the request goes without it: an older server picks its own default, and one - // without remote sync refuses in its own words. The merge check and the features both read the - // profile the branch read settles, so neither costs a request. + // The branch to name when the caller named none: the tracked one, read from the session + // properties, and only for a server that guards it. Such a server requires it on an import and an + // export, so there an unset setting, or one hidden from a caller who is not an admin, is refused + // before the wire. An older server reads the setting itself in the same request; it ignores an + // import's assertion and takes an export's branch as a switch, so naming one read a moment + // earlier would undo a switch another admin made in between. The merge check and the features + // both read the profile the branch read settles, so neither costs a request. async function syncBranch( given: string | undefined, merge: boolean | undefined, @@ -230,9 +230,9 @@ export function gitSyncResource(transport: Transport) { * Export Metabase's content to the remote. The endpoint queues a task and returns at once; pass * `wait` to poll that task until it reaches a terminal status. A server that guards the tracked * branch refuses with 409 when `branch` is not the tracked one; left out, it is the tracked - * branch as read just before the request, which is the default of a server without the guard. - * A guarding server granting remote sync is refused before the request when the setting is unset - * or hidden from the caller; any other server then gets no branch and picks its own. When the + * branch as read just before the request, and the request is refused before the wire when the + * setting is unset or hidden from the caller. A server without the guard exports to a named + * `branch` and tracks it from then on; left out, it gets none and exports to its own. When the * remote has moved past the last sync, a plain export ends in a `conflict` task where `merge` is * available, and is refused with 400 before any task elsewhere; `merge` folds the remote's * changes in by a three-way merge instead, and `force` overwrites them. @@ -441,11 +441,11 @@ function defaultBranch( tracked: RemoteSyncBranchProperty["remote-sync-branch"], features: Features, ): string | undefined { - if (typeof tracked === "string") { - return tracked; - } if (!features.remoteSync || !features.remoteSyncBranchGuard) { return undefined; } + if (typeof tracked === "string") { + return tracked; + } throw new ConfigError(tracked === null ? BRANCH_UNSET_MESSAGE : BRANCH_UNREADABLE_MESSAGE); } diff --git a/packages/client/src/resources/metric.test.ts b/packages/client/src/resources/metric.test.ts index d7b90903..715315d4 100644 --- a/packages/client/src/resources/metric.test.ts +++ b/packages/client/src/resources/metric.test.ts @@ -12,7 +12,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/moderation-review.test.ts b/packages/client/src/resources/moderation-review.test.ts index 3137fa87..e92ce412 100644 --- a/packages/client/src/resources/moderation-review.test.ts +++ b/packages/client/src/resources/moderation-review.test.ts @@ -10,7 +10,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/pulse.test.ts b/packages/client/src/resources/pulse.test.ts index 3f2a1d3b..0f6e5296 100644 --- a/packages/client/src/resources/pulse.test.ts +++ b/packages/client/src/resources/pulse.test.ts @@ -10,7 +10,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/replacement.test.ts b/packages/client/src/resources/replacement.test.ts index 93f26b7e..d3fa86d8 100644 --- a/packages/client/src/resources/replacement.test.ts +++ b/packages/client/src/resources/replacement.test.ts @@ -16,7 +16,7 @@ import { routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/revision.test.ts b/packages/client/src/resources/revision.test.ts index a30b0d80..3d9a6d06 100644 --- a/packages/client/src/resources/revision.test.ts +++ b/packages/client/src/resources/revision.test.ts @@ -9,7 +9,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/search.test.ts b/packages/client/src/resources/search.test.ts index a315c3cf..ea18fc5e 100644 --- a/packages/client/src/resources/search.test.ts +++ b/packages/client/src/resources/search.test.ts @@ -9,7 +9,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/table.test.ts b/packages/client/src/resources/table.test.ts index 1cbf4ef8..d6abe968 100644 --- a/packages/client/src/resources/table.test.ts +++ b/packages/client/src/resources/table.test.ts @@ -11,7 +11,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index f5dc5956..855fadcb 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -79,7 +79,7 @@ function listParamFeatures(params: TableListParams): FeatureName[] { // it travels in: `data-layer` on the list filter, `data_layer` on an update. function dataLayerFeatures( value: TableDataLayer | null | undefined, - field: string, + field: keyof TableListParams | keyof TableUpdateInput, ): ParameterRequirement[] { const isTierName = value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success; @@ -398,9 +398,8 @@ function assertCollectionWritten( if (requested === undefined || table.collection_id === requested) { return; } - const answered = table.collection_id; - const stayed = - answered === null || answered === undefined ? "in no collection" : `in collection ${answered}`; + const answered = table.collection_id ?? null; + const stayed = answered === null ? "in no collection" : `in collection ${answered}`; throw new PartialWriteError( `the server applied the rest of the update to table ${table.id} but not collection_id: the table stays ${stayed}, because this server does not move a table to a collection through an update`, { method: "PUT", path, field: "collection_id", requested, answered }, diff --git a/packages/client/src/resources/transform-dag-run.test.ts b/packages/client/src/resources/transform-dag-run.test.ts index 195ccee3..9c62cf43 100644 --- a/packages/client/src/resources/transform-dag-run.test.ts +++ b/packages/client/src/resources/transform-dag-run.test.ts @@ -10,7 +10,7 @@ import { routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/transform-inspector.test.ts b/packages/client/src/resources/transform-inspector.test.ts index 795cbce9..36c77711 100644 --- a/packages/client/src/resources/transform-inspector.test.ts +++ b/packages/client/src/resources/transform-inspector.test.ts @@ -12,7 +12,7 @@ import { routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/transform-python.test.ts b/packages/client/src/resources/transform-python.test.ts index ea8632ac..da2ac119 100644 --- a/packages/client/src/resources/transform-python.test.ts +++ b/packages/client/src/resources/transform-python.test.ts @@ -12,7 +12,7 @@ import { probeResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/transform-test.test.ts b/packages/client/src/resources/transform-test.test.ts index 86557770..d0dafbb4 100644 --- a/packages/client/src/resources/transform-test.test.ts +++ b/packages/client/src/resources/transform-test.test.ts @@ -17,7 +17,7 @@ import { TEST_USER_AGENT, thrownBy, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts index 0325afb4..21cfa87e 100644 --- a/packages/client/src/resources/transform.test.ts +++ b/packages/client/src/resources/transform.test.ts @@ -11,7 +11,7 @@ import { routeMissingResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { CapabilityError } from "../version/preflight-error"; +import { CapabilityError } from "../version/capability-error"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; diff --git a/packages/client/src/version/preflight-error.ts b/packages/client/src/version/capability-error.ts similarity index 98% rename from packages/client/src/version/preflight-error.ts rename to packages/client/src/version/capability-error.ts index 5b0db516..6e77ad80 100644 --- a/packages/client/src/version/preflight-error.ts +++ b/packages/client/src/version/capability-error.ts @@ -7,7 +7,7 @@ import { FEATURE_NAMES } from "./features"; // The one refusal status that is also an ordinary validation failure: a server lacking a parameter's // feature rejects the unknown parameter with it, and so does any server rejecting a bad value. -const BAD_REQUEST_STATUS = 400; +export const BAD_REQUEST_STATUS = 400; export const RequirementReason = z.enum(["version-too-old", "missing-token-feature"]); export type RequirementReason = z.infer; diff --git a/packages/client/src/version/refusal.ts b/packages/client/src/version/refusal.ts index 774f800d..eab0c4e8 100644 --- a/packages/client/src/version/refusal.ts +++ b/packages/client/src/version/refusal.ts @@ -1,9 +1,30 @@ import { InternalError } from "../errors"; +import { FIELD_PATH_SEPARATOR, HttpError } from "../http/errors"; import type { Transport } from "../http/transport"; -import type { CallRequirement, ParameterRequirement } from "./requirement-check"; +import { BAD_REQUEST_STATUS, type RequirementFailure } from "./capability-error"; +import type { ServerProfile } from "./profile"; +import { + type CallRequirement, + callFeatures, + checkFeatures, + type ParameterRequirement, +} from "./requirement-check"; import { isMethodKey, type MethodKey, methodRequirements } from "./requirements"; +const PAYMENT_REQUIRED_STATUS = 402; +const NOT_FOUND_STATUS = 404; + +// What a server answers for what it does not serve: unrouted, a verb the route lacks included +// (404); the premium refusal, which a gated prefix answers before routing (402); and a 400 for a +// value or a body shape an older route does not take. A 400 is also an ordinary validation failure, +// so it is the feature's refusal only when nothing in it points elsewhere. +const REFUSAL_STATUSES: ReadonlySet = new Set([ + BAD_REQUEST_STATUS, + PAYMENT_REQUIRED_STATUS, + NOT_FOUND_STATUS, +]); + // The features a call needs only because of the arguments it was handed, where a server without // them rejects the request rather than dropping the argument. export type ParameterFeatures = ( @@ -70,3 +91,69 @@ function callRequirement( const brought = parameters === undefined ? [] : parameters(...args); return { parameters: brought, method: methodRequirements(key) }; } + +/** + * Whether `error` may be the server refusing `call` for a feature it lacks. A call that needs no + * feature cannot be, and neither can a 404 for a missing row, which comes from a route the server + * serves. + */ +export function mayBeRefusal(call: CallRequirement, error: unknown): error is HttpError { + if (callFeatures(call).length === 0) { + return false; + } + if (!(error instanceof HttpError) || error.kind === "resource-missing") { + return false; + } + return REFUSAL_STATUSES.has(error.status); +} + +/** + * The feature `profile` lacks that explains `refusal` of `call`, or `null` when the server has every + * feature the call needs, so it failed for some other reason, or when the refusal points elsewhere. + */ +export function explainedFailure( + call: CallRequirement, + profile: ServerProfile, + refusal: HttpError, +): RequirementFailure | null { + const failure = checkFeatures(callFeatures(call), profile); + if (failure === null || pointsElsewhere(call, profile, refusal)) { + return null; + } + return failure; +} + +// A 400 names the fields it rejected, and one naming a field the missing parameter features do not +// gate rejected the call for that field: an argument needing a feature the server lacks rode along +// with a value the server refuses anyway. A server lacking a feature the method itself needs cannot +// serve the call whatever field it names, so nothing points elsewhere; nor does a 400 that names no +// field at all. +function pointsElsewhere( + call: CallRequirement, + profile: ServerProfile, + refusal: HttpError, +): boolean { + if (refusal.status !== BAD_REQUEST_STATUS || checkFeatures(call.method, profile) !== null) { + return false; + } + const named = rejectedFields(refusal); + const gated = new Set( + call.parameters + .filter((parameter) => checkFeatures([parameter.feature], profile) !== null) + .flatMap((parameter) => parameter.fields), + ); + return named.some((field) => !gated.has(field)); +} + +// The top-level request field of every rejection the server listed; a nested one is keyed by its +// dot-joined path. +function rejectedFields(refusal: HttpError): string[] { + return [refusal.fieldErrors, refusal.specificFieldErrors].flatMap((errors) => + errors === null ? [] : Object.keys(errors).map(topLevelField), + ); +} + +function topLevelField(path: string): string { + const end = path.indexOf(FIELD_PATH_SEPARATOR); + return end === -1 ? path : path.slice(0, end); +} diff --git a/packages/client/src/version/requirement-check.ts b/packages/client/src/version/requirement-check.ts index f7fcfb4d..30dc6938 100644 --- a/packages/client/src/version/requirement-check.ts +++ b/packages/client/src/version/requirement-check.ts @@ -3,7 +3,7 @@ import { missingTokenFeatureMessage, type RequirementFailure, versionTooOldMessage, -} from "./preflight-error"; +} from "./capability-error"; import { featureGap, type ServerProfile } from "./profile"; import { describeVersion } from "./tag"; diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index 9c2881f5..d5686184 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -8,7 +8,7 @@ import { isFileNotFoundError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; import type { FeatureName } from "@metabase/client/version/features"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; -import type { RequirementFailure } from "@metabase/client/version/preflight-error"; +import type { RequirementFailure } from "@metabase/client/version/capability-error"; import { checkFeatures } from "@metabase/client/version/requirement-check"; import { readBootstrapSync } from "./bootstrap-data"; diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index c6333a1d..cc2c5e91 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -191,7 +191,7 @@ describe("skills e2e", () => { [ `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, - `Skills are filtered by profile "default"'s server as probed at ${probedAt} (run \`mb auth list\` to probe it again).`, + `Skills are filtered by profile "default"'s server as last recorded at ${probedAt} (run \`mb auth list\` to probe it again).`, ].join("\n"), ); }); From e3c5010d52787c211df26ae9cb628c513a4ed963 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 19:50:18 -0400 Subject: [PATCH 04/11] align tests with the review fixes - git-sync: the tracked branch is sent only to a guarding server - runtime: a probe that agrees with the record leaves it untouched - transport: drop the cases for the removed per-call explanation budget - oauth: an unadvertised full-access scope is a v64 server's, not v63's --- packages/cli/src/commands/runtime.test.ts | 28 +++------------- packages/client/src/http/oauth.test.ts | 4 +-- packages/client/src/http/transport.test.ts | 33 ++----------------- .../client/src/resources/git-sync.test.ts | 31 ++++++++++------- 4 files changed, 28 insertions(+), 68 deletions(-) diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index d649e8af..3cbf2095 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -610,8 +610,9 @@ describe("defineMetabaseCommand", () => { expect(stderr).toEqual([NEWER_NOTICE]); }); - it("saves a probe that agrees with the record, dating it by the probe", async () => { + it("leaves the record alone when the probe agrees with it", async () => { await seedProbedProfile("default", probeAt(63, { content_verification: true })); + const seeded = await readProfileRecord("default"); vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({ @@ -625,17 +626,7 @@ describe("defineMetabaseCommand", () => { await runCommand(verifiedSearchCommand(), { rawArgs: [] }); expect(process.exitCode).toBe(0); - expect(await readProfileRecord("default")).toEqual( - reprobedRecord({ - at: REPROBED_AT, - edition: "oss", - version: { kind: "release", tag: "v0.63.0", major: 63, patch: 0 }, - date: null, - hash: null, - tokenFeatures: { content_verification: true }, - user: { id: 1, name: "Tester", isAdmin: true }, - }), - ); + expect(await readProfileRecord("default")).toEqual(seeded); }); }); @@ -729,6 +720,7 @@ describe("defineMetabaseCommand", () => { it("reports the error unchanged when the fresh probe agrees with the cache", async () => { await seedProbedProfile("default", probeAt(59)); + const seeded = await readProfileRecord("default"); vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({}), @@ -744,17 +736,7 @@ describe("defineMetabaseCommand", () => { "https://m.example.com/api/session/properties", ]); expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); - expect(await readProfileRecord("default")).toEqual( - reprobedRecord({ - at: REPROBED_AT, - edition: "oss", - version: { kind: "release", tag: "v0.59.0", major: 59, patch: 0 }, - date: null, - hash: null, - tokenFeatures: {}, - user: { id: 1, name: "Tester", isAdmin: true }, - }), - ); + expect(await readProfileRecord("default")).toEqual(seeded); }); it("reports the error unchanged when the re-probe itself fails", async () => { diff --git a/packages/client/src/http/oauth.test.ts b/packages/client/src/http/oauth.test.ts index 8a776646..bc6e085d 100644 --- a/packages/client/src/http/oauth.test.ts +++ b/packages/client/src/http/oauth.test.ts @@ -87,14 +87,14 @@ describe("oauth HTTP boundary", () => { expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toBeNull(); }); - it("accepts a discovery document that omits scopes_supported when the version grants the full-access scope", async () => { + it("accepts a discovery document that omits scopes_supported on a version that grants the full-access scope without advertising it", async () => { installFetch([ jsonResponse({ issuer: "https://mb.example.com", authorization_endpoint: "https://mb.example.com/oauth/authorize", token_endpoint: TOKEN_ENDPOINT, }), - jsonResponse({ version: { tag: "v0.63.1" } }), + jsonResponse({ version: { tag: "v0.64.0" } }), ]); expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toEqual({ issuer: "https://mb.example.com", diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 2b2e7651..3481bd1e 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -871,12 +871,9 @@ describe("createTransport.explainRefusal", () => { return { transport, urls, stop: () => controller.abort(new Error("test over")) }; } - async function failedRequest( - transport: Transport, - budget: Pick = {}, - ): Promise { + async function failedRequest(transport: Transport): Promise { return transport - .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { ...budget, retries: 0 }) + .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { retries: 0 }) .catch((caught: unknown) => caught); } @@ -958,32 +955,6 @@ describe("createTransport.explainRefusal", () => { ).toBe(refusal); }); - it("lets the refusal stand once the explaining probe outlasts the call's own timeout", async () => { - const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); - const refusal = await failedRequest(transport, { timeoutMs: 20 }); - - expect( - await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), - ).toBe(refusal); - stop(); - }); - - it("surfaces the caller's interrupt during the explaining probe rather than the refusal", async () => { - const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); - const controller = new AbortController(); - const refusal = await failedRequest(transport, { signal: controller.signal }); - - const pending = transport - .explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal) - .catch((caught: unknown) => caught); - controller.abort(new Error("caller interrupt")); - - const error = await pending; - assert(error instanceof AbortError, "expected AbortError"); - expect(error.message).toBe("caller interrupt"); - stop(); - }); - it("surfaces the client-wide interrupt during the explaining probe rather than the refusal", async () => { const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); const refusal = await failedRequest(transport); diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index 2a8510d2..2edf5e59 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -122,12 +122,19 @@ function sessionProperties(settings: object, profile: ServerProfile = SERVER): R return jsonResponse({ version: { tag: profile.version.tag }, ...settings }); } -// The session properties an admin reads on a server tracking `branch`. -function trackedBranchResponse(branch: string): Response { - return sessionProperties({ - "remote-sync-branch": branch, - "token-features": { remote_sync: true }, - }); +// The session properties an admin reads on a server tracking `branch`, by default one that guards +// it and so is sent it. +function trackedBranchResponse( + branch: string, + profile: ServerProfile = SERVER_WITH_PREFLIGHT, +): Response { + return sessionProperties( + { + "remote-sync-branch": branch, + "token-features": { remote_sync: true }, + }, + profile, + ); } function noContent(): Response { @@ -286,15 +293,15 @@ describe("git-sync resource wire requests", () => { expect(capture.calls.map((call) => call.body)).toEqual(['{"expected_branch":"dev"}']); }); - it("sends the tracked branch to a server before the branch guard, which ignores it", async () => { + it("sends no tracked branch to a server before the branch guard, which reads its own", async () => { const { mb, capture } = clientOver([ - trackedBranchResponse("main"), + trackedBranchResponse("main", SERVER), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), ]); await mb.gitSync.import(); - expect(capture.calls.map((call) => call.body)).toEqual([null, '{"expected_branch":"main"}']); + expect(capture.calls.map((call) => call.body)).toEqual([null, "{}"]); }); it("sends no expected branch where the setting is hidden on a server without remote sync, and explains its refusal", async () => { @@ -409,7 +416,7 @@ describe("git-sync resource wire requests", () => { ]); }); - it("explains a refused import from its own branch read, without probing again", async () => { + it("explains a refused import from its own branch read, without probing again or naming a branch", async () => { const { mb, capture } = clientOver([ sessionProperties( { "remote-sync-branch": "main", "token-features": { remote_sync: false } }, @@ -432,7 +439,7 @@ describe("git-sync resource wire requests", () => { }); expect(capture.calls.map((call) => [call.url, call.body])).toEqual([ [PROBE_URL, null], - ["https://mb.example.com/metabase/api/ee/remote-sync/import", '{"expected_branch":"main"}'], + ["https://mb.example.com/metabase/api/ee/remote-sync/import", "{}"], ]); }); @@ -606,7 +613,7 @@ describe("git-sync resource wire requests", () => { it("explains a refused preflight of the tracked branch by the branch read, in two requests", async () => { const { mb, capture } = clientOver( - [trackedBranchResponse("main"), routeMissingResponse()], + [trackedBranchResponse("main", SERVER), routeMissingResponse()], SERVER_WITH_PREFLIGHT, ); From 9065a31bc0ce696280d92717db3ff40c52ff98ff Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 20:02:23 -0400 Subject: [PATCH 05/11] drop the --skip-preflight shim --- README.md | 17 ++++++++--------- packages/cli/src/commands/flags.ts | 7 ------- packages/cli/src/commands/runtime.ts | 7 +------ packages/cli/src/core/env.ts | 2 -- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e2eb4922..db9e863a 100644 --- a/README.md +++ b/README.md @@ -2349,15 +2349,14 @@ Exit codes: `0` success (a skill the server cannot use is reported, not refused) ## Environment variables -| Variable | Effect | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `MB_URL` | Default URL for `auth login` and config resolution. | -| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | -| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | -| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | -| `MB_CLI_SKIP_PREFLIGHT` | Deprecated, no effect beyond a warning, like `--skip-preflight`: every call goes to the server, which decides whether it is allowed. | +| Variable | Effect | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `MB_URL` | Default URL for `auth login` and config resolution. | +| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index c4eed0d2..c6926eef 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -72,11 +72,4 @@ export const profileFlag = { export const connectionFlags = { url: { type: "string", description: "Metabase URL" }, apiKey: { type: "string", description: "API key", alias: "api-key" }, - // Accepted so a script written for a release that checked the server before sending still runs; - // the server decides every call, so it only warns. - skipPreflight: { - type: "boolean", - description: "Deprecated, no effect: the server decides every call", - alias: "skip-preflight", - }, } as const; diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 969f6fc0..0aefa92a 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -29,7 +29,7 @@ import { type ConfigFlags, type ResolvedConfig, } from "../core/config"; -import { consumeLegacyEnvWarnings, ENV_SKIP_PREFLIGHT, readEnv } from "../core/env"; +import { consumeLegacyEnvWarnings } from "../core/env"; import { USER_AGENT } from "../core/user-agent"; import { reportError } from "../output/error"; import { warn } from "../output/notice"; @@ -80,9 +80,6 @@ export function defineMetabaseCommand( try { reportFormat = resolveReportFormat(commonArgs, rawArgs, def.args); assertArgv(rawArgs, def.args); - if (args["skipPreflight"] === true || readEnv(ENV_SKIP_PREFLIGHT) !== undefined) { - warn(SKIP_PREFLIGHT_NOTICE); - } const ctx = resolveCommonFlags(commonArgs); let cachedConfig: ResolvedConfig | null = null; const getResolvedConfig = async (): Promise => { @@ -156,8 +153,6 @@ export function defineMetabaseCommand( return cmd; } -const SKIP_PREFLIGHT_NOTICE = `--skip-preflight and ${ENV_SKIP_PREFLIGHT} have no effect and will be removed: every call goes to the server, which decides whether it is allowed.`; - function deriveRequirements(methods: readonly MethodKey[]): CommandRequirements { const features = [...new Set(methods.flatMap((key) => methodRequirements(key)))]; return { methods, features }; diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts index 923f274a..043ed5d0 100644 --- a/packages/cli/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -5,8 +5,6 @@ export const ENV_URL = "MB_URL"; export const ENV_API_KEY = "MB_API_KEY"; export const ENV_PROFILE = "MB_PROFILE"; export const ENV_VERBOSE = "MB_VERBOSE"; -// Read only to warn that it no longer does anything. -export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; From 9def20e62532b853929b9c987e42bfd46234536d Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 6 Oct 2026 23:12:16 -0400 Subject: [PATCH 06/11] address reviews of server-authoritative gates; restore a narrow --skip-preflight - explainer gains refuse / refuseAfterReading: a whole-method refusal before the wire is declared where the method is exported, from METHOD_REQUIREMENTS; explain-guard requires exactly one wrapper per gated method - explainRefusal moves into version/refusal.ts; the fake client explains for real - the shared probe parses SessionProperties only and each caller applies its own reader, naming the version that response reported; a good answer is no longer discarded when a later probe fails; probe() runs on the probe budget - pivoted exports read their setting through transport.probe - git-sync: branch reads cost one request and judge their own read; status reads the session properties once with a fixed error precedence; an import sends its branch assertion to every server with remote sync - CLI waits for in-flight probes before exit and says when a refreshed profile could not be saved - --skip-preflight / MB_CLI_SKIP_PREFLIGHT skip only the rule-based checks made before sending, through one decision point in the transport; each skipped check warns once, an unreachable check lets the call go on, and a refusal made before sending names the flag - search declares verified so a server's 402 is explained - HTTP status constants live in http/errors.ts; HttpError.serverMessage; PartialWriteDetail distinguishes an unreported collection - docs and skills describe the end state; the e2e version lane expects the write remedy for a POST --- .claude/skills/add-resource-command/SKILL.md | 4 +- .claude/skills/review/SKILL.md | 8 +- CLAUDE.md | 2 +- README.md | 103 +++--- docs/architecture.md | 10 +- packages/cli/src/commands/context.test.ts | 3 + packages/cli/src/commands/context.ts | 4 + .../cli/src/commands/delete-runtime.test.ts | 1 + packages/cli/src/commands/flags.ts | 6 + packages/cli/src/commands/git-sync/status.ts | 23 +- packages/cli/src/commands/query.ts | 14 +- packages/cli/src/commands/runtime.ts | 98 +++-- packages/cli/src/commands/search.ts | 2 +- .../transform/collection-namespace.ts | 4 +- .../cli/src/core/auth/server-summary.test.ts | 23 +- packages/cli/src/core/auth/server-summary.ts | 25 +- packages/cli/src/core/env.ts | 1 + packages/cli/src/output/error.test.ts | 5 +- packages/cli/src/output/error.ts | 20 +- packages/cli/src/output/help.test.ts | 3 + packages/cli/src/output/notice.ts | 19 + packages/client/README.md | 18 +- packages/client/src/client.ts | 1 + packages/client/src/errors.ts | 24 +- packages/client/src/http/errors.ts | 57 +-- packages/client/src/http/transport.test.ts | 155 -------- packages/client/src/http/transport.ts | 350 ++++++++++++------ packages/client/src/index.ts | 8 +- packages/client/src/resources/card.ts | 6 +- packages/client/src/resources/dataset.ts | 6 +- packages/client/src/resources/dependency.ts | 3 +- packages/client/src/resources/field.ts | 8 +- .../client/src/resources/git-sync.test.ts | 8 +- packages/client/src/resources/git-sync.ts | 142 ++++--- packages/client/src/resources/pivot-export.ts | 13 +- packages/client/src/resources/revision.ts | 3 +- packages/client/src/resources/search.ts | 26 +- packages/client/src/resources/table.test.ts | 8 +- packages/client/src/resources/table.ts | 49 ++- packages/client/src/testing/fake-client.ts | 21 +- packages/client/src/testing/fetch-capture.ts | 4 +- .../client/src/version/capability-error.ts | 28 +- .../client/src/version/explain-guard.test.ts | 42 +-- packages/client/src/version/probe.ts | 32 +- packages/client/src/version/refusal.test.ts | 264 +++++++++++-- packages/client/src/version/refusal.ts | 141 ++++++- .../client/src/version/requirement-check.ts | 55 ++- tests/e2e/field.e2e.test.ts | 3 +- tests/e2e/search.e2e.test.ts | 3 +- tests/e2e/subscription.e2e.test.ts | 3 +- tests/e2e/table.e2e.test.ts | 9 +- tests/e2e/version.e2e.test.ts | 7 +- 52 files changed, 1237 insertions(+), 638 deletions(-) diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index c42406a2..6e4d08aa 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -214,7 +214,7 @@ The nine conventions every method follows: `ListResult` = `{ data, total }`. 7. A string path parameter always goes through `encodeURIComponent`. 8. Every method carries the endpoint's description as a doc comment. -9. Every method has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), keyed exactly as the method is reached on the client: `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported through `explain("", method)`, where `const { explain, explainWalk } = explainer(transport, "")` from `packages/client/src/version/refusal.ts` opens the factory, and a `Pages` method answering an async iterable through `explainWalk("", method)`, so the server's rejection reads as the feature it lacks; nothing refuses on the table's word, and `explain-guard.test.ts` fails a gated method exported unwrapped. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is sent and named through `explain`'s third argument, a small named function beside the method that takes the method's arguments and returns `{ feature, fields }` entries — the feature, and the request `fields` the argument travels in as the server names them in a 400; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire with `await transport.requireFeatures([...], options)`, with a comment saying what the server would do. `requirements.test.ts` fails a client method the table lacks and a table key no method answers to. +9. Every method has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), keyed exactly as the method is reached on the client: `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported through `explain("", method)`, where `const { explain, explainWalk, refuse, refuseAfterReading } = explainer(transport, "")` from `packages/client/src/version/refusal.ts` opens the factory (take only the wrappers the resource uses), and a `Pages` method answering an async iterable through `explainWalk("", method)`, so the server's rejection reads as the feature it lacks; nothing refuses on the table's word, and `explain-guard.test.ts` fails a gated method not wrapped exactly once. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is sent and named through `explain`'s third argument, a small named function beside the method that takes the method's arguments and returns `{ feature, fields }` entries — the feature, and the request `fields` the argument travels in as the server names them in a 400; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire with `await transport.requireFeatures([...], options)` (a check by value reads `transport.preflightServer(options)` and refuses through `transport.refuseBeforeSending`, never by throwing), with a comment saying what the server would do. A method every call of which such a server would answer wrongly without a word (its whole answer misread, or its whole body dropped) is exported through `refuse("", method)` instead of `explain`, with that comment beside it in the factory's return: `refuse` runs the method's whole `METHOD_REQUIREMENTS` entry through `transport.requireFeatures` before the call, so the method must end in optional `RequestOptions`. Such a method answering from a setting the session properties carry is exported through `refuseAfterReading("", reader, method)` instead, where `reader` extends `SessionProperties` with the setting and `method` is a pure function of the properties read: the wrapper reads them afresh through `transport.probe`, judges the entry against the profile that read settled, and exposes a method taking only optional `RequestOptions`, so the call costs one request. `requirements.test.ts` fails a client method the table lacks and a table key no method answers to. Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten. @@ -509,7 +509,7 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and each gated method exported through the `explain` / `explainWalk` of `explainer(transport, "")`. +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, a `METHOD_REQUIREMENTS` entry per method, and each gated method exported through exactly one of the `explain` / `explainWalk` / `refuse` / `refuseAfterReading` of `explainer(transport, "")`. - [ ] **Requirements** — every method has its `"."` entry in `METHOD_REQUIREMENTS`; a gated route has a behaviour-named rule in `FEATURE_RULES` verified against the Metabase release branches; a shape that differs by generation has its `WireV` + converter + exported reader, with converter unit tests per generation. - [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. - [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 66199e5c..48bd7006 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -37,9 +37,11 @@ packages/client/src/ auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts version/ tag.ts, probe.ts, known-range.ts (KNOWN_RANGE), profile.ts (ServerProfile), features.ts (FEATURE_RULES — the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS), - requirement-check.ts, capability-error.ts + requirement-check.ts (checkFeatures), capability-error.ts, + refusal.ts (explainer → explain / explainWalk / refuse / refuseAfterReading, explainRefusal) domain/ Metabase API resource Zod schemas + inferred types - errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage + errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage, + isNonInterruptFailure list.ts ListResult = { data, total } — what a one-response list method returns url.ts normalizeUrl, displayUrl, assertEndpointOrigin — the only home for new URL() json.ts parseJson — the only home for JSON.parse @@ -110,7 +112,7 @@ A new or changed method comes with its wire test in `packages/client/src/resourc `packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison or a literal major used as a decision anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. -Every method in `resources/*.ts` has a matching `METHOD_REQUIREMENTS` entry under its own namespace, strictest feature first (`requirements.test.ts`). A gated method not exported through the resource's `explain` or `explainWalk` (`explainer(transport, "")`) is FAIL (`explain-guard.test.ts`). A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through `transport.requireFeatures` or a value check on `verifiedServer`, with a comment saying what the server would do; a parameter the server rejects belongs in `explain`'s parameter callback, with the request fields it travels in. A method that chooses what to send from `transport.server()`'s `features` is FAIL — that profile may be a stale cache; send what every server accepts, or decide against `verifiedServer`. A method without an entry, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. +Every method in `resources/*.ts` has a matching `METHOD_REQUIREMENTS` entry under its own namespace, strictest feature first (`requirements.test.ts`). A gated method not exported through exactly one of the resource's `explain`, `explainWalk`, `refuse` or `refuseAfterReading` (`explainer(transport, "")`) is FAIL (`explain-guard.test.ts`). A method refusing before the wire is FAIL unless the refusal guards a parameter an unsupporting server would drop or rewrite without a word, asked through `transport.requireFeatures` or a value check on `transport.preflightServer()` whose refusal is thrown through `transport.refuseBeforeSending` (a refusal thrown directly, or a value check reading `verifiedServer`, is FAIL: the caller could not skip it), or the method is one whose every call such a server would answer wrongly without a word, exported through `refuse` and ending in optional `RequestOptions`, or through `refuseAfterReading` with a reader when it answers from a setting the session properties carry (a `refuse`-wrapped method that reads the session properties itself pays a second request); either way with a comment saying what the server would do; a parameter the server rejects belongs in `explain`'s parameter callback, with the request fields it travels in. A method that chooses what to send from `transport.server()`'s `features` is FAIL — that profile may be a stale cache; send what every server accepts, or decide against `verifiedServer`. A method without an entry, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. Version-driven drift is a `WireV` schema plus a converter, selected by a feature and handed to `requestParsed` through an exported `Schema(features): z.ZodType` reader. The canonical schema is the newest server's shape; a field an older server cannot report is `.nullable()`. FAIL: `.optional()` meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a `z.union` of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in `packages/cli/skill-data/` where a `requires:` frontmatter key or a `` … `` fence belongs (markers on their own lines, never inside a table). diff --git a/CLAUDE.md b/CLAUDE.md index 53983b78..10047cfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Every resource lives in `packages/client/src/domain/.ts` and - `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". - Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. - Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE.min … max + 1` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. -- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through `explain("", fn, parameters?)`, or `explainWalk(…)` for a method answering an async iterable, from `const { explain, explainWalk } = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace; `explain-guard.test.ts` fails a gated method exported unwrapped, except the methods it names in `REFUSED_BEFORE_THE_REQUEST`, which refuse before the wire (below). When the server rejects the call, `transport.explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError` that carries the server's own answer. A parameter an older server rejects is named through `parameters`, a small named function beside the method returning `{ feature, fields }` entries: the feature and the request fields the argument travels in; a 400 naming a field outside those is the server's own error, not the feature's refusal. Only a call an unsupporting server would answer wrongly without a word — a parameter it drops or rewrites, or a method whose answer it would read differently — is refused before the wire, through `transport.requireFeatures` (or a value check on `transport.verifiedServer()`), never against the cached profile; say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature; where an older server acts on it instead (a git-sync export's `branch` switches the tracked branch), send it only where the feature holds. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. +- Every resource method declares its features in `version/requirements.ts` (strictest first); `requirements.test.ts` asserts the table names exactly the methods the client exposes. The server decides what it allows, so nothing refuses on the table's word: a resource exports each gated method through exactly one of `explain("", fn, parameters?)`, `explainWalk(…)` for a method answering an async iterable, `refuse("", fn)` for a method refused before the wire (below), or `refuseAfterReading("", reader, fn)` for one refused by the session-properties read it answers from, from `const { explain, explainWalk, refuse, refuseAfterReading } = explainer(transport, "")` (`version/refusal.ts`), which type-checks each name against its namespace; `explain-guard.test.ts` fails a gated method not wrapped exactly once. When the server rejects an explained call, `explainRefusal` reads a profile the client probed itself and turns the rejection into a `CapabilityError` that carries the server's own answer. A parameter an older server rejects is named through `parameters`, a small named function beside the method returning `{ feature, fields }` entries: the feature and the request fields the argument travels in; a 400 naming a field outside those is the server's own error, not the feature's refusal. Only a call an unsupporting server would answer wrongly without a word — a parameter it drops or rewrites, or a method whose answer it would read differently — is refused before the wire, never against the cached profile: a whole method through `refuse`, which runs its `METHOD_REQUIREMENTS` entry through `transport.requireFeatures` and so must end in optional `RequestOptions`, or, when it answers from a setting the session properties carry, through `refuseAfterReading`, which reads them afresh through `transport.probe`, judges the same entry against the profile that read settled, and hands them to a method taking only them, so the call costs one request; a parameter through `transport.requireFeatures` (or a value check on `transport.preflightServer()`) inside the method. Every such refusal is thrown through `transport.refuseBeforeSending`, never directly, and a value check reads its profile through `preflightServer`, because the caller may skip these checks (`onPreflightSkipped`) and the transport alone decides that. Say in a comment what the server does with it. Where an older server ignores a key a newer one requires, send it on every server rather than choosing by feature; where an older server acts on it instead (a git-sync export's `branch` switches the tracked branch), send it only where the feature holds. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. - List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. diff --git a/README.md b/README.md index db9e863a..af74bf2e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Every command declares the client methods it calls, and each method names the se - the server is older than the command's minimum version, or - the command needs a premium feature (e.g. `remote_sync`, `content_translation`, `library`) that isn't enabled. -A license activated or a server upgraded after `mb auth login` takes effect on the next command; nothing has to be refreshed first. The one refusal issued before a request is for a parameter a server without the feature would drop without a word, answering as if it had never been sent — `table list --unused-only` without the `dependencies` feature lists every table. Such a parameter is judged against a probe taken in the same run, never the cached one, so a command passing it costs one request more than it would otherwise, even when the cache grants the feature: a cache can be wrong in that direction too, after a license lapses. +A license activated or a server upgraded after `mb auth login` takes effect on the next command; nothing has to be refreshed first. A request is refused before it is sent only where a server without the feature would answer wrongly without a word: a parameter it would drop or rewrite (`table list --unused-only` without the `dependencies` feature lists every table), or a call whose answer it would misread (git-sync's synced collections). The tracked branch is read from the same probe that judges it, and refused there before it is answered. The notes on `table list`, `table update`, `table bulk-edit`, `field update`, `field set-sensitivity`, `card query`, `query`, `subscription create`, `search`, `git-sync status`, `git-sync import`, `git-sync export` and `git-sync export-preflight` name the parameters and calls refused this way. Such a refusal is judged against a probe taken in the same run, never the cached one, so the command costs at most one request more than it would otherwise, even when the cache grants the feature: a cache can be wrong in that direction too, after a license lapses. + +These refusals are the CLI's own judgement, read from its table of which release and which premium feature brings each feature, so a feature backported to an older release, or a rule the table gets wrong, is refused although the server would serve it. `--skip-preflight`, taken by every command that takes `--url` and `--api-key` (all but `auth list`, `auth status`, `auth logout`, `skills`, `uuid` and `upgrade`, none of which makes these checks), or `MB_CLI_SKIP_PREFLIGHT=1` in the environment, skips them, sends the request anyway and lets the server answer; a refusal of a feature the server lacks names the flag. Each check it lets through prints one stderr warning naming what was skipped, because a server that truly lacks the feature may ignore or rewrite what it does not support and answer without saying so; when the server cannot be checked at all, the request goes out unchecked with one warning per run. It skips nothing else: input the CLI rejects, a refusal read from the server's own settings (pivoted exports turned off, an unset or unreadable tracked branch), and a refusal the server answers itself stand as they are. `mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A release above the window is read as the major after the newest known one: its additions pass through, and one stderr notice per run points at `mb upgrade`. A development build is `development`, ahead of every release, and prints no notice; a server below the window is `older-than-known`, still evaluated at its own major, with a notice naming the oldest major the CLI supports. A response the CLI cannot parse under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. @@ -473,7 +475,7 @@ mb table list --term order --can-query --json mb table list --data-layer hidden --orphan-only --json ``` -`--db-id` narrows the list on the client; every other flag is applied by the server. A filter the server is too old to honour is refused before any request (exit 2) rather than dropped. +`--db-id` narrows the list on the client; every other flag is applied by the server. `--unused-only`, `--published-only`, `--can-query`, `--can-write` and `--include-transform-targets` are refused before the request (exit 2) on a server without the feature their row names, judged against a probe of the server taken in the same run, because such a server drops a filter it does not know without a word. | Flag | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | @@ -564,7 +566,7 @@ mb table discard-values 42 ### `mb table bulk-edit` -Set the same metadata on every table a selector picks out (`POST /api/data-studio/table/edit`, Metabase v59+). The body selects tables with any of `table_ids`, `database_ids`, and `schema_ids` (each schema id is `":"`, e.g. `1:public`; the selectors are unioned) and sets any of `data_authority`, `data_source`, `data_layer`, `entity_type`, `owner_email`, `owner_user_id` on all of them. A configured `data_authority` cannot be set back to `unconfigured`, and `data_source` never moves to or from `metabase-transform`. A selected table that breaks either rule fails the call: before Metabase 64 no table is edited, while on 64 the selected tables Metabase held no user edits for may already carry the new values. Before Metabase 64, `null` clears a field, `null` for `data_authority` is refused before any request, and the next scheduled analysis overwrites `entity_type` with the type it derives from the table name. On Metabase 64, `null` withdraws the edit: `data_source` and `data_layer` read back empty, while `entity_type`, `owner_email`, `owner_user_id`, and `data_authority` fall back to the values Metabase keeps for the table, which are the name-derived entity type and the owner and data authority from before the upgrade (no owner and `unconfigured` for a table published then or created since). A body that selects nothing or sets nothing is refused before any request. Pass the body via `--body`, `--file`, or stdin (exactly one). The server answers `{}` whether or not a selector matched a table, so the command returns the accepted request restated, `{ accepted: true, ...body }`, and cannot say which tables were edited. Metabase 58 serves the same edit only on Enterprise, at `/api/ee/data-studio/table/edit` with the medallion layer names; the command does not reach it, because a method's requirements cannot say "58 with the `data-studio` token, or 59 and later", and the medallion names do not map onto the `final` / `internal` / `hidden` layers the body takes. +Set the same metadata on every table a selector picks out (`POST /api/data-studio/table/edit`, Metabase v59+). The body selects tables with any of `table_ids`, `database_ids`, and `schema_ids` (each schema id is `":"`, e.g. `1:public`; the selectors are unioned) and sets any of `data_authority`, `data_source`, `data_layer`, `entity_type`, `owner_email`, `owner_user_id` on all of them. A configured `data_authority` cannot be set back to `unconfigured`, and `data_source` never moves to or from `metabase-transform`. A selected table that breaks either rule fails the call: before Metabase 64 no table is edited, while on 64 the selected tables Metabase held no user edits for may already carry the new values. Before Metabase 64, `null` clears a field, `null` for `data_authority` is refused before the request (exit 2), judged against a probe of the server taken in the same run, and the next scheduled analysis overwrites `entity_type` with the type it derives from the table name. On Metabase 64, `null` withdraws the edit: `data_source` and `data_layer` read back empty, while `entity_type`, `owner_email`, `owner_user_id`, and `data_authority` fall back to the values Metabase keeps for the table, which are the name-derived entity type and the owner and data authority from before the upgrade (no owner and `unconfigured` for a table published then or created since). A body that selects nothing or sets nothing is refused before any request. Pass the body via `--body`, `--file`, or stdin (exactly one). The server answers `{}` whether or not a selector matched a table, so the command returns the accepted request restated, `{ accepted: true, ...body }`, and cannot say which tables were edited. Metabase 58 serves the same edit only on Enterprise, at `/api/ee/data-studio/table/edit` with the medallion layer names; the command does not reach it, because a method's requirements cannot say "58 with the `data-studio` token, or 59 and later", and the medallion names do not map onto the `final` / `internal` / `hidden` layers the body takes. ```sh mb table bulk-edit --body '{"table_ids":[42,43],"owner_email":"dba@example.com"}' @@ -614,7 +616,7 @@ mb field summary 100 --json ### `mb field update ` -Patch a field (`PUT /api/field/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `semantic_type`, `coercion_strategy`, `fk_target_field_id`, `visibility_type`, `has_field_values`, `settings`, `nfc_path`, `json_unfolding`. Pass the body via `--body`, `--file`, or stdin. +Patch a field (`PUT /api/field/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `semantic_type`, `coercion_strategy`, `data_sensitivity`, `fk_target_field_id`, `visibility_type`, `has_field_values`, `settings`, `nfc_path`, `json_unfolding`. Pass the body via `--body`, `--file`, or stdin. A `data_sensitivity` label (see `field set-sensitivity`) is refused before the request on a server older than Metabase v64 (exit 2), judged against a probe of the server taken in the same run, because such a server drops the key without a word. ```sh mb field update 100 --body '{"description":"customer email","semantic_type":"type/Email"}' @@ -648,7 +650,7 @@ mb field remapping 100 101 20 --json ### `mb field set-sensitivity ` -Label a field's data sensitivity by hand (`PUT /api/field/:id` with `data_sensitivity`). A label set here is a person's call that the server's classifier never overwrites; `none` withdraws it, and the field then shows the label the classifier wrote, if it wrote one (the classifier is off unless the server enables it, and a label it wrote stays after it is switched off), else none. Labels, most severe first: `SEC_KEY`, `SYS_TELEMETRY`, `PHI`, `BIO_GEN`, `PCI_FIN`, `SENS_PERS`, `PII`, `CORP_IP`, `BIZ_CONF`, `PUBLIC`. Answers the field with its `data_sensitivity`; `mb field get ` reads it back. Requires Metabase v64 or newer. +Label a field's data sensitivity by hand (`PUT /api/field/:id` with `data_sensitivity`). A label set here is a person's call that the server's classifier never overwrites; `none` withdraws it, and the field then shows the label the classifier wrote, if it wrote one (the classifier is off unless the server enables it, and a label it wrote stays after it is switched off), else none. Labels, most severe first: `SEC_KEY`, `SYS_TELEMETRY`, `PHI`, `BIO_GEN`, `PCI_FIN`, `SENS_PERS`, `PII`, `CORP_IP`, `BIZ_CONF`, `PUBLIC`. Answers the field with its `data_sensitivity`; `mb field get ` reads it back. Requires Metabase v64 or newer, and is refused before the request on an older server (exit 2), judged against a probe of the server taken in the same run, because a server without the column drops the label and answers the field as if it had never been sent. ```sh mb field set-sensitivity 100 PII @@ -756,14 +758,14 @@ mb card query 1 --export-format xlsx > export.xlsx mb card query 1 --parameters '[{"type":"category","value":"A","target":["variable",["template-tag","c"]]}]' ``` -| Flag | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--export-format ` | Stream the export instead of the JSON envelope. One of `csv`, `json`, `xlsx`. | -| `--parameters ` | JSON array of Metabase parameter objects (the same shape Metabase POSTs from a dashboard). | -| `--limit ` | Cap rows kept in the JSON envelope. Refused with `--export-format`. | -| `--format-rows` | Streamed exports only: format values as Metabase displays them, the card's column settings included (default `false`). Refused without `--export-format`. | -| `--pivot-results` | With `--export-format csv` or `xlsx`: lay a pivot question's rows out as the pivot, with its subtotals (default `false`). Refused when the server has pivoted exports turned off (the `enable-pivoted-exports` setting). | -| `--csv-include-bom` | With `--export-format csv`: open the file with a UTF-8 byte order mark, so Excel reads it as UTF-8 (default `false`). Metabase v63+. | +| Flag | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--export-format ` | Stream the export instead of the JSON envelope. One of `csv`, `json`, `xlsx`. | +| `--parameters ` | JSON array of Metabase parameter objects (the same shape Metabase POSTs from a dashboard). | +| `--limit ` | Cap rows kept in the JSON envelope. Refused with `--export-format`. | +| `--format-rows` | Streamed exports only: format values as Metabase displays them, the card's column settings included (default `false`). Refused without `--export-format`. | +| `--pivot-results` | With `--export-format csv` or `xlsx`: lay a pivot question's rows out as the pivot, with its subtotals (default `false`). Refused when the server has pivoted exports turned off (the `enable-pivoted-exports` setting). | +| `--csv-include-bom` | With `--export-format csv`: open the file with a UTF-8 byte order mark, so Excel reads it as UTF-8 (default `false`). Metabase v63+; refused before the request on an older server (exit 2), judged against a probe of the server taken in the same run, because an older server drops the parameter and exports the file without the mark. | ### `mb card alerts ` @@ -1446,7 +1448,7 @@ The compact view returns `id`, `name`, `dashboard_id`, `collection_id`, `archive ### `mb subscription create` -The body needs `name`, `dashboard_id`, `cards`, and `channels`. Non-empty `parameters` (filter values for this subscription only) need the `dashboard_subscription_filters` premium feature and are refused before any request without it (exit 2), because a server without it stores them but sends the dashboard's own filter values; the same holds on `update`. +The body needs `name`, `dashboard_id`, `cards`, and `channels`. Non-empty `parameters` (filter values for this subscription only) need the `dashboard_subscription_filters` premium feature and are refused before the request without it (exit 2), judged against a probe of the server taken in the same run, because a server without it stores them but sends the dashboard's own filter values; the same holds on `update`. Each channel names a `channel_type` (`email`, `slack`, `http`) and a `schedule_type` (`hourly`, `daily`, `weekly`, `monthly`) plus the fields that schedule needs: `daily` needs `schedule_hour` (0–23); `weekly` also needs `schedule_day` (`mon`…`sun`); `monthly` also needs `schedule_frame` (`first`, `mid`, `last`). Email recipients are `{"email":"a@b.com"}` or `{"id":}`; Slack targets a channel with `"details":{"channel":"#general"}`. A channel is `enabled` unless you say otherwise. @@ -1921,19 +1923,19 @@ mb search --collection 12 --created-by 3,7 --include-dashboard-questions --json mb search revenue --search-native-query --include-metadata --full --json ``` -| Flag | Description | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--models`, `-m` | Comma-separated model filter: `card,dataset,metric,dashboard,collection,database,table,segment,measure,document,action,transform,indexed-entity`. | -| `--archived` | Include archived items only. | -| `--limit` | Max results to return (default `20` — `search` is the one list verb with its own default). | -| `--offset` | Where the window starts, applied by the server (default `0`). | -| `--db-id` | Restrict to items on a given database id. | -| `--verified` | Only verified content. Needs the `content_verification` premium feature; refused before any request without it, since a server without it ignores the filter. | -| `--collection` | Restrict to one collection by id: the collection's own row, its subcollections and the content filed under them. Segments, measures and transforms never match, tables only once published to the Library, and a transforms collection is not searchable at all. Questions saved into a dashboard match only with `--include-dashboard-questions`. | -| `--created-by` | Comma-separated user ids; matches items created by any of them. Only kinds that record a creator can match — cards, models, metrics, dashboards, actions, documents, and measures from Metabase 60 — so collections, tables, databases, segments, transforms and indexed entities drop out. | -| `--search-native-query` | Also match native query text. Narrows the result to the models that carry a query — cards, models, metrics, actions, transforms — so dashboards, collections and tables drop out. | -| `--include-metadata` | Attach the `result_metadata` of each card, model and metric row. Only `--json` with `--full`, or `--fields` naming `result_metadata`, prints it, so the flag needs one of them. | -| `--include-dashboard-questions` | Also match questions saved into a dashboard, which search leaves out by default. | +| Flag | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--models`, `-m` | Comma-separated model filter: `card,dataset,metric,dashboard,collection,database,table,segment,measure,document,action,transform,indexed-entity`. | +| `--archived` | Include archived items only. | +| `--limit` | Max results to return (default `20` — `search` is the one list verb with its own default). | +| `--offset` | Where the window starts, applied by the server (default `0`). | +| `--db-id` | Restrict to items on a given database id. | +| `--verified` | Only verified content. Needs the `content_verification` premium feature; refused before the request without it (exit 2), judged against a probe of the server taken in the same run, since a server with `official_collections` but not `content_verification` takes the filter without applying it; a server with neither refuses it, and the error names the feature. | +| `--collection` | Restrict to one collection by id: the collection's own row, its subcollections and the content filed under them. Segments, measures and transforms never match, tables only once published to the Library, and a transforms collection is not searchable at all. Questions saved into a dashboard match only with `--include-dashboard-questions`. | +| `--created-by` | Comma-separated user ids; matches items created by any of them. Only kinds that record a creator can match — cards, models, metrics, dashboards, actions, documents, and measures from Metabase 60 — so collections, tables, databases, segments, transforms and indexed entities drop out. | +| `--search-native-query` | Also match native query text. Narrows the result to the models that carry a query — cards, models, metrics, actions, transforms — so dashboards, collections and tables drop out. | +| `--include-metadata` | Attach the `result_metadata` of each card, model and metric row. Only `--json` with `--full`, or `--fields` naming `result_metadata`, prints it, so the flag needs one of them. | +| `--include-dashboard-questions` | Also match questions saved into a dashboard, which search leaves out by default. | ## Git Sync @@ -1941,7 +1943,7 @@ Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / expor ### `mb git-sync status` -Roll up the current sync state in one call: configured branch, dirty flag, the most recent sync task (or `null` if none has ever run), and the collections marked for sync. +Roll up the current sync state in one call: configured branch, dirty flag, the most recent sync task (or `null` if none has ever run), and the collections marked for sync. On a server without the `remote_sync` feature the command refuses with exit 2 rather than misreading what the server answers: the collection read behind the sync scope is refused before it is sent, judged against a probe of the server taken in the same run, because such a server lists its collections without the sync flag, which would read as an empty scope; and the branch is refused on the same probe that reads the `remote-sync-branch` setting, because an Enterprise server without the token still shows an admin that setting, which would read as a branch git-sync tracks. ```sh mb git-sync status @@ -2020,16 +2022,16 @@ mb git-sync import --force --no-wait mb git-sync import --merge ``` -| Flag | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--branch `, `-b` | Branch to import from (defaults to the `remote-sync-branch` setting). A blank name is refused with exit 2. | -| `--force` | Discard local Metabase-side dirty changes before importing (LOSSY). | -| `--merge` | Keep un-pushed local changes and fold the remote's in by a three-way merge; entities changed on both sides, or no merge base (a rewritten remote history, or an instance that has never synced), end the task in `conflict` without touching local state. Not with `--force`. Requires Metabase v63 or newer. | -| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | -| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | -| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +| Flag | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--branch `, `-b` | Branch to import from (defaults to the `remote-sync-branch` setting). A blank name is refused with exit 2. | +| `--force` | Discard local Metabase-side dirty changes before importing (LOSSY). | +| `--merge` | Keep un-pushed local changes and fold the remote's in by a three-way merge; entities changed on both sides, or no merge base (a rewritten remote history, or an instance that has never synced), end the task in `conflict` without touching local state. Not with `--force`. Requires Metabase v63 or newer, and is refused before the request on an older server (exit 2), judged against a probe of the server taken in the same run, because an older server ignores `merge` and runs a plain import. | +| `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -On Metabase v63 and newer the import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included), and the server rejects an import whose assertion disagrees. When no branch is tracked, or the caller is not an admin, so the setting is not readable, the command refuses with exit 2 there rather than reading the setting as unset. Older servers ignore the assertion, so they get none. On a server without remote sync the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). +The import also asserts which branch git-sync tracks, read from the session properties just before the request (an environment-set `MB_REMOTE_SYNC_BRANCH` included), and on Metabase v63 and newer the server rejects an import whose assertion disagrees. There, when no branch is tracked, or the caller is not an admin, so the setting is not readable, the command refuses with exit 2 rather than reading the setting as unset. Older servers ignore the assertion, so they get it when the setting is readable and set, and none otherwise. On a server without remote sync the server's refusal names the missing feature. A task that ends in `conflict` lists the conflicting entities in its text output and error. The server may then count the remote commit that task saw as synced, so a retry, `--merge` included, may no longer see the remote's changes. Never answer a conflict with a retry: keep one side with `--force`, or both through a reviewed PR from a new branch (git-sync skill, "After a conflict task"). ### `mb git-sync export` @@ -2047,7 +2049,7 @@ mb git-sync export --no-wait | `--branch `, `-b` | Branch to export to (defaults to the `remote-sync-branch` setting). On Metabase v63 and newer it must be the branch git-sync tracks (the server answers 409 with the current one), and with no `--branch` the export goes to the tracked branch, read as `import` reads it, with the same refusals. Older servers export to the named branch and switch git-sync to it, refusing with 400 when that branch's tip is not the last synced commit unless `--force` is given; with no `--branch` the request names none and the server exports to the branch it tracks. A blank name is refused with exit 2. | | `--message `, `-m` | Commit message for the export. | | `--force` | Force-push / overwrite the remote branch. | -| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer. | +| `--merge` | When the remote moved past the last sync, fold its changes in by a three-way merge instead of ending in a `conflict` task; entities changed on both sides still end it in `conflict`, writing nothing. Not with `--force`. Requires Metabase v63 or newer, and is refused before the request on an older server (exit 2), judged against a probe of the server taken in the same run, because an older server ignores `merge` and runs a plain export. | | `--wait` / `--no-wait` | Poll until the task reaches a terminal status (default: wait). | | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | @@ -2065,7 +2067,7 @@ mb git-sync export-preflight --branch main --json | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--branch `, `-b` | Branch to preview against (defaults to the `remote-sync-branch` setting). It must be the branch git-sync tracks; the server answers 409 with the current one. A blank name is refused with exit 2. | -The result is the server's `{ has_changes, clean, conflicts, summary: { added, updated, removed }, force_push_casualties: { deleted, overwritten }, reason }`. `reason` is `"history-rewritten"` when the remote was force-pushed or rebased so no merge base exists (a merge is impossible; only `export --force` can push), otherwise `null`. When `clean` is true, `export --merge` applies the merge; when it is false, `create-branch ` then `export` pushes Metabase's state to a new branch for a PR into the old one. On servers that count a task that ended in `conflict` as synced, `has_changes` is also false after one. `force_push_casualties` is empty when nothing has been synced yet, although a forced export then replaces the remote's managed directories wholesale. Text output is one headline for the branch, then the conflicts, the merge summary and the casualties, each only when there is something to list. When no `--branch` is passed and no branch is tracked, or the setting is not readable (the caller is not an admin), the command refuses with exit 2 before contacting the remote. The tracked branch is read from the session properties, so one set by `MB_REMOTE_SYNC_BRANCH` counts. +The result is the server's `{ has_changes, clean, conflicts, summary: { added, updated, removed }, force_push_casualties: { deleted, overwritten }, reason }`. `reason` is `"history-rewritten"` when the remote was force-pushed or rebased so no merge base exists (a merge is impossible; only `export --force` can push), otherwise `null`. When `clean` is true, `export --merge` applies the merge; when it is false, `create-branch ` then `export` pushes Metabase's state to a new branch for a PR into the old one. On servers that count a task that ended in `conflict` as synced, `has_changes` is also false after one. `force_push_casualties` is empty when nothing has been synced yet, although a forced export then replaces the remote's managed directories wholesale. Text output is one headline for the branch, then the conflicts, the merge summary and the casualties, each only when there is something to list. When no `--branch` is passed and no branch is tracked, or the setting is not readable (the caller is not an admin), the command refuses with exit 2 before contacting the remote. The tracked branch is read from the session properties, so one set by `MB_REMOTE_SYNC_BRANCH` counts. On a server without the `remote_sync` feature that read is refused with exit 2 on the same probe that reads the setting, because an Enterprise server without the token still shows an admin the setting, which would read as a branch git-sync tracks. ### `mb git-sync stash` @@ -2197,7 +2199,7 @@ Body sources: `--file`, `--body`, or stdin (exactly one). Body is JSON. | `--visualization-settings ` | Streamed exports only: the visualization settings object an ad-hoc query has no card to take from. Its `column_settings` shape `--format-rows`; its `pivot_table.column_split` is the layout `--pivot-results` needs. Refused without `--export-format`. | | `--format-rows` | Streamed exports only: format values as Metabase displays them, `--visualization-settings` column settings included (default `false`). Refused without `--export-format`. | | `--pivot-results` | With `--export-format csv` or `xlsx`: run the body as a pivot query and lay its rows out as the pivot `--visualization-settings` describes, with subtotals (default `false`). Refused without a `pivot_table.column_split` in `--visualization-settings`, and when the server has pivoted exports turned off (the `enable-pivoted-exports` setting). | -| `--csv-include-bom` | With `--export-format csv`: open the file with a UTF-8 byte order mark, so Excel reads it as UTF-8 (default `false`). Metabase v63+. | +| `--csv-include-bom` | With `--export-format csv`: open the file with a UTF-8 byte order mark, so Excel reads it as UTF-8 (default `false`). Metabase v63+; refused before the request on an older server (exit 2), judged against a probe of the server taken in the same run, because an older server drops the parameter and exports the file without the mark. | | `--skip-validate` | Skip the local MBQL 5 pre-flight and let the server be the authority. Mutually exclusive with `--dry-run`. | Any non-MBQL 5 body skips pre-flight automatically — legacy MBQL 4 (`{ "type": "query", "database": N, "query": { "source-table": T, ... } }`), legacy native (`{ "type": "native", "database": N, "native": { "query": "..." } }`), or any other shape that doesn't carry `"lib/type": "mbql/query"`. The bundled schema only models MBQL 5; `/api/dataset` normalizes the rest server-side via `lib-be/normalize-query` (the same normalizer that backs `card create` / `transform create`), so behavior is symmetric across endpoints. `--dry-run` on a non-MBQL 5 body goes straight to the server compile. The double-wrap footgun — an MBQL 5 query nested inside a `{type:"query", query:…}` envelope — is still rejected with a `ConfigError` before send. The pre-flight applies to every server-bound mode: `--compile`, `--metadata` and `--export-format` refuse an invalid MBQL 5 body the same way a run does. @@ -2349,16 +2351,17 @@ Exit codes: `0` success (a skill the server cannot use is reported, not refused) ## Environment variables -| Variable | Effect | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `MB_URL` | Default URL for `auth login` and config resolution. | -| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | -| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | -| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | - -The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. +| Variable | Effect | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MB_URL` | Default URL for `auth login` and config resolution. | +| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_CLI_SKIP_PREFLIGHT` | When set to `1`, every command acts as if `--skip-preflight` were passed, unless `--no-skip-preflight` is (see [Supported Metabase versions](#supported-metabase-versions)). | +| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | + +The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_SKIP_PREFLIGHT`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. ## Agent integration diff --git a/docs/architecture.md b/docs/architecture.md index 1f70c2ee..676127d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,15 +56,15 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/known-range.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so a feature it lacks is named when the server refuses it with a refusal status; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a record written under older placement rules reads the way the tag places today. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (the version, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so a feature it lacks is named when the server refuses it with a refusal status; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`. The version is a `ServerVersion`: a `release` with its major and patch, or a `development` build for any tag that names no release (`vUNKNOWN` on head images, `vLOCAL_DEV`, a `-SNAPSHOT` jar). Only an unreleased build reports such a tag, and it is ahead of every release, so a development build is `development`: it passes every rule's `since`, including a rule written for a major not yet released, and is past every `until`. Its token features still gate the premium rules. A persisted probe re-derives its version from the stored tag (`StoredServerVersion`), so a stored record reads the way the placement rules in force place its tag, whatever rules it was written under. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. -**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const { explain, explainWalk } = explainer(transport, "")` from `version/refusal.ts` and exports its gated methods through `explain(name, method, parameters?)`, visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. `explain` takes a method answering a promise and hands back one with the same signature whose rejection is explained; `explainWalk` takes one answering an async iterable (a `Pages` walk) and has the failure of any step explained as it is thrown, so a page walk needs no plumbing of its own. `explain-guard.test.ts` fails a gated method a resource exports unwrapped, except the ones it names as refused before the wire, and a wrapped one with no feature to name. When the call fails the way a server rejects what it lacks — a 402 premium refusal (which a gated `/api/ee` prefix answers before routing, whether or not the route exists), an unrouted 404, a 400 on a value outside an older enum or a body an older route reads in another shape, but never a 404 for a missing row, which comes from a route the server serves — `transport.explainRefusal` (classifying through `mayBeRefusal` and `explainedFailure` in `version/refusal.ts`) reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — and the server's own refusal as its `cause`. A 400 is also an ordinary validation failure, so it counts as the feature's refusal only when nothing in it points elsewhere: when its field errors (`fieldErrors` / `specificFieldErrors`, compared by top-level field) name a field outside the `fields` of the missing parameter features, the server's `HttpError` stands unchanged — a tier-named `data_layer` riding along with another field the server rejects reads as that field's rejection, not the tier floor. A 400 naming no field is the refusal (a v58 table update checks `data_layer` in its model and answers 400 with only a message), and so is any 400 from a server lacking a feature the method itself needs, since the call cannot succeed there whatever field it names (a v59 Python test run rejects the newer `source_tables` shape). The message of a 400-explained refusal still quotes the server's answer after the requirement; its field errors stay structured on the cause. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails. The probe is bounded by its own timeout and cancelled by the client's signal, so an interrupt during it surfaces as the interrupt. The call's features are the method's plus those its arguments bring, parameters first. A parameter's features come from `explain`'s `parameters` callback, a small named function beside the method in its resource file, typed by the method's own arguments and returning `{ feature, fields }` entries: the feature, and the request `fields` the argument travels in as the server names them in a 400. A revision of a measure or a transform (a v58 server answers 404 for the listing and a 400 naming `entity` for a revert), a measure's dependency graph (a 400 naming `type`) and a tier-named `data_layer` (the list filter's 400 names `data-layer`; the update's names nothing) are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, and a wrapped call that needs nothing for the arguments it was handed (a revision of a card) gets its own error back at once, so a baseline command with no cached probe stays socket-free; so does a method refused before the wire, whose `CapabilityError` is no refusal to explain. `requirements.test.ts` asserts the table names exactly the methods the client exposes. +**The server decides; the table explains.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the explanation names, and naming the lowest floor sends a user through one upgrade only to be refused again). The module imports only `FeatureName`, so `--help` reaches it without dragging `domain/` onto the startup chunk. Whether a server grants a feature is the server's fact at request time; a cached profile is a copy from the last probe, and a license activated or a server upgraded since would turn it into a refusal the server never made. So the client sends the request and lets the server answer. Each resource builds `const { explain, explainWalk, refuse, refuseAfterReading } = explainer(transport, "")` from `version/refusal.ts`, taking the wrappers it uses, and exports its gated methods through `explain(name, method, parameters?)`, visible beside the method's name in the factory's return; `name` is typed as the namespace's own keys, so a key cannot name another method's entry by a typo. `explain` takes a method answering a promise and hands back one with the same signature whose rejection is explained; `explainWalk` takes one answering an async iterable (a `Pages` walk) and has the failure of any step explained as it is thrown, so a page walk needs no plumbing of its own. `explain-guard.test.ts` fails a gated method a resource exports through anything but exactly one of `explain`, `explainWalk`, `refuse` and `refuseAfterReading` (below), and a wrapped one with no feature to name. When the call fails the way a server rejects what it lacks — a 402 premium refusal (which a gated `/api/ee` prefix answers before routing, whether or not the route exists), an unrouted 404, a 400 on a value outside an older enum or a body an older route reads in another shape, but never a 404 for a missing row, which comes from a route the server serves — `explainRefusal` in `version/refusal.ts` (classifying through `mayBeRefusal` and `explainedFailure`, and needing nothing of the transport but `verifiedServer`) reads a profile the client probed itself (`verifiedServer`, never the one handed in) and, when that profile lacks one of the call's features, throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — and the server's own refusal as its `cause`. A 400 is also an ordinary validation failure, so it counts as the feature's refusal only when nothing in it points elsewhere: when its field errors (`fieldErrors` / `specificFieldErrors`, compared by top-level field) name a field outside the `fields` of the missing parameter features, the server's `HttpError` stands unchanged — a tier-named `data_layer` riding along with another field the server rejects reads as that field's rejection, not the tier floor. A 400 naming no field is the refusal (a v58 table update checks `data_layer` in its model and answers 400 with only a message), and so is any 400 from a server lacking a feature the method itself needs, since the call cannot succeed there whatever field it names (a v59 Python test run rejects the newer `source_tables` shape). The message of a 400-explained refusal quotes the server's answer after the requirement when the server said anything (`HttpError.serverMessage`, its envelope's message or field errors or a plain-text body, is non-null); its field errors stay structured on the cause. A 402 body already names the premium feature, but the refusal is still explained from a probe: only a probe places the server for a version gate, one probe keeps every `RequirementFailure` the same shape, and its write-back corrects the cache the refusal has just shown stale. A server that turns out to have every feature failed for another reason, and its own error stands, as it does when the explaining probe fails. The probe is bounded by its own timeout and cancelled by the client's signal, so an interrupt during it surfaces as the interrupt. The call's features are the method's plus those its arguments bring, parameters first. A parameter's features come from `explain`'s `parameters` callback, a small named function beside the method in its resource file, typed by the method's own arguments and returning `{ feature, fields }` entries: the feature, and the request `fields` the argument travels in as the server names them in a 400. A revision of a measure or a transform (a v58 server answers 404 for the listing and a 400 naming `entity` for a revert), a measure's dependency graph (a 400 naming `type`) and a tier-named `data_layer` (the list filter's 400 names `data-layer`; the update's names nothing) are rejected by an older server, and the rejection names the feature that brought them. A method that needs nothing is not wrapped and never probes, and a wrapped call that needs nothing for the arguments it was handed (a revision of a card) gets its own error back at once, so a baseline command with no cached probe stays socket-free; a `CapabilityError` thrown before the wire passes through `explain` unchanged, since it is no refusal to explain. `requirements.test.ts` asserts the table names exactly the methods the client exposes. -**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent, rewrite it, or fail it without a cause: a `GET /api/table` filter below its floor lists every table, a search without the `content_verification` token drops `verified`, an import or export before `remoteSyncMerge` ignores `merge`. Such a method refuses first through `transport.requireFeatures` or a value check on `transport.verifiedServer()`, never against the cached profile, and says in a comment beside the check what the server would do; those comments are the list. The same holds for a method whose whole answer a server without the feature would misread (`gitSync.syncedCollections`, `gitSync.branch`), and those methods are the ones `explain-guard.test.ts` names in `REFUSED_BEFORE_THE_REQUEST`. A drop that depends on the patch release rather than the major is judged from the answer instead: `table.update` compares the collection the server answers with the one asked for and, when they differ, throws a `PartialWriteError` (category `partial-write`, not retryable) saying the rest of the update was applied. A `null` the update drops on every server is no value of `TableUpdateInput`. These checks judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. +**Refused before the wire only where the server cannot answer.** Some servers take a parameter they do not support and answer as if it had never been sent, rewrite it, or fail it without a cause: a `GET /api/table` filter below its floor lists every table, a search on a server granting `official_collections` but not `content_verification` takes `verified` without applying it (one granting neither answers 402, which the parameter's declaration explains), an import or export before `remoteSyncMerge` ignores `merge`. Such a method refuses first through `transport.requireFeatures` or a value check on `transport.preflightServer()`, never against the cached profile, and says in a comment beside the check what the server would do; those comments are the list. A method every call of which a server without the feature would answer that way — whose whole answer it would misread (`gitSync.syncedCollections`, `gitSync.branch`, `gitSync.trackedBranch`) or whose whole body it would drop (`field.setDataSensitivity`) — is exported through `refuse(name, method)` instead of `explain`, with the comment beside it in the factory's return: `refuse` runs the method's whole `METHOD_REQUIREMENTS` entry through `transport.requireFeatures` before calling it, waiting under the signal of the method's trailing `RequestOptions`, so a method it wraps ends in optional `RequestOptions`. A method answering from a setting the session properties carry is exported through `refuseAfterReading(name, reader, method)` instead (`gitSync.branch`, `gitSync.trackedBranch`): the wrapper reads the properties afresh through `transport.probe(reader, options)`, then checks the method's `METHOD_REQUIREMENTS` entry against the profile that same answer describes, never whichever probe is newest by then, refusing with the `CapabilityError` `transport.requireFeatures` would, so the call costs one request however other calls interleave, and hands the properties to the method, a pure function of them; the exposed method takes only optional `RequestOptions`, whose `signal`, `timeoutMs` and `retries` govern the read. A check that depends on the arguments stays inline in the method. A drop that depends on the patch release rather than the major is judged from the answer instead: `table.update` compares the collection the server answers with the one asked for and, when they differ, throws a `PartialWriteError` (category `partial-write`, not retryable) saying the rest of the update was applied. A `null` the update drops on every server is no value of `TableUpdateInput`. These checks judge by `FEATURE_RULES`, which places a server by its major, so a feature backported to an older patch release, or a rule the table gets wrong, is refused although the server would honour it; nothing finer is possible until Metabase reports its capabilities itself, so the caller may skip them. Every refusal they make, the medallion `data_layer` value check included (a `CapabilityError` whose `RequirementFailure` has reason `superseded-by-feature`: the server has `tableDataLayerTiers`, which takes the medallion names out of its vocabulary), goes through `transport.refuseBeforeSending`, the one place that decides: it throws, unless the client was built with `onPreflightSkipped`, which is then handed the refusal's `CapabilityError` while the call goes on to the server. `requireFeatures` and `refuseAfterReading` hand over every feature a call lacks (`featureFailures`), not only the first, and throw the first when nothing skips. A check reads its profile through `transport.preflightServer()`, which is `verifiedServer()` except under the hook, where a probe failing short of an interrupt is handed over as unverified (`SkippedPreflight` is `{ kind: "refused", refusal }` or `{ kind: "unverified", failure }`) and the call goes on unchecked; `refuseAfterReading` is the exception, since its read is the method's own data and a failed read fails the call. The checks that judge the server's own state rather than a rule (pivoted exports turned off, git-sync's unset or unreadable tracked branch), input the client rejects, the `PartialWriteError` judged from the answer and the explanation of a refusal the server answered never pass through it, and neither does the choice of what to send (git-sync's `expected_branch` and `branch` below). The CLI sets the hook under `--skip-preflight` or `MB_CLI_SKIP_PREFLIGHT=1` and warns once per distinct refusal, and once per run for a server it could not check; without it, its rendering of a `CapabilityError` with no `cause`, which only a refusal before the wire lacks, names the flag. A verified profile is memoized until the client reads the session properties again, so a long-lived embedder whose server gains a license mid-session builds a new client to see it. -**Git-sync names the tracked branch only to a server that guards it.** Where an older server ignores a key a newer one requires, the key is sent on every server and no feature is read. A git-sync import's `expected_branch` and an export's `branch` are the exception. When the caller names none, the client reads the tracked branch from the session properties through `transport.probe`, which settles the verified profile in the same request, so the merge check and the explanation of a refusal that follow cost nothing more. It sends that branch only to a server with `remoteSync` and `remoteSyncBranchGuard`, which requires it; there an unset setting, or one hidden from a caller who is not an admin, is refused before the request. An older server reads the setting itself within the request. It ignores an import's assertion, but it takes an export's branch as a switch, so a branch read a moment earlier would undo a switch another admin made in between. +**Git-sync sends the tracked branch where an older server ignores it.** Where an older server ignores a key a newer one requires, the key is sent on every server; where an older server acts on it instead, it is sent only where the feature holds. A git-sync import's `expected_branch` is the first kind. When the caller names none, the client reads the tracked branch from the session properties through `transport.probe` and sends it to every server with `remoteSync` whose setting the caller can read and finds set. Only a server with `remoteSyncBranchGuard` rejects an import without it, so only there is an unset setting, or one hidden from a caller who is not an admin, refused before the request; an older server with nothing to read gets none. An export's `branch` is the second kind: an older server reads the setting itself within the request but takes a branch it is sent as a switch, so a branch read a moment earlier would undo a switch another admin made in between. It is sent only to a server with `remoteSync` and `remoteSyncBranchGuard`, which requires it, with the same refusals before the request. Both judge the features by the profile that same read describes (`profileFromProperties` in `version/probe.ts`), and the read settles the verified profile in the same request, so the merge check and the explanation of a refusal that follow cost nothing more. `transport.probe(reader, options)` starts a probe on the probe's own budget (a 10 s timeout, no retries) unless the caller sets `timeoutMs` or `retries`, as `verifiedServer()`'s probe does. The probe every caller shares parses the body as `SessionProperties` only, and the caller's `reader` parses the same body for that caller alone, so a setting one resource requires fails only that resource's call, with the `ResponseShapeError` a read of its own would raise. Probes are ordered by when they started: an answer is applied, made the profile in force and handed to `onServerProbed`, unless a later-started probe has been applied already, so neither goes back to an older answer and an answer outliving a later probe that failed still counts. `verifiedServer()` joins the newest probe started, in flight or settled, and when that one fails the newest applied answer stands in for it. `gitSync.branch` and `gitSync.trackedBranch` cost one read of the session properties: `refuseAfterReading` reads the setting afresh, so a long-lived client sees a branch another admin switched, and checks `remoteSync` against the profile that read describes, because an EE server without the token still shows its admin the setting. A pivoted export reads the `enable-pivoted-exports` setting through `transport.probe` too, so the export's feature checks that follow reuse that read; `exportQuery` on `card` and `dataset` checks the pivot before the byte order mark. **The CLI declares what a command reaches.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features for `help --json`, which reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the table. The CLI runs no check of its own: the client's explanation reaches the user as the same `CapabilityError` (exit 2). @@ -72,7 +72,7 @@ The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packa Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade. A run with no cached probe learns the server only from a probe the client runs, so when its command fails with a failure skew could explain — a shape error or an unrouted 404, never a validation 4xx, a 5xx, a network error, a timeout, a credential failure, a missing row or the CLI's own refusal — `diagnoseFailure` in `commands/runtime.ts` asks the client for `verifiedServer()` on the way out, which costs nothing when the run has probed already and otherwise gives the notice its say; a command that succeeds against an out-of-range server without a cache stays silent. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe the client runs reaches the CLI through `ClientOptions.onServerProbed`, which the client calls without awaiting; the CLI queues a write of the probe to the profile record only when it differs from the cached one in version or granted token features, and waits on the queue before the command ends (a write that fails warns without failing the command). So a refusal explained today leaves no stale cache behind for the skills filter or the next run's shape choices, and a run that learns nothing new leaves the profiles file alone. Under the file backend that file also holds OAuth tokens, and every read-modify-write of it within a process runs alone (`exclusively` in `core/auth/storage.ts`), so a probe save never restores a refresh token the server has already rotated away. A record's probe time is when the profile last recorded the server: a login, a listing, or a change. A `ResponseShapeError` whose shape was chosen under the cached profile — its tag is the cached one — earns the same `verifiedServer()` from `diagnoseFailure`: when the fresh probe differs from the cache in version or granted token features, the error says the profile was refreshed and, for a read, to retry; for a write it says the request may have been applied and to check before retrying. The command is not retried. A shape chosen under a probe the run already settled carries that probe's tag, so its error stands. The one misreading left is a probe settled mid-run with the cached tag but other token features: a shape chosen under it still reads as chosen under the cache, and the error carries a note it did not need. `skills list` / `skills get` filter by the cached probe without a request, and name when the profile last recorded the server and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run, from the cached probe or else the first probe the client runs, pointing at `mb upgrade`; a development build gets no notice, and a server below the window gets one pointing at a Metabase upgrade. A run with no cached probe learns the server only from a probe the client runs, so when its command fails with a failure skew could explain — a shape error or an unrouted 404, never a validation 4xx, a 5xx, a network error, a timeout, a credential failure, a missing row or the CLI's own refusal — `diagnoseFailure` in `commands/runtime.ts` asks the client for `verifiedServer()` on the way out, which costs nothing when the run has probed already and otherwise gives the notice its say; a command that succeeds against an out-of-range server without a cache stays silent. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. Every probe answer the client applies reaches the CLI through `ClientOptions.onServerProbed`, which the client calls without awaiting; the CLI queues a write of the probe to the profile record only when it differs from the cached one in version or granted token features, and before the command ends waits for every probe still in flight (`client.probesSettled()`) and then on the queue, so a probe that outlives a failed command still has its write land before the process exits (a write that fails warns without failing the command). So an explained refusal leaves no stale cache behind for the skills filter or the next run's shape choices, and a run that learns nothing new leaves the profiles file alone. Under the file backend that file also holds OAuth tokens, and every read-modify-write of it within a process runs alone (`exclusively` in `core/auth/storage.ts`), so a probe save never restores a refresh token the server has already rotated away. A record's probe time is when the profile last recorded the server: a login, a listing, or a change. A `ResponseShapeError` whose shape was chosen under the cached profile — its tag is the cached one — earns the same `verifiedServer()` from `diagnoseFailure`: when the fresh probe differs from the cache in version or granted token features, `diagnoseFailure` waits for that probe's write, and the error says the profile was refreshed and, for a read, to retry; for a write it says the request may have been applied and to check before retrying. When the write failed, the error says instead that the profile could not be updated, pointing at the warning that says why, and for a read to retry once that is addressed; for a write, again, to check before retrying. The command is not retried. A shape chosen under a probe the run already settled carries that probe's tag, so its error stands. The one misreading left is a probe settled mid-run with the cached tag but other token features: a shape chosen under it still reads as chosen under the cache, and the error carries a note it did not need. `skills list` / `skills get` filter by the cached probe without a request, and name when the profile last recorded the server and `mb auth list` to probe again whenever they leave a skill out. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). **Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. Content values an older server stores without complaint — a `boxplot` display, a `table` template tag, a measure smart link — are guarded by these markers only: the client sends them, and nothing refuses them. diff --git a/packages/cli/src/commands/context.test.ts b/packages/cli/src/commands/context.test.ts index e28d6082..ca4f63f3 100644 --- a/packages/cli/src/commands/context.test.ts +++ b/packages/cli/src/commands/context.test.ts @@ -151,6 +151,7 @@ describe("resolveCommonFlags — full result shape", () => { url: undefined, apiKey: undefined, profile: undefined, + skipPreflight: undefined, }); }); @@ -165,6 +166,7 @@ describe("resolveCommonFlags — full result shape", () => { maxBytes: "1024", limit: "25", offset: "50", + skipPreflight: true, }, { isTty: false }, ); @@ -178,6 +180,7 @@ describe("resolveCommonFlags — full result shape", () => { url: "https://m.example.com", apiKey: "secret", profile: "prod", + skipPreflight: true, }); }); }); diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index d894bb17..3db8ad6b 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -32,6 +32,9 @@ export interface CommonContext { url: string | undefined; apiKey: string | undefined; profile: string | undefined; + // Unset when neither `--skip-preflight` nor `--no-skip-preflight` was typed, so the environment + // decides. + skipPreflight: boolean | undefined; } interface ResolveOptions { @@ -65,6 +68,7 @@ export function resolveCommonFlags(args: CommonArgs, options: ResolveOptions = { url: args.url, apiKey: args.apiKey, profile: args.profile, + skipPreflight: args.skipPreflight, }; } diff --git a/packages/cli/src/commands/delete-runtime.test.ts b/packages/cli/src/commands/delete-runtime.test.ts index 82c1dcb0..0186c7cf 100644 --- a/packages/cli/src/commands/delete-runtime.test.ts +++ b/packages/cli/src/commands/delete-runtime.test.ts @@ -20,6 +20,7 @@ const CTX: CommonContext = { url: undefined, apiKey: undefined, profile: undefined, + skipPreflight: false, }; describe("confirmAndDelete", () => { diff --git a/packages/cli/src/commands/flags.ts b/packages/cli/src/commands/flags.ts index c6926eef..acc4d8da 100644 --- a/packages/cli/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -72,4 +72,10 @@ export const profileFlag = { export const connectionFlags = { url: { type: "string", description: "Metabase URL" }, apiKey: { type: "string", description: "API key", alias: "api-key" }, + skipPreflight: { + type: "boolean", + description: + "Skip the client's version and license checks made before a request, and let the server answer", + alias: "skip-preflight", + }, } as const; diff --git a/packages/cli/src/commands/git-sync/status.ts b/packages/cli/src/commands/git-sync/status.ts index b08e2745..2f104b6c 100644 --- a/packages/cli/src/commands/git-sync/status.ts +++ b/packages/cli/src/commands/git-sync/status.ts @@ -47,14 +47,22 @@ export default defineMetabaseCommand({ examples: ["mb git-sync status", "mb git-sync status --json"], async run({ ctx, getClient }) { const mb = await getClient(); - const [isDirty, currentTask, collections] = await Promise.all([ + // The branch read starts its probe first, so the check the synced collections make before + // their request joins that probe rather than starting another, and the run reads the session + // properties once. The branch read also refuses a non-admin by itself, so every read settles + // and the first failure in this order is reported: that caller's failure is the admin-only + // endpoints' 403, not the branch read's refusal, whichever lands first. + const branchRead = mb.gitSync.branch(); + const [dirtyRead, taskRead, collectionsRead, settledBranch] = await Promise.allSettled([ mb.gitSync.isDirty(), mb.gitSync.currentTask(), mb.gitSync.syncedCollections(), + branchRead, ]); - // The branch read refuses a non-admin by itself; asking only after the admin-only endpoints - // keeps that caller's failure the server's 403 rather than whichever request lost the race. - const branch = await mb.gitSync.branch(); + const isDirty = fulfilled(dirtyRead); + const currentTask = fulfilled(taskRead); + const collections = fulfilled(collectionsRead); + const branch = fulfilled(settledBranch); const syncedCollections: SyncedCollection[] = collections.data.map((collection) => ({ id: collection.id, name: collection.name, @@ -86,3 +94,10 @@ function formatSyncedCollections(collections: SyncedCollection[]): string { const names = collections.map((collection) => `${collection.name} (${collection.id})`); return `Synced collections: ${names.join(", ")}.`; } + +function fulfilled(result: PromiseSettledResult): T { + if (result.status === "rejected") { + throw result.reason; + } + return result.value; +} diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index 69ba6cda..5667871e 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -4,7 +4,13 @@ import { CardQueryResult } from "@metabase/client/domain/card"; import { CompiledQuery, QueryMetadata } from "@metabase/client/domain/dataset"; import { DatasetQuery } from "@metabase/client/domain/query"; import { ConfigError, type MetabaseError } from "@metabase/client/errors"; -import { chainRequestFailure, HttpError } from "@metabase/client/http/errors"; +import { + BAD_REQUEST_STATUS, + chainRequestFailure, + FORBIDDEN_STATUS, + HttpError, + INTERNAL_SERVER_ERROR_STATUS, +} from "@metabase/client/http/errors"; import { assertNotLegacyEnvelopeWrappingMbql5, @@ -42,8 +48,10 @@ const QueryOutput = z.union([CardQueryResult, QueryDryRunOutcome, CompiledQuery, // A compile never touches the warehouse, and the server answers query problems (a missing table, // an unfilled template tag) with 500 as well as 400, so both are reported against the body. -const COMPILE_REJECTION_STATUSES: ReadonlySet = new Set([400, 500]); -const FORBIDDEN_STATUS = 403; +const COMPILE_REJECTION_STATUSES: ReadonlySet = new Set([ + BAD_REQUEST_STATUS, + INTERNAL_SERVER_ERROR_STATUS, +]); // The server's message names no location in the body, so it points at the whole query. const WHOLE_QUERY_POINTER = ""; diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 0aefa92a..d4756ce2 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -3,20 +3,21 @@ import type { ArgsDef, CommandDef, CommandMeta, ParsedArgs } from "citty"; import type { ZodType } from "zod"; import type { MetabaseClient } from "@metabase/client/client"; -import { - AbortError, - errorMessage, - MetabaseError, - ResponseShapeError, -} from "@metabase/client/errors"; +import { errorMessage, isNonInterruptFailure, ResponseShapeError } from "@metabase/client/errors"; import { HttpError } from "@metabase/client/http/errors"; +import type { ClientOptions, SkippedPreflight } from "@metabase/client/http/transport"; import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import type { ProfileLastProbe } from "../core/auth/profile-record"; import { ProfileRefreshedError } from "../core/profile-refreshed-error"; -import { sameServer, serverChangeNote, skewNotice } from "../core/auth/server-summary"; +import { + type ProfileState, + sameServer, + serverChangeNote, + skewNotice, +} from "../core/auth/server-summary"; import { readCachedProbe } from "../core/auth/cached-server"; import { consumeKeyringDowngradeWarning, @@ -29,10 +30,10 @@ import { type ConfigFlags, type ResolvedConfig, } from "../core/config"; -import { consumeLegacyEnvWarnings } from "../core/env"; +import { consumeLegacyEnvWarnings, ENV_SKIP_PREFLIGHT, readEnv } from "../core/env"; import { USER_AGENT } from "../core/user-agent"; import { reportError } from "../output/error"; -import { warn } from "../output/notice"; +import { preflightSkippedNotice, preflightUncheckedNotice, warn } from "../output/notice"; import { type CommandRequirements, setMetabaseAugment, @@ -88,8 +89,9 @@ export function defineMetabaseCommand( } return cachedConfig; }; - const run: RunState = { server: null, writes: Promise.resolve() }; + const run: RunState = { server: null, profileSave: Promise.resolve("current") }; const noticeSkew = createSkewNotifier(); + const skipPreflight = ctx.skipPreflight ?? readEnv(ENV_SKIP_PREFLIGHT) === "1"; // Imported here rather than at the top of the file so the resource namespaces // `createClient` composes — and the whole `domain/` layer behind them — stay off the chunk // every command loads, including `--help`, a flag error, and the commands that open no @@ -112,9 +114,10 @@ export function defineMetabaseCommand( onServerProbed: (fresh, profile) => { noticeSkew(profile); if (saveProbe !== null) { - run.writes = run.writes.then(() => saveProbe(fresh)); + run.profileSave = run.profileSave.then(() => saveProbe(fresh)); } }, + ...(skipPreflight && { onPreflightSkipped: createPreflightSkipWarner() }), signal: interruptSignal, }, ); @@ -134,7 +137,7 @@ export function defineMetabaseCommand( } catch (error) { throw await diagnoseFailure(error, run); } finally { - await run.writes; + await settleRun(run); emitPendingWarnings(); } } catch (error) { @@ -190,34 +193,80 @@ function createSkewNotifier(): SkewNotifier { }; } -type ProbeSaver = (fresh: ServerInfo) => Promise; +type PreflightSkipWarner = NonNullable; + +// A check a run skips on every page of a walk, or on each of several calls, carries one risk, so +// each distinct refusal warns once, and a server that could not be checked warns once whatever +// each failed probe said. +function createPreflightSkipWarner(): PreflightSkipWarner { + const warned = new Set(); + return (skipped) => { + const notice = skippedPreflightNotice(skipped); + const key = skipped.kind === "unverified" ? skipped.kind : notice; + if (warned.has(key)) { + return; + } + warned.add(key); + warn(notice); + }; +} + +function skippedPreflightNotice(skipped: SkippedPreflight): string { + if (skipped.kind === "refused") { + return preflightSkippedNotice(skipped.refusal.message); + } + return preflightUncheckedNotice(skipped.failure.message); +} + +type ProbeSaver = (fresh: ServerInfo) => Promise; // A probe that finds the server changed since the cache was written is saved, so the skills filter // and the next run's shape choices read the server as it is; one that agrees with the cache is not, // so a run that probes rewrites the profiles file only when it has something new to say. The cache // is a convenience, so a write that fails warns rather than failing the command the probe served. +// Each save answers whether the profile now holds what that probe said, which a probe agreeing with +// the last one saved inherits from that save. function createProbeSaver(profileName: string, cached: ProfileLastProbe): ProbeSaver { let current: ServerInfo = cached; + let state: ProfileState = "current"; return async (fresh) => { if (sameServer(current, fresh)) { - return; + return state; } current = fresh; try { await writeProbeResult(profileName, { user: cached.user, server: fresh }); + state = "current"; } catch (error) { warn( `Could not save the server's new probe to profile "${profileName}": ${errorMessage(error)}`, ); + state = "stale"; } + return state; }; } -// What a run knows of its server once the client is built, and the cache writes the client's probes -// have queued, which the run waits on before it ends. +// What a run knows of its server once the client is built, and the newest profile save the client's +// probes have queued, which settles after every save queued before it and which the run waits on +// before it ends. interface RunState { server: RunServer | null; - writes: Promise; + profileSave: Promise; +} + +// A probe still in flight when the command ends may yet queue a save, which the process exiting +// right after would cut off midway, so the run waits for its probes before the saves they queued, +// and goes round again when a save was queued while it waited. +async function settleRun(run: RunState): Promise { + let awaited: Promise | null = null; + while (awaited !== run.profileSave) { + if (run.server !== null) { + await run.server.client.probesSettled(); + } + awaited = run.profileSave; + await awaited; + } } interface RunServer { @@ -228,8 +277,10 @@ interface RunServer { // A failure that skew could explain earns the probe that names the server, on the way out; it is // free once the run has probed, because the client keeps its probe. With no cached profile the // probe only lets the skew notice speak. Under one, only a shape read under that profile's tag is -// worth it: a probe settled mid-run with the same tag at worst adds a needless note. The command is -// never retried, because its request may have been a write. +// worth it: a probe settled mid-run with the same tag at worst adds a needless note. The note +// waits for that probe's save, queued before the probe answers, since it tells the user whether +// the profile now holds it. The command is never retried, because its request may have been a +// write. async function diagnoseFailure(error: unknown, run: RunState): Promise { if (run.server === null || !isSkewSymptom(error)) { return error; @@ -247,11 +298,11 @@ async function diagnoseFailure(error: unknown, run: RunState): Promise if (fresh === null) { return error; } - const note = serverChangeNote(cachedProbe, fresh, detail.method); + const note = serverChangeNote(cachedProbe, fresh, detail.method, await run.profileSave); return note === null ? error : new ProfileRefreshedError(error, note); } -function isSkewSymptom(error: unknown): error is MetabaseError { +function isSkewSymptom(error: unknown): error is ResponseShapeError | HttpError { if (error instanceof ResponseShapeError) { return true; } @@ -265,7 +316,7 @@ async function probeOnTheWayOut(client: MetabaseClient): Promise( if (typeof args["apiKey"] === "string") { out.apiKey = args["apiKey"]; } + if (typeof args["skipPreflight"] === "boolean") { + out.skipPreflight = args["skipPreflight"]; + } return out; } diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 8bb3219a..877d1bc4 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -64,7 +64,7 @@ export default defineMetabaseCommand({ verified: { type: "boolean", description: - "Only verified content. Needs the content_verification premium feature, and is refused without it: a server without it ignores the filter", + "Only verified content. Needs the content_verification premium feature, and is refused without it: a server with official_collections alone takes the filter without applying it", }, collection: { type: "string", diff --git a/packages/cli/src/commands/transform/collection-namespace.ts b/packages/cli/src/commands/transform/collection-namespace.ts index 0d90305c..238e187a 100644 --- a/packages/cli/src/commands/transform/collection-namespace.ts +++ b/packages/cli/src/commands/transform/collection-namespace.ts @@ -1,5 +1,5 @@ import { ConfigError } from "@metabase/client/errors"; -import { HttpError } from "@metabase/client/http/errors"; +import { BAD_REQUEST_STATUS, HttpError } from "@metabase/client/http/errors"; const TRANSFORMS_NAMESPACE_HINT = 'transforms can only be filed in a :transforms-namespace collection. Create one with `mb collection create --body \'{"name":"…"}\' --namespace transforms` and pass its id as collection_id, or omit collection_id to leave the transform uncollected.'; @@ -11,7 +11,7 @@ const TRANSFORMS_NAMESPACE_HINT = export function enrichTransformCollectionError(error: unknown): unknown { if ( error instanceof HttpError && - error.status === 400 && + error.status === BAD_REQUEST_STATUS && /:transforms namespace/i.test(error.message) ) { return new ConfigError(`${error.message} — ${TRANSFORMS_NAMESPACE_HINT}`); diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index d3ed1c3c..bdbece2c 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -76,39 +76,48 @@ describe("skewNotice", () => { describe("serverChangeNote", () => { it("names both tags when the version changed", () => { - expect(serverChangeNote(serverAt(59), serverAt(63), "GET")).toBe( + expect(serverChangeNote(serverAt(59), serverAt(63), "GET", "current")).toBe( "The server's version changed since the last probe (was v0.59.0, now v0.63.0); the profile was refreshed — retry the command.", ); }); it("names a development build that replaced a release", () => { - expect(serverChangeNote(serverAt(59), DEVELOPMENT, "GET")).toBe( + expect(serverChangeNote(serverAt(59), DEVELOPMENT, "GET", "current")).toBe( "The server's version changed since the last probe (was v0.59.0, now development build vUNKNOWN); the profile was refreshed — retry the command.", ); }); it("reports a premium feature the server started granting under the same version", () => { expect( - serverChangeNote(serverAt(59, { library: false }), serverAt(59, { library: true }), "GET"), + serverChangeNote( + serverAt(59, { library: false }), + serverAt(59, { library: true }), + "GET", + "current", + ), ).toBe( "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", ); }); it("reports a premium feature the server stopped granting", () => { - expect(serverChangeNote(serverAt(59, { library: true }), serverAt(59, null), "GET")).toBe( + expect( + serverChangeNote(serverAt(59, { library: true }), serverAt(59, null), "GET", "current"), + ).toBe( "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", ); }); it("treats an unreported map, an empty one, and a denied feature as the same grants", () => { - expect(serverChangeNote(serverAt(59, null), serverAt(59, {}), "GET")).toBeNull(); - expect(serverChangeNote(serverAt(59, {}), serverAt(59, { library: false }), "GET")).toBeNull(); + expect(serverChangeNote(serverAt(59, null), serverAt(59, {}), "GET", "current")).toBeNull(); + expect( + serverChangeNote(serverAt(59, {}), serverAt(59, { library: false }), "GET", "current"), + ).toBeNull(); }); it("is null when tag and grants agree even though the build moved", () => { const cached = serverAt(59, { library: true }); const fresh: ServerInfo = { ...cached, date: "2026-09-16", hash: "abc1234" }; - expect(serverChangeNote(cached, fresh, "GET")).toBeNull(); + expect(serverChangeNote(cached, fresh, "GET", "current")).toBeNull(); }); }); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index 6057377b..bbe9aeb3 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -66,8 +66,13 @@ export function skewNotice(profile: ServerProfile): string | null { } const PROFILE_REFRESHED = "the profile was refreshed"; +const PROFILE_STALE = "the profile could not be updated (the warning above says why)"; const SAFE_METHODS: ReadonlySet = new Set(["GET", "HEAD", "OPTIONS"]); +// Whether the profile record holds what the newest probe said: it did already or the save landed +// (`current`), or the save failed (`stale`). +export type ProfileState = "current" | "stale"; + // What a feature switch reads off a probe. type ServerIdentity = Pick; @@ -81,18 +86,19 @@ export function sameServer(a: ServerIdentity, b: ServerIdentity): boolean { // What a fresh probe says that the cached one did not, with what to do about a request of `method` // read under the cached one, or `null` when the two are the same server. A read is safe to repeat; -// a write the server answered may have landed, and repeating it could apply it twice. +// a write the server answered may have landed, and repeating it could apply it twice. A profile +// left `stale` would choose the same shape again, so a read is worth repeating only once the save +// can land. export function serverChangeNote( cached: ServerIdentity, fresh: ServerIdentity, method: string, + profile: ProfileState, ): string | null { if (sameServer(cached, fresh)) { return null; } - const remedy = SAFE_METHODS.has(method) - ? `${PROFILE_REFRESHED} — retry the command.` - : `${PROFILE_REFRESHED}, but the request may have been applied — check before retrying.`; + const remedy = changeRemedy(SAFE_METHODS.has(method), profile); const before = describeVersion(cached.version); const after = describeVersion(fresh.version); if (before !== after) { @@ -101,6 +107,17 @@ export function serverChangeNote( return `The server's premium features changed since the last probe; ${remedy}`; } +function changeRemedy(isSafe: boolean, profile: ProfileState): string { + if (profile === "current") { + return isSafe + ? `${PROFILE_REFRESHED} — retry the command.` + : `${PROFILE_REFRESHED}, but the request may have been applied — check before retrying.`; + } + return isSafe + ? `${PROFILE_STALE} — retry the command once it can be.` + : `${PROFILE_STALE}, and the request may have been applied — check before retrying.`; +} + // Only a granted feature turns a switch on, so a map the server did not report, an empty one, and // one that names a feature it denies all read the same. function sameTokenFeatures( diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts index 043ed5d0..81b2437b 100644 --- a/packages/cli/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -5,6 +5,7 @@ export const ENV_URL = "MB_URL"; export const ENV_API_KEY = "MB_API_KEY"; export const ENV_PROFILE = "MB_PROFILE"; export const ENV_VERBOSE = "MB_VERBOSE"; +export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index 954134d6..5a44b68a 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -161,6 +161,7 @@ describe("reportError", () => { expect(streams.stderr).toBe( "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.\n" + "Or install an `@metabase/cli` release that targets this server.\n" + + "If this server does support it, rerun with --skip-preflight; a server without it may ignore or rewrite what it does not support and answer without saying so.\n" + "(rerun with MB_VERBOSE=1 for details)\n", ); expect(process.exitCode).toBe(2); @@ -226,6 +227,7 @@ describe("reportError", () => { reportError(new CapabilityError(failure)); expect(streams.stderr).toBe( "This operation requires the 'library' premium feature (not enabled on this server).\n" + + "If this server does support it, rerun with --skip-preflight; a server without it may ignore or rewrite what it does not support and answer without saying so.\n" + "(rerun with MB_VERBOSE=1 for details)\n", ); }); @@ -250,7 +252,8 @@ describe("reportError", () => { category: "capability", message: "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.\n" + - "Or install an `@metabase/cli` release that targets this server.", + "Or install an `@metabase/cli` release that targets this server.\n" + + "If this server does support it, rerun with --skip-preflight; a server without it may ignore or rewrite what it does not support and answer without saying so.", exitCode: 2, }, }) + "\n", diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index 2af7f25c..0b936cd3 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -1,10 +1,13 @@ import { AbortError, toMetabaseError } from "@metabase/client/errors"; import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; -import type { RequirementFailure } from "@metabase/client/version/capability-error"; +import { + CapabilityError, + type RequirementFailure, +} from "@metabase/client/version/capability-error"; import { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; -import { warn } from "./notice"; +import { PREFLIGHT_SKIP_REMEDY, warn } from "./notice"; import { isPromptCancel } from "./prompt"; import { serializeJson } from "./render"; import type { Format } from "./types"; @@ -91,6 +94,12 @@ function isRouteMissing(handled: MetabaseError): boolean { return "kind" in handled && handled.kind === ROUTE_MISSING_KIND; } +// A refusal the client made before the wire carries no server answer as its cause. One the server +// answered does, and no flag gets a call past the server's own refusal. +function isRefusedBeforeSending(handled: MetabaseError): boolean { + return handled instanceof CapabilityError && handled.cause === undefined; +} + // What the CLI adds to a message the client had to phrase without knowing who would print it. Both // output formats carry them, so an agent reading `--json` gets the same remediation a human does. function remediesFor(handled: MetabaseError): readonly string[] { @@ -98,13 +107,12 @@ function remediesFor(handled: MetabaseError): readonly string[] { if (handled instanceof ProfileRefreshedError) { return []; } - if (isVersionTooOld(handled.developerDetail)) { - return [DOWNGRADE_REMEDY]; - } if (isRouteMissing(handled)) { return [ROUTE_MISSING_REMEDY]; } - return []; + const downgrade = isVersionTooOld(handled.developerDetail) ? [DOWNGRADE_REMEDY] : []; + const skip = isRefusedBeforeSending(handled) ? [PREFLIGHT_SKIP_REMEDY] : []; + return [...downgrade, ...skip]; } function writeTextError(handled: MetabaseError, verbose: boolean): void { diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 8b5fb834..9bbc72c3 100644 --- a/packages/cli/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -228,9 +228,12 @@ describe("showUsage", () => { const out = chunks.join(""); expect(out).toContain("--max-bytes="); expect(out).toContain("--api-key="); + expect(out).toContain("--skip-preflight"); expect(out).not.toContain("--maxBytes"); expect(out).not.toContain("--apiKey"); + expect(out).not.toContain("--skipPreflight"); expect(out).not.toMatch(/(? { diff --git a/packages/cli/src/output/notice.ts b/packages/cli/src/output/notice.ts index b28fefbe..22071f6d 100644 --- a/packages/cli/src/output/notice.ts +++ b/packages/cli/src/output/notice.ts @@ -2,6 +2,25 @@ export function warn(message: string): void { process.stderr.write(message + "\n"); } +// What sending a call the client's own checks would have refused risks, said both where the flag is +// offered and where it was used. +const PREFLIGHT_RISK = + "may ignore or rewrite what it does not support and answer without saying so"; + +export const PREFLIGHT_SKIP_REMEDY = `If this server does support it, rerun with --skip-preflight; a server without it ${PREFLIGHT_RISK}.`; + +export function preflightSkippedNotice(refusal: string): string { + return `Skipped preflight: ${withoutFinalPeriod(refusal)}. A server without it ${PREFLIGHT_RISK}.`; +} + +export function preflightUncheckedNotice(reason: string): string { + return `Skipped preflight: could not check the server (${withoutFinalPeriod(reason)}), so the request went out unchecked. A server without a feature it needs ${PREFLIGHT_RISK}.`; +} + +function withoutFinalPeriod(text: string): string { + return text.endsWith(".") ? text.slice(0, -1) : text; +} + const LIST_TRUNCATION_REMEDY = "narrow the selection or raise --max-bytes"; // A cut the cap could not make resumable still has a way out, and on the commands that know a diff --git a/packages/client/README.md b/packages/client/README.md index 2f9795a6..62484c5d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -306,7 +306,9 @@ try { } ``` -A parameter an older server rejects is sent, and the rejection is explained the same way: `mb.revision.list("measure", 1)` on a server without measures, `mb.dependency.graph("measure", 1)` below 59, or a tier-named `data_layer` (`final`, `internal`, `hidden`, `TableDataLayerTier`) on a server that still speaks medallions (`gold`, `silver`, `bronze`, `copper`, `TableDataLayerMedallion`) throws the `CapabilityError` naming the feature. A parameter a server without it would drop or rewrite without a word is refused before any request leaves, judged against a profile the client probed itself rather than one handed in: `mb.table.list({ "can-query": true })` asks for the access filters below 59 (a filter set to `false` asks for nothing), `{ "unused-only": true }` for the `dependencies` token, `mb.table.update` and `mb.table.bulkEdit` with `data_authority: null` for `tableUserValueWithdrawal` below 64, `mb.field.update(id, { data_sensitivity })` and `mb.field.setDataSensitivity` for the sensitivity column below 64, `mb.search.query({ verified: true })` for the `content_verification` token, and `mb.pulse.create` / `mb.pulse.update` with non-empty `parameters` for the `dashboard_subscription_filters` token, each with the `CapabilityError` naming the feature; without the parameter neither method consults the profile. `mb.table.update` refuses a medallion name on a server that names tiers as a `ConfigError`, because that server would map it onto a tier rather than reject it, and throws a `ConfigError` after the request when the table the server answers is not in the `collection_id` asked for, because some patch releases of every major before 62 drop the key without a word. +A parameter an older server rejects is sent, and the rejection is explained the same way: `mb.revision.list("measure", 1)` on a server without measures, `mb.dependency.graph("measure", 1)` below 59, or a tier-named `data_layer` (`final`, `internal`, `hidden`, `TableDataLayerTier`) on a server that still speaks medallions (`gold`, `silver`, `bronze`, `copper`, `TableDataLayerMedallion`) throws the `CapabilityError` naming the feature. A parameter a server without it would drop or rewrite without a word is refused before any request leaves, judged against a profile the client probed itself rather than one handed in: `mb.table.list({ "can-query": true })` asks for the access filters below 59 (a filter set to `false` asks for nothing), `{ "unused-only": true }` for the `dependencies` token, `mb.table.update` and `mb.table.bulkEdit` with `data_authority: null` for `tableUserValueWithdrawal` below 64, `mb.field.update(id, { data_sensitivity })` and `mb.field.setDataSensitivity` for the sensitivity column below 64, `mb.search.query({ verified: true })` for the `content_verification` token (a server granting `official_collections` alone takes the filter without applying it, and one granting neither answers 402, which is explained as above), and `mb.pulse.create` / `mb.pulse.update` with non-empty `parameters` for the `dashboard_subscription_filters` token, each with the `CapabilityError` naming the feature; without the parameter neither method consults the profile. A method whose whole answer a server without the feature would misread is refused the same way: `mb.gitSync.syncedCollections()` before any request (such a server lists its collections without `is_remote_synced`, which would read as an empty sync scope), and `mb.gitSync.branch()` / `mb.gitSync.trackedBranch()` by the profile their one read of the session properties settles, which reads the setting afresh (an EE server without the `remote_sync` token still shows its admin the setting), throw the `CapabilityError` for `remoteSync`. `mb.table.update` refuses a medallion name before the request on a server that names tiers, because that server would map it onto a tier rather than reject it, with a `CapabilityError` whose `RequirementFailure` has reason `superseded-by-feature`: the server has `tableDataLayerTiers`, which takes the value out of its vocabulary; and throws a `PartialWriteError` after the request when the table the server answers is not in the `collection_id` asked for, because some patch releases of every major before 62 drop the key without a word: the rest of the update was applied and repeating it still leaves the table where it was, so it is not retryable, and its `developerDetail.answered` says whether the server reported the collection the table stayed in or left the key out of its answer. + +These refusals before the request are the client's own judgement from its feature rules, which place a server by its major and its token, so a feature backported to an older release, or a rule that is wrong, is refused although the server would serve it. A host that knows better passes `ClientOptions.onPreflightSkipped(skipped)`: when it is set, each such refusal is handed to it instead of thrown as `{ kind: "refused", refusal }` (the `CapabilityError` it would have thrown), once for every feature the call lacks, and the call goes on, so the server answers it. A check whose probe fails short of an interrupt is handed over as `{ kind: "unverified", failure }` and the call goes on unchecked; an interrupt still ends it. The exceptions are `mb.gitSync.branch()` and `mb.gitSync.trackedBranch()`, whose read of the session properties is the answer itself, so a failed read fails them. The argument is a `SkippedPreflight`. A server that truly lacks the feature may then ignore or rewrite what it does not support and answer without saying so, which is why the hook exists rather than a switch: the host can tell its user what was let through. It is called synchronously and not awaited, and a hook that throws fails the call with that error. It never sees input the client rejects, a refusal read from the server's own state (pivoted exports turned off, an unset or unreadable tracked branch), the `PartialWriteError` judged from an answer, or a refusal the server answered, which is explained as above whether or not the hook is set. A `CapabilityError` the client refused with before the request carries no `cause`; one explaining a server's refusal carries that `HttpError` as its `cause`. A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape. @@ -377,12 +379,12 @@ Types: `MetabaseClient`, `Transport`, `ClientCredentials`, `ClientOptions`, `Req `MetabaseError` is the abstract root of the taxonomy: `NetworkError`, `TimeoutError`, `ValidationError`, `ResponseShapeError`, `ConfigError`, `InternalError`, `AbortError`, -`ChainedRequestError`, `UnknownError`, and `HttpError`. Every one carries a `category` +`ChainedRequestError`, `UnknownError`, `PartialWriteError`, `CapabilityError`, and `HttpError`. Every one carries a `category` (`ErrorCategory`), an `isRetryable`, a `userMessage`, and a `developerDetail` typed per class — `NetworkErrorDetail`, `TimeoutErrorDetail` (the `HttpTimeoutDetail | PollingTimeoutDetail` union), `ValidationErrorDetail`, `ResponseShapeErrorDetail` (the `ZodResponseShapeDetail | DecodedResponseShapeDetail` union), `UnknownErrorDetail`, -`HttpErrorDetail`, and `null` where the class has nothing to add. +`HttpErrorDetail`, `PartialWriteDetail`, `RequirementFailure`, and `null` where the class has nothing to add. `ResponseShapeError` covers every 2xx whose payload the client cannot read: a schema parse failure carries the request context and the Zod issues, and a hand-decoded body or header — a bare scalar, a @@ -396,6 +398,10 @@ carries the status and response body, redacted of known secrets at construction, status 404. `ChainedRequestError` wraps a cause and delegates its category and retryability to it. `toMetabaseError(unknown)` normalizes a thrown value into the taxonomy. +An `HttpError`'s `serverMessage` is what the server itself said about the failure — its envelope's message or field errors, or a plain-text body — and `null` when it said nothing and the message is the client's own. + +`PartialWriteError` (category `partial-write`, not retryable) is a write the server answered as a success having applied all but one field: neither a failure nor a success, and repeating it rewrites what landed while leaving that field as it was. Its `PartialWriteDetail` names the `method`, `path` and `field`, the value `requested`, and in `answered` a `PartialWriteAnswer`: `PartialWriteReported` (`{ kind: "reported", value: number | null }`, the value the server's answer reported, `null` when it reported the field empty) or `PartialWriteUnreported` (`{ kind: "unreported" }`, an answer that left the field out). + An `HttpError` also carries `errorCode`, the envelope's `error-code` when the throw site published a machine-readable name for the answer, else `null`. A transform test's `create`, `update` and `run` refuse that way when the server declines to run the test at all, and `isTransformTestRefusalCode(code)` narrows the code to the closed `TransformTestRefusalCode` vocabulary. `create` and `update` answer the authoring refusals at 400 (`transform-test.missing-inputs`, `unused-inputs`, `duplicate-input-table`, `unparseable-source`, `unremapped-reference`) and the environment ones at 422 (`unsupported-transform`, `unsupported-driver`); `run` adds `setup-failed` and `transform-failed`, also 422. The remaining names in the vocabulary (`unknown-column`, `ambiguous-column`, `unsupported-format`, `expectation-failed`) never refuse a run: they arrive as one expectation's own `error`, whose `type` is the server's internal name for the same code. A failing expectation is a result, not a refusal: `run` answers a `TransformTestRunResult` whose `status` is `failed` and whose expectations report what they found. Two helpers read raw thrown values away from the HTTP boundary. `isFileNotFoundError(value)` reports a @@ -404,6 +410,8 @@ Node `ENOENT` filesystem error — a missing file on disk, never an HTTP 404, wh which would put `@types/node` on every consumer's dependency list. `errorMessage(value)` reads a message off an unknown throw. +`isNonInterruptFailure(value)`, at `@metabase/client/errors`, narrows a thrown value to a `MetabaseError` that is not an `AbortError`: what a caller treating a request as optional sets aside, while an interrupt still ends the work and a throw outside the taxonomy still surfaces. + ### Auth `oauthLogin(input, deps)` runs the loopback-redirect authorization-code flow with PKCE and returns @@ -418,11 +426,11 @@ Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo`: the version, the edition the tag stamps, the build date and hash, and the token-feature map, each but the version `null` when the server does not report it. `parseTag(tag)` reads a tag into a `ServerVersion`, a `ReleaseVersion` (`{ kind: "release", tag, major, patch }`) or a `DevelopmentBuild` (`{ kind: "development", tag }`); `describeVersion(version)` words it for a message (the tag, or `development build `); `StoredServerVersion` reads a persisted version back by re-deriving it from its tag; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile a read chooses its shape by: the newest probe this client ran, else the one passed as `ClientOptions.server`, else one probed now; a probe in flight is awaited, and its failure falls back to the profile it would have replaced; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in, memoized until the client reads the session properties again (a git-sync branch read does), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. `ClientOptions.onServerProbed(info, profile)` is called with the `ServerInfo` of every probe the client runs and the `ServerProfile` derived from it, so a consumer that passes a cached profile can keep its cache current: only the newest probe started reaches it, so it never sees an older answer after a newer one; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not kept. A shape error names the profile in force when its request started — the newest probe settled, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile a read chooses its shape by: the profile in force, which is the newest probe answer this client applied, else the one passed as `ClientOptions.server`, else one probed now; the newest probe started is awaited while it is in flight, and its failure falls back to the profile in force by then; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in: every caller shares the newest probe started, in flight or settled, and when it fails the newest answer the client applied stands in for it. That answer is memoized until the client reads the session properties again (a git-sync branch read does, and so does a pivoted export's check of the `enable-pivoted-exports` setting), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. Probes are ordered by when they started, and an answer is applied unless a probe started after it has been applied already. `ClientOptions.onServerProbed(info, profile)` is called with the `ServerInfo` of every probe answer the client applies and the `ServerProfile` derived from it, so a consumer that passes a cached profile can keep its cache current: it never sees an older answer after a newer one, and an answer outliving a later probe that failed still reaches it; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not applied. `client.probesSettled()` resolves once no probe the client started is in flight, whatever each answered, and starts none, so a consumer about to exit can wait for every call of the hook first; each probe is bounded by its own timeout and the client's signal. A shape error names the profile in force when its request started — the newest probe answer applied, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(major, tokenFeatures)` evaluates it for a `RuleMajor` (a release major, or `"development"` for a build ahead of every release), `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. -`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on a value outside an older enum or a body an older route reads in another shape), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A 400 is also an ordinary validation failure, so it stands as the server's own `HttpError` when its field errors (`fieldErrors`, `specificFieldErrors`) name a field the missing parameter feature does not gate: a `mb.table.update` carrying a tier-named `data_layer` to a server that speaks medallions, alongside another field that server rejects, throws that field's rejection rather than the tier floor. A 400 that names no field, or one from a server lacking a feature the method itself needs, is the refusal. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check. +`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on a value outside an older enum or a body an older route reads in another shape), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A 400 is also an ordinary validation failure, so it stands as the server's own `HttpError` when its field errors (`fieldErrors`, `specificFieldErrors`) name a field the missing parameter feature does not gate: a `mb.table.update` carrying a tier-named `data_layer` to a server that speaks medallions, alongside another field that server rejects, throws that field's rejection rather than the tier floor. A 400 that names no field, or one from a server lacking a feature the method itself needs, is the refusal. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), answering the first feature the profile lacks, and `featureFailures(features, profile)` answers every one, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check. A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `fieldDetailSchema(features)` reads a `FieldDetail`, which is a `FieldWithDataSensitivity` (its `data_sensitivity` `null` for an unlabelled field) on a generation with the label and a plain `Field` without the key elsewhere, told apart by `hasDataSensitivity`, `fieldRemappedValueSchema(features, sameField)` reads a `FieldRemappedValue` from the first cells of a row a generation pads with display columns, `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 691d9bdf..f5d4408d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -86,6 +86,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) user: userResource(transport), server: transport.server, verifiedServer: transport.verifiedServer, + probesSettled: transport.probesSettled, // The escape hatch: an endpoint the client never models still has to cost a consumer nothing. requestParsed: transport.requestParsed, requestRaw: transport.requestRaw, diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 07e7461a..b33664bf 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -234,6 +234,13 @@ export class AbortError extends MetabaseError { } } +// A failure from this taxonomy that is not an interrupt: what a caller treating a request as +// optional sets aside. An interrupt is the user's own and still ends the work, and an error outside +// the taxonomy is a bug and still surfaces. +export function isNonInterruptFailure(error: unknown): error is MetabaseError { + return error instanceof MetabaseError && !(error instanceof AbortError); +} + export class ChainedRequestError extends MetabaseError { override readonly cause: MetabaseError; @@ -268,13 +275,26 @@ export class UnknownError extends MetabaseError { } } +// The value the server's answer reported for the field it left unwritten, `null` when it reported +// the field empty. +export interface PartialWriteReported { + kind: "reported"; + value: number | null; +} + +// The server's answer left the field out, so it said nothing of where the write left it. +export interface PartialWriteUnreported { + kind: "unreported"; +} + +export type PartialWriteAnswer = PartialWriteReported | PartialWriteUnreported; + export interface PartialWriteDetail { method: string; path: string; field: string; requested: number; - // `null` for a table the server answered in no collection, the field left out included. - answered: number | null; + answered: PartialWriteAnswer; } // The server answered success having written all but one field, so the request is neither a diff --git a/packages/client/src/http/errors.ts b/packages/client/src/http/errors.ts index b104cf5f..6b30580d 100644 --- a/packages/client/src/http/errors.ts +++ b/packages/client/src/http/errors.ts @@ -21,7 +21,17 @@ interface StatusClassification { message?: string; } -const NOT_FOUND_STATUS = 404; +export const BAD_REQUEST_STATUS = 400; +export const UNAUTHORIZED_STATUS = 401; +export const PAYMENT_REQUIRED_STATUS = 402; +export const FORBIDDEN_STATUS = 403; +export const NOT_FOUND_STATUS = 404; +export const CONFLICT_STATUS = 409; +export const TOO_MANY_REQUESTS_STATUS = 429; +export const INTERNAL_SERVER_ERROR_STATUS = 500; + +const SERVER_ERROR_STATUSES_START = 500; +const SERVER_ERROR_STATUSES_END = 600; const TEXT_CONTENT_TYPE = "text/plain"; const ROUTE_MISSING_LITERAL = "API endpoint does not exist."; @@ -137,19 +147,25 @@ export class HttpError extends MetabaseError { readonly category = "http"; readonly status: number; readonly kind: HttpErrorKind; + // What the server itself said about the failure, from its envelope's message or field errors or + // a plain-text body, or `null` when it said nothing and the message is this client's own. + readonly serverMessage: string | null; readonly developerDetail: HttpErrorDetail; constructor(input: HttpErrorInput) { const sanitizedBody = sanitizeBody(input.rawBody, input.redactionContext); const redactedHeaders = redactHeaders(input.responseHeaders); const kind = classifyKind(input.status, sanitizedBody, redactedHeaders); - super( - input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody, redactedHeaders), - ); + const said: ServerSaid = { + envelope: parseEnvelopeMessage(sanitizedBody), + text: plainTextMessage(sanitizedBody, redactedHeaders), + }; + super(input.overrideUserMessage ?? buildUserMessage(kind, input, said)); const fields = extractEnvelopeViews(sanitizedBody); this.name = "HttpError"; this.status = input.status; this.kind = kind; + this.serverMessage = said.envelope ?? said.text; this.developerDetail = { status: input.status, statusText: input.statusText, @@ -244,13 +260,13 @@ function classifyKind( ): HttpErrorKind { // 401: Metabase did not accept the credential. 403: it identified the user and refused the // request. 409: the request conflicts with existing state. None of the last two is about the key. - if (status === 401) { + if (status === UNAUTHORIZED_STATUS) { return "auth"; } - if (status === 403) { + if (status === FORBIDDEN_STATUS) { return "forbidden"; } - if (status === 409) { + if (status === CONFLICT_STATUS) { return "conflict"; } if (status === NOT_FOUND_STATUS) { @@ -258,10 +274,10 @@ function classifyKind( ? "route-missing" : "resource-missing"; } - if (status === 429) { + if (status === TOO_MANY_REQUESTS_STATUS) { return "rate-limit"; } - if (status >= 500 && status < 600) { + if (status >= SERVER_ERROR_STATUSES_START && status < SERVER_ERROR_STATUSES_END) { return "server-error"; } return "generic"; @@ -290,12 +306,13 @@ function isRouteMissingResponse( return parseEnvelope(sanitizedBody) === null; } -function buildUserMessage( - kind: HttpErrorKind, - input: HttpErrorInput, - sanitizedBody: string | null, - redactedHeaders: Record, -): string { +// The message a failure body carries, read once for the user message and `serverMessage` both. +interface ServerSaid { + envelope: string | null; + text: string | null; +} + +function buildUserMessage(kind: HttpErrorKind, input: HttpErrorInput, said: ServerSaid): string { if (kind === "route-missing") { return buildRouteMissingMessage(input); } @@ -304,16 +321,14 @@ function buildUserMessage( } // Messages we generate read as full sentences ending in a period; messages quoted from a // Metabase response envelope (parseEnvelopeMessage) are passed through verbatim, periods or not. - const fromBody = parseEnvelopeMessage(sanitizedBody); - if (fromBody !== null) { - return fromBody; + if (said.envelope !== null) { + return said.envelope; } if (kind === "auth") { return `Invalid or unauthorized API key (host: ${hostFromUrl(input.url)}).`; } - const fromText = plainTextMessage(sanitizedBody, redactedHeaders); - if (fromText !== null) { - return fromText; + if (said.text !== null) { + return said.text; } return defaultMessageForStatus(input.status); } diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 3481bd1e..9763f25f 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -17,7 +17,6 @@ import { deferred } from "../testing/deferred"; import { captureFetch, jsonResponse, - premiumRefusalResponse, probeResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; @@ -47,11 +46,6 @@ function transportFailure(code: string, detail: string): TypeError { return failure; } -// A fetch answering every request with a fresh `response()`. -function answering(response: () => Response): typeof fetch { - return async () => response(); -} - // Bun rejects with one error carrying the syscall code itself, where Node nests it under `cause`. function bunTransportFailure(code: string, detail: string): Error { return Object.assign(new Error(detail), { code }); @@ -832,155 +826,6 @@ describe("createTransport.verifiedServer", () => { }); }); -describe("createTransport.explainRefusal", () => { - const LICENSED_63 = createServerProfile({ - edition: "ee", - version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, - date: null, - hash: null, - tokenFeatures: { remote_sync: true }, - }); - const UNLICENSED_63 = createServerProfile({ - edition: "ee", - version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, - date: null, - hash: null, - tokenFeatures: { remote_sync: false }, - }); - - // A transport whose first request fails as `failure` and whose later ones are `rest`, handed the - // licensed profile a cache would hold. - function transportOver(failure: Response, rest: typeof fetch) { - let first = true; - const urls: string[] = []; - const fetchImpl: typeof fetch = async (input, init) => { - urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); - if (first) { - first = false; - return failure; - } - return rest(input, init); - }; - const controller = new AbortController(); - const transport = createTransport(CONFIG, { - userAgent: TEST_USER_AGENT, - fetchImpl, - server: LICENSED_63, - signal: controller.signal, - }); - return { transport, urls, stop: () => controller.abort(new Error("test over")) }; - } - - async function failedRequest(transport: Transport): Promise { - return transport - .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { retries: 0 }) - .catch((caught: unknown) => caught); - } - - it("turns a premium refusal into the feature a fresh probe finds missing, whatever the cache said", async () => { - const { transport, urls } = transportOver( - premiumRefusalResponse("Remote Sync"), - answering(() => probeResponse(UNLICENSED_63)), - ); - const refusal = await failedRequest(transport); - - const explained = await transport.explainRefusal( - { parameters: [], method: ["remoteSync"] }, - refusal, - ); - - assert(explained instanceof CapabilityError, "expected CapabilityError"); - expect(explained.developerDetail).toEqual({ - reason: "missing-token-feature", - detail: - "This operation requires the 'remote_sync' premium feature (not enabled on this server).", - feature: "remoteSync", - since: 58, - tokenFeature: "remote_sync", - serverVersion: "v1.63.0", - }); - expect(urls).toEqual([ - "https://m.example.com/api/ee/remote-sync/branches", - `https://m.example.com${PROBE_PATH}`, - ]); - }); - - it("lets the server's error stand when a fresh probe finds every feature", async () => { - const { transport } = transportOver( - premiumRefusalResponse("Remote Sync"), - answering(() => probeResponse(LICENSED_63)), - ); - const refusal = await failedRequest(transport); - - expect( - await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), - ).toBe(refusal); - }); - - it("never blames a missing row on a feature, and asks the server nothing", async () => { - const { transport, urls } = transportOver( - new Response("Not found.", { status: 404, headers: { "content-type": "text/plain" } }), - answering(() => probeResponse(UNLICENSED_63)), - ); - const missing = await failedRequest(transport); - - expect( - await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, missing), - ).toBe(missing); - expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); - }); - - it("leaves a failure that is no refusal unexplained, and asks the server nothing", async () => { - const { transport, urls } = transportOver( - new Response("boom", { status: 500 }), - answering(() => probeResponse(UNLICENSED_63)), - ); - const failure = await failedRequest(transport); - - expect( - await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, failure), - ).toBe(failure); - expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); - }); - - it("lets the refusal stand when the explaining probe fails", async () => { - const { transport } = transportOver( - premiumRefusalResponse("Remote Sync"), - answering(() => new Response("boom", { status: 500 })), - ); - const refusal = await failedRequest(transport); - - expect( - await transport.explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal), - ).toBe(refusal); - }); - - it("surfaces the client-wide interrupt during the explaining probe rather than the refusal", async () => { - const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); - const refusal = await failedRequest(transport); - - const pending = transport - .explainRefusal({ parameters: [], method: ["remoteSync"] }, refusal) - .catch((caught: unknown) => caught); - stop(); - - const error = await pending; - assert(error instanceof AbortError, "expected AbortError"); - expect(error.message).toBe("test over"); - }); - - it("explains nothing for a call that needs no feature", async () => { - const { transport, urls } = transportOver( - premiumRefusalResponse("Remote Sync"), - answering(() => probeResponse(UNLICENSED_63)), - ); - const refusal = await failedRequest(transport); - - expect(await transport.explainRefusal({ parameters: [], method: [] }, refusal)).toBe(refusal); - expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); - }); -}); - describe("createTransport cancellation", () => { it("installs no process signal handler, so an embedder keeps its own Ctrl-C semantics", async () => { const before = process.listenerCount("SIGINT"); diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index 8dab7c64..30d3f77d 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -1,16 +1,27 @@ import type { ZodType } from "zod"; import { SessionProperties } from "../domain/session-properties"; -import { AbortError, errorMessage, MetabaseError, NetworkError, TimeoutError } from "../errors"; +import { + errorMessage, + isNonInterruptFailure, + type MetabaseError, + NetworkError, + TimeoutError, +} from "../errors"; import { JSON_CONTENT_TYPE } from "../json"; import { combineAborts, throwIfAborted, untilAborted } from "../signal"; import { normalizeUrl } from "../url"; import type { FeatureName } from "../version/features"; import { CapabilityError } from "../version/capability-error"; -import { probeProperties, serverInfoFromProperties, type ServerInfo } from "../version/probe"; +import { + PROBE_PATH, + probeBudget, + type ProbeOptions, + serverInfoFromProperties, + type ServerInfo, +} from "../version/probe"; import { createServerProfile, type ServerProfile } from "../version/profile"; -import { explainedFailure, mayBeRefusal } from "../version/refusal"; -import { type CallRequirement, checkFeatures } from "../version/requirement-check"; +import { featureFailures } from "../version/requirement-check"; import { assertCredentialHeaderSafe, @@ -20,14 +31,12 @@ import { type CredentialRefresher, } from "../auth/credential"; -import { HttpError, isRetryableStatus } from "./errors"; +import { HttpError, isRetryableStatus, UNAUTHORIZED_STATUS } from "./errors"; import { buildNetworkError, isConnectionClosed } from "./network-error"; -import { parseJsonResponse } from "./response-shape"; +import { parseJsonResponse, type ResponseContext } from "./response-shape"; import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries, type RetryOutcome } from "./retry"; import type { RedactionContext } from "./sanitize"; -const UNAUTHORIZED_STATUS = 401; - export type HttpMethod = "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ExpectedContentType = "json" | "text" | "binary"; @@ -64,39 +73,88 @@ export interface Transport { requestParsed(schema: ZodType, path: string, opts?: TransportRequestOptions): Promise; requestRaw(path: string, opts?: TransportRequestOptions): Promise; requestStream(path: string, opts?: TransportRequestOptions): Promise>; - // The profile a read chooses its shape by: the newest probe this client ran, else the one handed - // in, else one probed now. A probe in flight is awaited, and its failure falls back to the profile - // it would have replaced, so a read never fails on a probe it did not need. `signal` ends this - // caller's wait; the probe itself is the client's and is cancelled only by the client's own - // signal, so a later call still finds it settled. + // The profile a read chooses its shape by: the profile in force, which is the newest probe answer + // this client applied, else the one handed in, else one probed now. The newest probe started is + // awaited while it is in flight, and its failure falls back to the profile in force by then, so a + // read never fails on a probe it did not need. `signal` ends this caller's wait; the probe itself + // is the client's and is cancelled only by the client's own signal, so a later call still finds + // it settled. server(options?: WaitOptions): Promise; // A profile this client probed itself, never one handed in, for a verdict the server cannot give: // a profile cached before an upgrade or a license change would refuse what the server allows. - // Every caller shares the probe in flight and the newest one settled; a failed one is not kept. + // Every caller shares the newest probe started, in flight or settled. When it fails short of an + // interrupt, the newest answer this client applied stands in for it, or else the failure does; + // a call finding no probe and no answer starts one. verifiedServer(options?: WaitOptions): Promise; - // The session properties read afresh through `reader`, as a probe `verifiedServer` then shares, - // so a resource needing a setting they carry pays one request for both. The read is the caller's, - // so it runs under the caller's `timeoutMs` and `retries` as any request does; `signal` ends only - // this caller's wait, because the profile the read settles is the client's. + // The session properties read afresh, as a probe `server` and `verifiedServer` then share, and + // handed to this caller alone through `reader`, so a resource needing a setting they carry pays + // one request for both. The shared probe parses only what every caller needs, so a reader that + // rejects the answer fails this call with the `ResponseShapeError` a read of its own would raise + // and no other. The read runs on the probe's own budget, its timeout and no retries, unless the + // caller sets `timeoutMs` or `retries`; `signal` ends only this caller's wait, because the profile + // the read settles is the client's. probe(reader: ZodType, options?: ProbeReadOptions): Promise; - // Throws `CapabilityError` before the wire for features a method needs only because of the - // parameters it was handed, where a server without them would drop the parameter and answer as - // if it had never been sent. Judged against `verifiedServer`, never a profile handed in: a cache - // that outlived a license would pass a parameter the server now drops, which is the silent answer - // this check exists to prevent. An empty list resolves without consulting the server. + // Refuses with `CapabilityError`, through `refuseBeforeSending`, for features a method needs only + // because of the parameters it was handed, where a server without them would drop the parameter + // and answer as if it had never been sent. Judged against `preflightServer`, never a profile + // handed in: a cache that outlived a license would pass a parameter the server now drops, which + // is the silent answer this check exists to prevent. An empty list resolves without consulting + // the server. requireFeatures(features: readonly FeatureName[], options?: WaitOptions): Promise; - // The error a call failed with, as a `CapabilityError` carrying the server's answer when the - // server refused it for lacking one of the features `call` needs, and unchanged otherwise. The - // explaining probe is bounded by its own timeout and the client's signal; the error stands when - // the probe fails, and an interrupt surfaces as one. - explainRefusal(call: CallRequirement, error: unknown): Promise; + // The profile a refusal before the wire judges by: `verifiedServer`, unless it fails short of an + // interrupt while the caller skips such refusals (`onPreflightSkipped`). Then the caller is told + // the check could not run and this answers null, so the call goes on unchecked; an interrupt + // still ends it, and without the hook the failure does. + preflightServer(options?: WaitOptions): Promise; + // Throws `refusal`, made before the wire because the feature rules say the server would answer + // the call wrongly without a word, unless the caller set `onPreflightSkipped`: then the refusal + // is handed there and this returns, so the call goes on and the server answers it. Every refusal + // judged by the rules rather than by the server's own state comes through here or through + // `preflightServer`, which share the one decision whether the caller skips them. + refuseBeforeSending(refusal: CapabilityError): void; + // Resolves once no probe this client started is in flight, whatever each answered, and starts + // none, so a caller about to exit has seen every answer `onServerProbed` will be handed. Each + // probe is bounded by its own timeout and the client's signal, and so is the wait. + probesSettled(): Promise; } export type WaitOptions = Pick; export type ProbeReadOptions = Pick; -interface Probed { - properties: T; +// A check before the wire the caller skipped: one that would have refused, with the error it would +// have thrown, or one that could not run because the profile it judges by could not be had. +export interface PreflightRefused { + kind: "refused"; + refusal: CapabilityError; +} + +export interface PreflightUnverified { + kind: "unverified"; + failure: MetabaseError; +} + +export type SkippedPreflight = PreflightRefused | PreflightUnverified; + +// A JSON body read off the wire and not yet parsed, with what a shape error in it names. +interface JsonRead { + text: string; + context: ResponseContext; +} + +// The server a shape error names: its version tag and where that version sits in the known range. +type ServerNaming = Pick; + +// One probe's answer: the properties every caller needs, the profile they describe, and the body +// they came from, which a caller wanting a setting they carry parses again with its own reader. +interface Probed { + properties: SessionProperties; + profile: ServerProfile; + read: JsonRead; +} + +// A probe answer made the profile in force, with the order its probe started in. +interface AppliedProbe { + order: number; profile: ServerProfile; } @@ -139,13 +197,25 @@ export interface ClientOptions { // own. A shape error read under a profile names that profile, the one its reader was chosen for. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; - // Called with the answer of every probe this client runs and the profile derived from it, so a - // caller that handed in a cached profile can keep its cache as current as the server's last answer. Only the newest probe started - // reaches it, so it never sees an older answer after a newer one. It is called before the calls - // waiting on the probe resume and is not awaited: a caller with slow work to do (a cache write) - // keeps the promise and settles it itself. A hook that throws fails the probe, which is then not - // kept. + // Called with the answer of every probe this client applies and the profile derived from it, so a + // caller that handed in a cached profile can keep its cache as current as the server's last + // answer. An answer is applied unless a probe started after its own has been applied already, so + // the hook never sees an older answer after a newer one, and an answer outliving a later probe + // that failed still reaches it. It is called before the calls waiting on the probe resume and is + // not awaited: a caller with slow work to do (a cache write) keeps the promise and settles it + // itself, after `probesSettled` when it must see every call. A hook that throws fails the probe, + // which is then not applied. onServerProbed?: (info: ServerInfo, profile: ServerProfile) => void; + // When set, a refusal the client would make before sending, because its feature rules say the + // server cannot serve the call as asked, is handed here instead of thrown and the call goes on, + // for a caller that knows better than the rules: a backported feature, or a rule that is wrong. + // A server that truly lacks the feature may then drop or rewrite what it does not support and + // answer without saying so. A check whose profile cannot be had, the probe failing short of an + // interrupt, is handed here as unverified and the call goes on unchecked. It is called once for + // every feature a call lacks, or once for the check that could not run, and is not awaited; a + // hook that throws fails the call with that error. Refusals that read the server's own state, + // input the client rejects, and a refusal the server answers are never handed here. + onPreflightSkipped?: (skipped: SkippedPreflight) => void; // Cancels every request this client makes; composed with the per-request `signal` and the // timeout. A process-level interrupt is the caller's to own and to hand over here. signal?: AbortSignal; @@ -155,11 +225,15 @@ export function createTransport(config: ClientCredentials, options: ClientOption const baseUrl = normalizeUrl(config.url); assertCredentialHeaderSafe(config.credential); const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); - // The profile in force: the newest probe this client settled, else the one handed in. Errors name - // it without awaiting a pending probe, because a shape error inside the probe would wait on itself. + // The profile in force: the newest probe answer this client applied, else the one handed in. + // Errors name it without awaiting a pending probe, because a shape error inside the probe would + // wait on itself. let inForce: ServerProfile | null = options.server ?? null; - // This client's newest probe, in flight or settled, and dropped when it fails. - let newest: Promise> | null = null; + // This client's newest probe started, in flight or settled, and dropped when it fails. + let newest: Promise | null = null; + let probesStarted = 0; + let applied: AppliedProbe | null = null; + const inFlight = new Set>(); const getServerTag = options.getServerTag ?? (async () => tagOf(inForce)); const refreshCredential = options.refreshCredential; let credential = config.credential; @@ -328,35 +402,82 @@ export function createTransport(config: ClientCredentials, options: ClientOption }; } - // The newest probe started is the only one that may settle the profile in force and reach the - // hook, so an answer that lands after a later probe started never replaces it. A failed probe is - // dropped, so the next call asks again rather than replaying one transient failure for the life of - // the client. - function startProbe( - reader: ZodType, - budget: Pick, - ): Promise> { - const probed = probeProperties(transport, reader, budget).then((properties) => { - const info = serverInfoFromProperties(properties); - const profile = createServerProfile(info); - if (newest === probed) { - options.onServerProbed?.(info, profile); - inForce = profile; - } - return { properties, profile }; + async function readJson(path: string, opts: TransportRequestOptions = {}): Promise { + // A resource chooses its reader by the profile in force just before it calls here, so a shape + // error names that profile, whatever a probe settles while the request runs. + const chosenFor = inForce; + const { response, prepared } = await executeWithAuthRefresh(path, { + ...opts, + expectContentType: "json", }); + return { + text: await response.text(), + context: { + method: prepared.method, + url: prepared.url, + status: response.status, + ...namingOf(chosenFor), + }, + }; + } + + // With no profile in force, the tag is resolved only once a shape error asks for it. + function namingOf(profile: ServerProfile | null): ServerNaming { + if (profile === null) { + return { getServerTag, serverSkew: null }; + } + return { getServerTag: async () => profile.version.tag, serverSkew: profile.skew }; + } + + // Probes are ordered by when they started. An answer is applied, made the profile in force and + // handed to the hook, unless a later-started probe has been applied already, so neither ever goes + // back to an older answer, and an answer landing after a later probe failed still counts. + async function runProbe(order: number, budget: ProbeOptions): Promise { + const read = await readJson(PROBE_PATH, probeBudget(budget)); + const properties = await parseJsonResponse(read.text, SessionProperties, read.context); + const info = serverInfoFromProperties(properties); + const profile = createServerProfile(info); + if (applied === null || applied.order < order) { + options.onServerProbed?.(info, profile); + applied = { order, profile }; + inForce = profile; + } + return { properties, profile, read }; + } + + // Only the newest probe started is shared with the calls that come after it. A failed one is + // dropped, so the next call asks again rather than replaying one transient failure for the life + // of the client, unless an applied answer stands in for it. + function startProbe(budget: ProbeOptions): Promise { + probesStarted += 1; + const probed = runProbe(probesStarted, budget); newest = probed; - probed.then(undefined, () => { - if (newest === probed) { - newest = null; - } - }); + inFlight.add(probed); + probed.then( + () => { + inFlight.delete(probed); + }, + () => { + inFlight.delete(probed); + if (newest === probed) { + newest = null; + } + }, + ); return probed; } + function appliedProfile(): ServerProfile | null { + return applied === null ? null : applied.profile; + } + function verifying(): Promise { - const probing = newest ?? startProbe(SessionProperties, {}); - return probing.then((probed) => probed.profile); + const settled = appliedProfile(); + if (newest === null && settled !== null) { + return Promise.resolve(settled); + } + const probing = newest ?? startProbe({}); + return probing.then((probed) => probed.profile, fallBackTo(appliedProfile)); } function profileInForce(): Promise { @@ -367,7 +488,10 @@ export function createTransport(config: ClientCredentials, options: ClientOption if (newest === null) { return Promise.resolve(current); } - return newest.then((probed) => probed.profile, fallBackTo(current)); + return newest.then( + (probed) => probed.profile, + fallBackTo(() => inForce), + ); } async function server(wait: WaitOptions = {}): Promise { @@ -384,13 +508,20 @@ export function createTransport(config: ClientCredentials, options: ClientOption reader: ZodType, read: ProbeReadOptions = {}, ): Promise { - throwIfAborted(read.signal); - const budget = { - timeoutMs: read.timeoutMs ?? DEFAULT_TIMEOUT_MS, - retries: read.retries ?? DEFAULT_MAX_RETRIES, - }; - const probed = await untilAborted(startProbe(reader, budget), read.signal); - return probed.properties; + const { signal, ...budget } = read; + throwIfAborted(signal); + const probed = await untilAborted(startProbe(budget), signal); + // The body a reader rejects reported the server's version itself, so the error names that + // version, not the profile in force before the probe: a reader is chosen by no profile, and a + // cached one that predates an upgrade would pass the failure off as a read made under it. + const context = { ...probed.read.context, ...namingOf(probed.profile) }; + return parseJsonResponse(probed.read.text, reader, context); + } + + async function probesSettled(): Promise { + while (inFlight.size > 0) { + await Promise.allSettled(inFlight); + } } async function requireFeatures( @@ -400,28 +531,39 @@ export function createTransport(config: ClientCredentials, options: ClientOption if (features.length === 0) { return; } - const failure = checkFeatures(features, await verifiedServer(wait)); - if (failure !== null) { - throw new CapabilityError(failure); + const profile = await preflightServer(wait); + if (profile === null) { + return; + } + for (const failure of featureFailures(features, profile)) { + refuseBeforeSending(new CapabilityError(failure)); } } - // The server's own refusal is the verdict; the profile only names what was missing. - async function explainRefusal(call: CallRequirement, error: unknown): Promise { - if (!mayBeRefusal(call, error)) { - return error; - } - let profile: ServerProfile; + async function preflightServer(wait: WaitOptions = {}): Promise { try { - profile = await verifiedServer(); - } catch (probeError) { - if (probeError instanceof MetabaseError && !(probeError instanceof AbortError)) { - return error; + return await verifiedServer(wait); + } catch (error) { + if (!isNonInterruptFailure(error)) { + throw error; } - throw probeError; + skipOrThrow({ kind: "unverified", failure: error }); + return null; } - const failure = explainedFailure(call, profile, error); - return failure === null ? error : new CapabilityError(failure, error); + } + + function refuseBeforeSending(refusal: CapabilityError): void { + skipOrThrow({ kind: "refused", refusal }); + } + + // The one decision whether the caller skips checks before the wire: `skipped` is handed to the + // hook and the call goes on, or the error it carries is thrown. + function skipOrThrow(skipped: SkippedPreflight): void { + const hook = options.onPreflightSkipped; + if (hook === undefined) { + throw skippedError(skipped); + } + hook(skipped); } const transport: Transport = { @@ -429,26 +571,15 @@ export function createTransport(config: ClientCredentials, options: ClientOption verifiedServer, probe, requireFeatures, - explainRefusal, + preflightServer, + refuseBeforeSending, + probesSettled, async requestRaw(path, opts) { return (await executeWithAuthRefresh(path, opts ?? {})).response; }, async requestParsed(schema, path, opts) { - // A resource chooses its reader by the profile in force just before it calls here, so a - // shape error names that profile, whatever a probe settles while the request runs. - const chosenFor = inForce; - const { response, prepared } = await executeWithAuthRefresh(path, { - ...opts, - expectContentType: "json", - }); - const text = await response.text(); - return parseJsonResponse(text, schema, { - method: prepared.method, - url: prepared.url, - status: response.status, - getServerTag: chosenFor === null ? getServerTag : async () => chosenFor.version.tag, - serverSkew: chosenFor === null ? null : chosenFor.skew, - }); + const read = await readJson(path, opts); + return parseJsonResponse(read.text, schema, read.context); }, async requestStream(path, opts) { const { response, prepared } = await executeWithAuthRefresh(path, { @@ -468,11 +599,16 @@ export function createTransport(config: ClientCredentials, options: ClientOption return transport; } -// A probe that fails is no reason to fail a read the profile it would have replaced can serve; an -// interrupt still ends the read. -function fallBackTo(profile: ServerProfile): (error: unknown) => ServerProfile { +function skippedError(skipped: SkippedPreflight): MetabaseError { + return skipped.kind === "refused" ? skipped.refusal : skipped.failure; +} + +// A probe that fails is no reason to fail a call a profile already in hand can serve; an interrupt +// still ends the call, and so does the failure when there is no profile to fall back to. +function fallBackTo(fallback: () => ServerProfile | null): (error: unknown) => ServerProfile { return (error) => { - if (error instanceof MetabaseError && !(error instanceof AbortError)) { + const profile = fallback(); + if (profile !== null && isNonInterruptFailure(error)) { return profile; } throw error; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 46d07ccc..6d8f2944 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -7,9 +7,12 @@ export type { ClientOptions, ExpectedContentType, HttpMethod, + PreflightRefused, + PreflightUnverified, QueryValue, RequestOptions, ServerTagResolver, + SkippedPreflight, TransportRequestOptions, } from "./http/transport"; export type { ListResult } from "./list"; @@ -42,7 +45,10 @@ export type { ErrorCategory, HttpTimeoutDetail, NetworkErrorDetail, + PartialWriteAnswer, PartialWriteDetail, + PartialWriteReported, + PartialWriteUnreported, PollingTimeoutDetail, ResponseShapeErrorDetail, TimeoutErrorDetail, @@ -98,7 +104,7 @@ export { methodRequirements, } from "./version/requirements"; export type { MethodKey } from "./version/requirements"; -export { checkFeatures } from "./version/requirement-check"; +export { checkFeatures, featureFailures } from "./version/requirement-check"; export { CapabilityError, RequirementFailure, RequirementReason } from "./version/capability-error"; export type { Page, PaginateOptions } from "./paginate"; diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts index 35a60d68..f12ca42e 100644 --- a/packages/client/src/resources/card.ts +++ b/packages/client/src/resources/card.ts @@ -105,13 +105,13 @@ export function cardResource(transport: Transport) { params: CardExportParams, options: RequestOptions = {}, ): Promise> { + if (params.pivot_results) { + await assertPivotedExport(transport, format, options); + } await transport.requireFeatures( params.csv_include_bom ? ["exportCsvByteOrderMark"] : [], options, ); - if (params.pivot_results) { - await assertPivotedExport(transport, format, options); - } const body = new URLSearchParams({ parameters: JSON.stringify(params.parameters), format_rows: String(params.format_rows), diff --git a/packages/client/src/resources/dataset.ts b/packages/client/src/resources/dataset.ts index 548a9649..1772ebd4 100644 --- a/packages/client/src/resources/dataset.ts +++ b/packages/client/src/resources/dataset.ts @@ -77,13 +77,13 @@ export function datasetResource(transport: Transport) { params: DatasetExportParams, options: RequestOptions = {}, ): Promise> { + if (params.pivot_results) { + await assertPivotedExport(transport, format, options); + } await transport.requireFeatures( params.csv_include_bom ? ["exportCsvByteOrderMark"] : [], options, ); - if (params.pivot_results) { - await assertPivotedExport(transport, format, options); - } const exported = params.pivot_results ? { ...params.query, "was-pivot": true } : params.query; return transport.requestStream(`/api/dataset/${format}`, { ...options, diff --git a/packages/client/src/resources/dependency.ts b/packages/client/src/resources/dependency.ts index a269684a..ed33c166 100644 --- a/packages/client/src/resources/dependency.ts +++ b/packages/client/src/resources/dependency.ts @@ -14,8 +14,7 @@ import type { SortDirection } from "../domain/query"; import type { QueryValue, RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; -import { explainer } from "../version/refusal"; -import type { ParameterRequirement } from "../version/requirement-check"; +import { explainer, type ParameterRequirement } from "../version/refusal"; const DependencyNodeApiList = z.array(DependencyNode); const DependencyEntityApiList = z.array(DependencyEntity); diff --git a/packages/client/src/resources/field.ts b/packages/client/src/resources/field.ts index 337ddf29..b56d3ad4 100644 --- a/packages/client/src/resources/field.ts +++ b/packages/client/src/resources/field.ts @@ -13,6 +13,7 @@ import { FieldWithDataSensitivity, } from "../domain/field"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer } from "../version/refusal"; import { fetchOptionalParsed } from "./optional-parsed"; @@ -47,6 +48,8 @@ export interface FieldSetDataSensitivityParams { // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function fieldResource(transport: Transport) { + const { refuse } = explainer(transport, "field"); + /** * Get one field by id. A server that labels data sensitivity answers `data_sensitivity`, `null` * for an unlabelled field; any other answers the field without the key (`hasDataSensitivity`). @@ -90,7 +93,6 @@ export function fieldResource(transport: Transport) { params: FieldSetDataSensitivityParams, options: RequestOptions = {}, ): Promise { - await transport.requireFeatures(["fieldDataSensitivity"], options); return transport.requestParsed(FieldWithDataSensitivity, `/api/field/${id}`, { ...options, method: "PUT", @@ -156,7 +158,9 @@ export function fieldResource(transport: Transport) { return { get, update, - setDataSensitivity, + // A server without the column drops `data_sensitivity` from the update and answers the field + // as if the label had never been sent. + setDataSensitivity: refuse("setDataSensitivity", setDataSensitivity), search, remapping, summary, diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index 2edf5e59..0d6baaa3 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -293,7 +293,7 @@ describe("git-sync resource wire requests", () => { expect(capture.calls.map((call) => call.body)).toEqual(['{"expected_branch":"dev"}']); }); - it("sends no tracked branch to a server before the branch guard, which reads its own", async () => { + it("sends the tracked branch to a server before the branch guard, which ignores it", async () => { const { mb, capture } = clientOver([ trackedBranchResponse("main", SERVER), jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), @@ -301,7 +301,7 @@ describe("git-sync resource wire requests", () => { await mb.gitSync.import(); - expect(capture.calls.map((call) => call.body)).toEqual([null, "{}"]); + expect(capture.calls.map((call) => call.body)).toEqual([null, '{"expected_branch":"main"}']); }); it("sends no expected branch where the setting is hidden on a server without remote sync, and explains its refusal", async () => { @@ -356,7 +356,7 @@ describe("git-sync resource wire requests", () => { assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe( - "the remote-sync-branch setting is not readable: it is visible to admins only", + "the remote-sync-branch setting is not readable: only admins see it, and a server without remote sync has none", ); expect(capture.calls).toEqual([PROPERTIES_READ]); }); @@ -886,7 +886,7 @@ describe("git-sync resource wire requests", () => { expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe( - "the remote-sync-branch setting is not readable: it is visible to admins only", + "the remote-sync-branch setting is not readable: only admins see it, and a server without remote sync has none", ); }); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index d5cf88ff..0a34cc68 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -15,11 +15,12 @@ import { } from "../domain/git-sync"; import { SessionProperties } from "../domain/session-properties"; import { ConfigError } from "../errors"; -import { HttpError } from "../http/errors"; +import { FORBIDDEN_STATUS, HttpError, NOT_FOUND_STATUS } from "../http/errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import { type PollOptions, pollUntil } from "../poll"; import type { Features } from "../version/features"; +import { profileFromProperties } from "../version/probe"; import { explainer } from "../version/refusal"; import { listCollectionsAs } from "./collection"; @@ -63,27 +64,24 @@ const RemoteSyncSetting = z.string().nullable(); // `/api/setting/remote-sync-branch` answers nothing for a branch set by environment variable, while // the session properties carry every setting's effective value, and carry a setting only when the // caller may read it: an absent key is "not readable", a null one "unset". They are read as a -// probe, so the merge check and a refusal's explanation that follow the read need no request of -// their own. +// probe, so the features a branch read, an import or an export judges by that same answer, and a +// refusal's explanation that follows, need no request of their own. const RemoteSyncBranchProperty = SessionProperties.extend({ "remote-sync-branch": z.string().nullable().optional(), }); type RemoteSyncBranchProperty = z.infer; const BRANCH_UNREADABLE_MESSAGE = - "the remote-sync-branch setting is not readable: it is visible to admins only"; + "the remote-sync-branch setting is not readable: only admins see it, and a server without remote sync has none"; const BRANCH_UNSET_MESSAGE = "git-sync tracks no branch: the remote-sync-branch setting is unset"; -const FORBIDDEN_STATUS = 403; -const UNREGISTERED_STATUS = 404; - // The remote-sync settings are admin-readable only, and unregistered on servers without the // remote-sync module; both answers mean "no usable remote", not a failure of the caller's request. function isRemoteUnreadable(error: unknown): boolean { if (!(error instanceof HttpError)) { return false; } - return error.status === FORBIDDEN_STATUS || error.status === UNREGISTERED_STATUS; + return error.status === FORBIDDEN_STATUS || error.status === NOT_FOUND_STATUS; } // Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition @@ -124,7 +122,7 @@ export interface SyncCreateBranchParams { } export function gitSyncResource(transport: Transport) { - const { explain } = explainer(transport, "gitSync"); + const { explain, refuse, refuseAfterReading } = explainer(transport, "gitSync"); /** Get the running or most recently finished sync task, or null when the server has none. */ async function currentTask(options: RequestOptions = {}): Promise { @@ -174,17 +172,19 @@ export function gitSyncResource(transport: Transport) { * pass `wait` to poll that task until it reaches a terminal status. A server already up to date * answers no task id, and there is then nothing to poll. A server that guards the tracked branch * refuses with 409 when `expected_branch` disagrees with the setting; left out, it is the tracked - * branch as read just before the request, and the request is refused before the wire when the - * setting is unset or hidden from the caller. A server without the guard ignores the assertion, - * so it gets none unless the caller names one. `merge` folds the remote's - * changes into local content by a three-way merge and keeps un-pushed local changes, where a - * plain import refuses on a dirty instance. + * branch as read just before the request, on every server with remote sync. A server that guards + * the branch also requires it, so there the request is refused before the wire when the setting + * is unset or hidden from the caller; a server without the guard ignores the assertion, and gets + * none when there is none to read. `merge` folds the remote's changes into local content by a + * three-way merge and keeps un-pushed local changes, where a plain import refuses on a dirty + * instance. */ async function importFromRemote( params: SyncImportParams = {}, options: RequestOptions = {}, ): Promise { - const expectedBranch = await syncBranch(params.expected_branch, params.merge, options); + const expectedBranch = await expectedBranchFor(params.expected_branch, options); + await requireMerge(params.merge, options); const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { ...options, method: "POST", @@ -202,28 +202,48 @@ export function gitSyncResource(transport: Transport) { return { message, task_id: started.task_id, final: await settle(params.wait, options) }; } - // The branch to name when the caller named none: the tracked one, read from the session - // properties, and only for a server that guards it. Such a server requires it on an import and an - // export, so there an unset setting, or one hidden from a caller who is not an admin, is refused - // before the wire. An older server reads the setting itself in the same request; it ignores an - // import's assertion and takes an export's branch as a switch, so naming one read a moment - // earlier would undo a switch another admin made in between. The merge check and the features - // both read the profile the branch read settles, so neither costs a request. - async function syncBranch( + // The import's assertion when the caller named none: the tracked branch, sent on every server + // with remote sync, since one without the guard ignores it. + async function expectedBranchFor( given: string | undefined, - merge: boolean | undefined, options: RequestOptions, ): Promise { - const properties = - given === undefined ? await transport.probe(RemoteSyncBranchProperty, options) : null; - if (merge === true) { - await transport.requireFeatures(["remoteSyncMerge"], options); + if (given !== undefined) { + return given; } - if (properties === null) { + return importAssertion(await readTrackedBranch(options)); + } + + // The export's branch when the caller named none: the tracked one, only for a server that guards + // it. An older server reads the setting itself in the same request and takes an export's branch + // as a switch, so naming one read a moment earlier would undo a switch another admin made in + // between. + async function exportBranchFor( + given: string | undefined, + options: RequestOptions, + ): Promise { + if (given !== undefined) { return given; } - const { features } = await transport.verifiedServer(options); - return defaultBranch(properties["remote-sync-branch"], features); + return exportBranch(await readTrackedBranch(options)); + } + + // The features are judged by the profile this same read describes, and the merge check that + // follows finds this probe or a later one settled or in flight, so neither costs a request. + async function readTrackedBranch(options: RequestOptions): Promise { + const properties = await transport.probe(RemoteSyncBranchProperty, options); + return { + tracked: properties["remote-sync-branch"], + features: profileFromProperties(properties).features, + }; + } + + // An import or export before `remoteSyncMerge` ignores `merge` and runs a plain one, which on a + // dirty instance or a moved remote is the very outcome the merge was asked to avoid. + async function requireMerge(merge: boolean | undefined, options: RequestOptions): Promise { + if (merge === true) { + await transport.requireFeatures(["remoteSyncMerge"], options); + } } /** @@ -241,7 +261,8 @@ export function gitSyncResource(transport: Transport) { params: SyncExportParams = {}, options: RequestOptions = {}, ): Promise { - const branchName = await syncBranch(params.branch, params.merge, options); + const branchName = await exportBranchFor(params.branch, options); + await requireMerge(params.merge, options); const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { ...options, method: "POST", @@ -342,9 +363,6 @@ export function gitSyncResource(transport: Transport) { async function syncedCollections( options: RequestOptions = {}, ): Promise> { - // A server without remote sync lists its collections without `is_remote_synced`, which would - // read as an empty sync scope rather than as a server with no git-sync at all. - await transport.requireFeatures(["remoteSync"], options); const data = await listCollectionsAs(transport, SyncScopeCollection, options); return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; } @@ -372,12 +390,7 @@ export function gitSyncResource(transport: Transport) { * remote sync or the caller may not read the setting (it is admin-only), rather than reading * either as unset. */ - async function branch(options: RequestOptions = {}): Promise { - const properties = await transport.probe(RemoteSyncBranchProperty, options); - // An EE server without the remote_sync token still shows its admin the setting, which would - // read as a branch git-sync tracks; the profile the read just settled refuses it without - // another request. - await transport.requireFeatures(["remoteSync"], options); + function branch(properties: RemoteSyncBranchProperty): string | null { const tracked = properties["remote-sync-branch"]; if (tracked === undefined) { throw new ConfigError(BRANCH_UNREADABLE_MESSAGE); @@ -389,8 +402,8 @@ export function gitSyncResource(transport: Transport) { * The branch git-sync tracks, refused when none is configured, the server has no remote sync, or * the caller may not read it. */ - async function trackedBranch(options: RequestOptions = {}): Promise { - const tracked = await branch(options); + function trackedBranch(properties: RemoteSyncBranchProperty): string { + const tracked = branch(properties); if (tracked === null) { throw new ConfigError(BRANCH_UNSET_MESSAGE); } @@ -429,21 +442,48 @@ export function gitSyncResource(transport: Transport) { branches: explain("branches", branches), createBranch: explain("createBranch", createBranch), setCollectionSynced: explain("setCollectionSynced", setCollectionSynced), - syncedCollections, + // A server without remote sync lists its collections without `is_remote_synced`, which would + // read as an empty sync scope rather than as a server with no git-sync at all. + syncedCollections: refuse("syncedCollections", syncedCollections), remoteUrl, - branch, - trackedBranch, + // An EE server without the remote_sync token still shows its admin the setting, which would + // read as a branch git-sync tracks. The refusal judges the profile the setting's read settles, + // so it costs no request of its own. + branch: refuseAfterReading("branch", RemoteSyncBranchProperty, branch), + trackedBranch: refuseAfterReading("trackedBranch", RemoteSyncBranchProperty, trackedBranch), waitForTask: explain("waitForTask", waitForTask), }; } -function defaultBranch( - tracked: RemoteSyncBranchProperty["remote-sync-branch"], - features: Features, -): string | undefined { - if (!features.remoteSync || !features.remoteSyncBranchGuard) { +// The tracked branch as one read of the session properties found it, beside the features of the +// server that answered. +interface TrackedBranchRead { + tracked: RemoteSyncBranchProperty["remote-sync-branch"]; + features: Features; +} + +// Only a server that guards the branch rejects an import without the assertion, so only there is +// an unset or unreadable setting refused before the wire. +function importAssertion(read: TrackedBranchRead): string | undefined { + if (!read.features.remoteSync) { return undefined; } + if (read.features.remoteSyncBranchGuard) { + return requiredBranch(read.tracked); + } + return typeof read.tracked === "string" ? read.tracked : undefined; +} + +function exportBranch(read: TrackedBranchRead): string | undefined { + if (!read.features.remoteSync || !read.features.remoteSyncBranchGuard) { + return undefined; + } + return requiredBranch(read.tracked); +} + +// A server guarding the branch requires it, so an unset setting, or one hidden from a caller who +// is not an admin, is refused before the wire. +function requiredBranch(tracked: RemoteSyncBranchProperty["remote-sync-branch"]): string { if (typeof tracked === "string") { return tracked; } diff --git a/packages/client/src/resources/pivot-export.ts b/packages/client/src/resources/pivot-export.ts index 57dbffb5..ca375462 100644 --- a/packages/client/src/resources/pivot-export.ts +++ b/packages/client/src/resources/pivot-export.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import { type ExportFormat, PivotExportFormat } from "../domain/query"; +import { SessionProperties } from "../domain/session-properties"; import { ConfigError } from "../errors"; import type { RequestOptions, Transport } from "../http/transport"; -import { PROBE_PATH } from "../version/probe"; // The session properties carry every setting the caller may read; the check needs only this one, -// which every signed-in user may read. -const PivotedExportsSetting = z.object({ "enable-pivoted-exports": z.boolean() }); +// which every signed-in user may read. They are read as a probe, so the export's own features and a +// refusal's explanation that follow need no request of their own. +const PivotedExportsProperty = SessionProperties.extend({ "enable-pivoted-exports": z.boolean() }); /** * Refuse a pivoted export the server would answer with plain rows: a JSON export keeps only the @@ -23,10 +24,8 @@ export async function assertPivotedExport( `a ${format} export cannot be pivoted; only ${PivotExportFormat.options.join(" and ")} can`, ); } - const setting = await transport.requestParsed(PivotedExportsSetting, PROBE_PATH, { - ...options, - }); - if (!setting["enable-pivoted-exports"]) { + const properties = await transport.probe(PivotedExportsProperty, options); + if (!properties["enable-pivoted-exports"]) { throw new ConfigError( "the server has pivoted exports turned off (the enable-pivoted-exports setting), so it would export the plain rows", ); diff --git a/packages/client/src/resources/revision.ts b/packages/client/src/resources/revision.ts index 5700dfd9..732bd5e1 100644 --- a/packages/client/src/resources/revision.ts +++ b/packages/client/src/resources/revision.ts @@ -10,8 +10,7 @@ import { import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; -import { explainer } from "../version/refusal"; -import type { ParameterRequirement } from "../version/requirement-check"; +import { explainer, type ParameterRequirement } from "../version/refusal"; // `GET /api/revision/{entity}/{id}` answers a bare array, newest first, that the server does not // count. diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index 672e127f..f6d7c52a 100644 --- a/packages/client/src/resources/search.ts +++ b/packages/client/src/resources/search.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { type SearchModel, SearchResult } from "../domain/search"; import type { RequestOptions, Transport } from "../http/transport"; +import { explainer, type ParameterRequirement } from "../version/refusal"; // `GET /api/search` applies the window itself and reports the count across the whole result set, // so `total` is the server's and never the returned slice's length. It is required rather than @@ -33,7 +34,18 @@ export interface SearchParams { include_dashboard_questions?: boolean | undefined; } +// Every server lacking both `content_verification` and `official_collections` answers 402 for a +// `verified` filter, which the parameter's feature explains. One granting `official_collections` +// alone takes the filter but applies it only under `content_verification`, answering unfiltered +// results, or none on the legacy in-place engine, without a word, so the parameter is also refused +// before the wire. +function verifiedFeatures(params: SearchParams = {}): ParameterRequirement[] { + return params.verified === true ? [{ feature: "contentVerification", fields: ["verified"] }] : []; +} + export function searchResource(transport: Transport) { + const { explain } = explainer(transport, "search"); + /** * Search over the instance's content, ranked against `q`. `models` narrows which kinds of entity * may match, `archived` swaps the active set for the archived one, `table_db_id` restricts to @@ -44,17 +56,17 @@ export function searchResource(transport: Transport) { * `include_dashboard_questions` also matches questions saved into a dashboard (excluded by * default), and `limit`/`offset` are the window the server applies before ranking hydration. * `verified` needs the `content_verification` premium feature, and is refused before the wire - * without it. + * without it: a server granting neither it nor `official_collections` refuses the filter, and one + * granting `official_collections` alone takes it without applying it. */ async function query( params: SearchParams = {}, options: RequestOptions = {}, ): Promise { - // A server without content verification drops the filter and answers unverified results as - // if they had been filtered. - if (params.verified === true) { - await transport.requireFeatures(["contentVerification"], options); - } + await transport.requireFeatures( + verifiedFeatures(params).map((requirement) => requirement.feature), + options, + ); const response = await transport.requestParsed(SearchApiResponse, "/api/search", { ...options, query: { @@ -75,5 +87,5 @@ export function searchResource(transport: Transport) { return { data: response.data, total: response.total }; } - return { query }; + return { query: explain("query", query, verifiedFeatures) }; } diff --git a/packages/client/src/resources/table.test.ts b/packages/client/src/resources/table.test.ts index d6abe968..e3cc7520 100644 --- a/packages/client/src/resources/table.test.ts +++ b/packages/client/src/resources/table.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; -import { ConfigError, PartialWriteError } from "../errors"; +import { PartialWriteError } from "../errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, @@ -431,9 +431,9 @@ describe("table resource wire requests", () => { const error = await thrownBy(() => mb.table.update(11, { data_layer: "gold" })); - assert(error instanceof ConfigError, "expected ConfigError"); + assert(error instanceof CapabilityError, "expected CapabilityError"); expect(error.message).toBe( - 'data_layer "gold" is a medallion name; this server names a table\'s layer final, internal, hidden', + 'data_layer "gold" is a medallion name; this server names a table\'s layer final, internal, hidden and would map it onto one of those without a word', ); expect(capture.calls.map((call) => call.url)).toEqual([PROBE_URL]); }); @@ -476,7 +476,7 @@ describe("table resource wire requests", () => { assert(error instanceof PartialWriteError, "expected PartialWriteError"); expect(error.message).toBe( - "the server applied the rest of the update to table 11 but not collection_id: the table stays in no collection, because this server does not move a table to a collection through an update", + "the server applied the rest of the update to table 11 but not collection_id: it answered the table without reporting a collection, because this server does not move a table to a collection through an update", ); expect(capture.calls.map((call) => call.url)).toEqual([TABLE_URL]); }); diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 855fadcb..54193b31 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -16,12 +16,13 @@ import { type TableUpdateInput, } from "../domain/table"; import type { UploadUpdateAction, UploadUpdateResult } from "../domain/upload"; -import { ConfigError, PartialWriteError } from "../errors"; +import { type PartialWriteAnswer, PartialWriteError } from "../errors"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; import type { FeatureName } from "../version/features"; -import { explainer } from "../version/refusal"; -import type { ParameterRequirement } from "../version/requirement-check"; +import { CapabilityError } from "../version/capability-error"; +import { explainer, type ParameterRequirement } from "../version/refusal"; +import { supersedingFeature } from "../version/requirement-check"; import { buildCsvFormData, type CsvFile } from "./csv-upload"; import { fetchOptionalParsed } from "./optional-parsed"; @@ -81,9 +82,11 @@ function dataLayerFeatures( value: TableDataLayer | null | undefined, field: keyof TableListParams | keyof TableUpdateInput, ): ParameterRequirement[] { - const isTierName = - value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success; - return isTierName ? [{ feature: "tableDataLayerTiers", fields: [field] }] : []; + return isTierName(value) ? [{ feature: "tableDataLayerTiers", fields: [field] }] : []; +} + +function isTierName(value: TableDataLayer | null | undefined): boolean { + return value !== null && value !== undefined && TableDataLayerTier.safeParse(value).success; } const UPLOAD_UPDATE_PATHS: Record = { @@ -156,14 +159,18 @@ export function tableResource(transport: Transport) { value: TableDataLayer | null | undefined, options: RequestOptions, ): Promise { - if (value === null || value === undefined || TableDataLayerTier.safeParse(value).success) { + if (value === null || value === undefined || isTierName(value)) { + return; + } + const profile = await transport.preflightServer(options); + if (profile === null) { return; } - const { features } = await transport.verifiedServer(options); - if (features.tableDataLayerTiers) { - throw new ConfigError( - `data_layer "${value}" is a medallion name; this server names a table's layer ${TableDataLayerTier.options.join(", ")}`, - ); + const tiers = TableDataLayerTier.options.join(", "); + const detail = `data_layer "${value}" is a medallion name; this server names a table's layer ${tiers} and would map it onto one of those without a word`; + const failure = supersedingFeature("tableDataLayerTiers", profile, detail); + if (failure !== null) { + transport.refuseBeforeSending(new CapabilityError(failure)); } } @@ -398,10 +405,22 @@ function assertCollectionWritten( if (requested === undefined || table.collection_id === requested) { return; } - const answered = table.collection_id ?? null; - const stayed = answered === null ? "in no collection" : `in collection ${answered}`; + const answered: PartialWriteAnswer = + table.collection_id === undefined + ? { kind: "unreported" } + : { kind: "reported", value: table.collection_id }; throw new PartialWriteError( - `the server applied the rest of the update to table ${table.id} but not collection_id: the table stays ${stayed}, because this server does not move a table to a collection through an update`, + `the server applied the rest of the update to table ${table.id} but not collection_id: ${describeCollectionAnswer(answered)}, because this server does not move a table to a collection through an update`, { method: "PUT", path, field: "collection_id", requested, answered }, ); } + +function describeCollectionAnswer(answered: PartialWriteAnswer): string { + if (answered.kind === "unreported") { + return "it answered the table without reporting a collection"; + } + if (answered.value === null) { + return "the table stays in no collection"; + } + return `the table stays in collection ${answered.value}`; +} diff --git a/packages/client/src/testing/fake-client.ts b/packages/client/src/testing/fake-client.ts index 01979f57..4a350135 100644 --- a/packages/client/src/testing/fake-client.ts +++ b/packages/client/src/testing/fake-client.ts @@ -1,5 +1,6 @@ import type { ZodType } from "zod"; +import { ConfigError } from "../errors"; import { type Transport, DEFAULT_METHOD, @@ -56,8 +57,11 @@ export interface FakeFeatureRequirement { readonly precedingRequests: number; } -// The fake records what a method required and never refuses or explains: both belong to the real -// transport and are proven there, so a resource test needs no profile to reach its wire assertions. +// The fake records what a method required and never refuses: that belongs to the real transport and +// is proven there, so a resource test needs no profile to reach its wire assertions. A refusal a +// resource judged itself is thrown, as a transport whose caller skips none throws it. A failure is +// explained for real against the plan's profile; without one the explaining probe fails short of an +// interrupt, so the failure stands. export interface FakeClient { readonly client: Transport; readonly calls: ReadonlyArray; @@ -111,7 +115,10 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { return plannedServer(); }, async verifiedServer() { - return plannedServer(); + if (plan.server === undefined) { + throw new ConfigError("no server profile in fake client plan"); + } + return plan.server; }, async probe(reader) { return client.requestParsed(reader, PROBE_PATH); @@ -119,9 +126,13 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { async requireFeatures(features) { requiredFeatures.push({ features, precedingRequests: calls.length }); }, - async explainRefusal(_call, error) { - return error; + async preflightServer() { + return client.verifiedServer(); + }, + refuseBeforeSending(refusal) { + throw refusal; }, + async probesSettled() {}, }; return { client, calls, requiredFeatures }; } diff --git a/packages/client/src/testing/fetch-capture.ts b/packages/client/src/testing/fetch-capture.ts index c047ee6a..4da3e250 100644 --- a/packages/client/src/testing/fetch-capture.ts +++ b/packages/client/src/testing/fetch-capture.ts @@ -1,11 +1,9 @@ // Test-only fetch double (the keyring-mock pattern): scripted responses plus a capture of every // call, shared by the client/oauth/logout suites so each doesn't grow its own drifting stub. +import { NOT_FOUND_STATUS, PAYMENT_REQUIRED_STATUS } from "../http/errors"; import type { ServerProfile } from "../version/profile"; -const NOT_FOUND_STATUS = 404; -const PAYMENT_REQUIRED_STATUS = 402; - // A caller identity no production code could produce, so a hardcoded fallback cannot fake it. export const TEST_USER_AGENT = "some-embedder/9.9.9"; diff --git a/packages/client/src/version/capability-error.ts b/packages/client/src/version/capability-error.ts index 6e77ad80..b2272c70 100644 --- a/packages/client/src/version/capability-error.ts +++ b/packages/client/src/version/capability-error.ts @@ -1,19 +1,21 @@ import { z } from "zod"; import { MetabaseError } from "../errors"; -import type { HttpError } from "../http/errors"; +import { BAD_REQUEST_STATUS, type HttpError } from "../http/errors"; import { FEATURE_NAMES } from "./features"; -// The one refusal status that is also an ordinary validation failure: a server lacking a parameter's -// feature rejects the unknown parameter with it, and so does any server rejecting a bad value. -export const BAD_REQUEST_STATUS = 400; - -export const RequirementReason = z.enum(["version-too-old", "missing-token-feature"]); +export const RequirementReason = z.enum([ + "version-too-old", + "missing-token-feature", + "superseded-by-feature", +]); export type RequirementReason = z.infer; // The feature a client method needed and the profile lacked, with the rule that decided it, so a -// consumer can explain the refusal without re-reading the feature table. A schema rather than a +// consumer can explain the refusal without re-reading the feature table. `superseded-by-feature` +// is the converse: the profile has the feature, which takes the value the call carried out of the +// server's vocabulary. A schema rather than a // bare type because a consumer that reports the refusal in its own output wants to describe it. export const RequirementFailure = z.object({ reason: RequirementReason, @@ -34,9 +36,9 @@ export function missingTokenFeatureMessage(tokenFeature: string): string { } // An explained refusal carries the server's answer as its `cause`. A 400 may have rejected the call -// for a reason other than the missing feature, so its message also quotes what the server said; a -// 402 or an unrouted 404 says nothing the requirement does not. The field errors stay structured on -// the cause rather than being re-rendered here. +// for a reason other than the missing feature, so its message also quotes what the server said, +// when it said anything; a 402 or an unrouted 404 says nothing the requirement does not. The field +// errors stay structured on the cause rather than being re-rendered here. export class CapabilityError extends MetabaseError { readonly category = "capability"; readonly isRetryable = false; @@ -53,5 +55,9 @@ function capabilityMessage(failure: RequirementFailure, refusal: HttpError | nul if (refusal === null || refusal.status !== BAD_REQUEST_STATUS) { return failure.detail; } - return `${failure.detail}\nMetabase answered ${refusal.status}: ${refusal.message}`; + const said = refusal.serverMessage; + if (said === null) { + return failure.detail; + } + return `${failure.detail}\nMetabase answered ${refusal.status}: ${said}`; } diff --git a/packages/client/src/version/explain-guard.test.ts b/packages/client/src/version/explain-guard.test.ts index d7ca4a49..c68d4bf6 100644 --- a/packages/client/src/version/explain-guard.test.ts +++ b/packages/client/src/version/explain-guard.test.ts @@ -9,27 +9,20 @@ import { isMethodKey, METHOD_KEYS, methodRequirements } from "./requirements"; const RESOURCES = resolve(dirname(fileURLToPath(import.meta.url)), "..", "resources"); const NAMESPACE = /explainer\(transport, "(\w+)"\)/; -// `explain("", ` or `explainWalk("", `, and whether a parameter-feature -// callback follows the method. -const EXPLAINED = /\bexplain(?:Walk)?\(\s*"(\w+)",\s*\w+\s*(,)?/g; - -// Methods whose whole requirement guards an answer a server without it would give wrongly without -// a word, so they refuse before the request through `requireFeatures` and the server never gets to -// refuse them. -const REFUSED_BEFORE_THE_REQUEST: ReadonlySet = new Set([ - "field.setDataSensitivity", - "gitSync.syncedCollections", - "gitSync.branch", - "gitSync.trackedBranch", -]); +// `explain("", `, `explainWalk("", ` or `refuse("", `, and whether a +// parameter-feature callback follows the method; or `refuseAfterReading(""`, whose reader +// comes before the method and which takes no callback. +const WRAPPED = + /\b(?:(?:explain(?:Walk)?|refuse)\(\s*"(\w+)",\s*\w+\s*(,)?|refuseAfterReading\(\s*"(\w+)",)/g; interface Wiring { - explained: Set; + // One entry per wrapping, so a method wrapped twice appears twice. + wrapped: string[]; explainedWithParameters: Set; } function readWiring(): Wiring { - const wiring: Wiring = { explained: new Set(), explainedWithParameters: new Set() }; + const wiring: Wiring = { wrapped: [], explainedWithParameters: new Set() }; for (const file of readdirSync(RESOURCES)) { if (!file.endsWith(".ts") || file.endsWith(".test.ts")) { continue; @@ -39,12 +32,13 @@ function readWiring(): Wiring { if (namespace === undefined) { continue; } - for (const [, name, parameters] of source.matchAll(EXPLAINED)) { + for (const [, wrappedName, parameters, readingName] of source.matchAll(WRAPPED)) { + const name = wrappedName ?? readingName; if (name === undefined) { continue; } const key = `${namespace}.${name}`; - wiring.explained.add(key); + wiring.wrapped.push(key); if (parameters !== undefined) { wiring.explainedWithParameters.add(key); } @@ -57,19 +51,19 @@ describe("the explanation of gated methods", () => { const wiring = readWiring(); // An unwrapped gated method still works; its refusal just reaches the caller as a bare HTTP - // error instead of the feature the server lacks. - it("wraps every method that needs a feature", () => { - const unexplained = METHOD_KEYS.filter( + // error instead of the feature the server lacks. A method refused before the request is never + // the server's to refuse, so explaining it as well would have nothing to explain. + it("wraps every method that needs a feature exactly once", () => { + const miswrapped = METHOD_KEYS.filter( (key) => methodRequirements(key).length > 0 && - !wiring.explained.has(key) && - !REFUSED_BEFORE_THE_REQUEST.has(key), + wiring.wrapped.filter((wrapped) => wrapped === key).length !== 1, ); - expect(unexplained).toEqual([]); + expect(miswrapped).toEqual([]); }); it("wraps nothing that could have no feature to name", () => { - const pointless = [...wiring.explained].filter( + const pointless = [...new Set(wiring.wrapped)].filter( (key) => isMethodKey(key) && methodRequirements(key).length === 0 && diff --git a/packages/client/src/version/probe.ts b/packages/client/src/version/probe.ts index 9cb3d532..cbd2cc48 100644 --- a/packages/client/src/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -1,8 +1,7 @@ -import type { ZodType } from "zod"; - import { SessionProperties, type TokenFeatures } from "../domain/session-properties"; import type { Transport } from "../http/transport"; +import { createServerProfile, type ServerProfile } from "./profile"; import { type Edition, editionFromTag, parseTag, type ServerVersion } from "./tag"; export const PROBE_PATH = "/api/session/properties"; @@ -18,11 +17,16 @@ export interface ServerInfo { readonly tokenFeatures: Readonly | null; } -interface ProbeOptions { +export interface ProbeOptions { timeoutMs?: number; retries?: number; } +export interface ProbeBudget { + timeoutMs: number; + retries: number; +} + // The probe runs before a profile exists, so it asks only for the wire. type ProbeTransport = Pick; @@ -30,20 +34,16 @@ export async function probeServer( client: ProbeTransport, opts: ProbeOptions = {}, ): Promise { - return serverInfoFromProperties(await probeProperties(client, SessionProperties, opts)); + return serverInfoFromProperties( + await client.requestParsed(SessionProperties, PROBE_PATH, probeBudget(opts)), + ); } -// The session properties through `reader`, for a caller that wants a setting they carry beside the -// version and token features a probe reads. -export async function probeProperties( - client: ProbeTransport, - reader: ZodType, - opts: ProbeOptions = {}, -): Promise { - return client.requestParsed(reader, PROBE_PATH, { +export function probeBudget(opts: ProbeOptions): ProbeBudget { + return { timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS, retries: opts.retries ?? 0, - }); + }; } export function serverInfoFromProperties(properties: SessionProperties): ServerInfo { @@ -56,3 +56,9 @@ export function serverInfoFromProperties(properties: SessionProperties): ServerI tokenFeatures: properties["token-features"] ?? null, }; } + +// The profile one read of the session properties describes, for a call judged by its own read +// rather than by whichever probe is newest by the time it asks. +export function profileFromProperties(properties: SessionProperties): ServerProfile { + return createServerProfile(serverInfoFromProperties(properties)); +} diff --git a/packages/client/src/version/refusal.test.ts b/packages/client/src/version/refusal.test.ts index 031bd6a5..178f7a81 100644 --- a/packages/client/src/version/refusal.test.ts +++ b/packages/client/src/version/refusal.test.ts @@ -1,83 +1,281 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; +import { z } from "zod"; -import type { RequestOptions, Transport } from "../http/transport"; +import { AbortError } from "../errors"; +import { HttpError, PAYMENT_REQUIRED_STATUS } from "../http/errors"; +import { + type ClientCredentials, + createTransport, + type RequestOptions, + type Transport, +} from "../http/transport"; +import { premiumRefusalResponse, probeResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; -import { explainer } from "./refusal"; -import type { CallRequirement } from "./requirement-check"; +import { CapabilityError } from "./capability-error"; +import { PROBE_PATH } from "./probe"; +import { createServerProfile } from "./profile"; +import { explainer, explainRefusal } from "./refusal"; -interface ExplainCall { - call: CallRequirement; - error: unknown; +const CONFIG: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_test_key_abcdef0123" }, +}; + +const PingResponse = z.object({ id: z.number().int(), email: z.string() }); + +const HANGING_FETCH: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }); + +// A fetch answering every request with a fresh `response()`. +function answering(response: () => Response): typeof fetch { + return async () => response(); } -const EXPLAINED = new Error("explained"); +// A v58 server licensed for nothing, so it lacks every feature the cases below name. +const UNLICENSED_58 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: {}, +}); function notOnThisPath(): never { throw new Error("the explainer reaches the transport only to explain a failure"); } -// A transport that records what each failure asked it to explain and answers `EXPLAINED`. -function recordingTransport(): { transport: Transport; calls: ExplainCall[] } { - const calls: ExplainCall[] = []; +// A transport whose only use on the explainer's path is the profile an explanation reads, which it +// answers with `UNLICENSED_58`, counting the reads. +function probingTransport(): { transport: Transport; reads: () => number } { + let reads = 0; const transport: Transport = { requestParsed: notOnThisPath, requestRaw: notOnThisPath, requestStream: notOnThisPath, server: notOnThisPath, - verifiedServer: notOnThisPath, + async verifiedServer() { + reads += 1; + return UNLICENSED_58; + }, probe: notOnThisPath, requireFeatures: notOnThisPath, - async explainRefusal(call, error) { - calls.push({ call, error }); - return EXPLAINED; - }, + preflightServer: notOnThisPath, + refuseBeforeSending: notOnThisPath, + probesSettled: notOnThisPath, }; - return { transport, calls }; + return { transport, reads: () => reads }; } -const REFUSED = new Error("refused"); +const REFUSED = new HttpError({ + status: PAYMENT_REQUIRED_STATUS, + statusText: "Payment Required", + method: "GET", + url: "https://m.example.com/api/ee/feature", + responseHeaders: {}, + rawBody: null, +}); async function refuse(_kind: string, _id: number, _options: RequestOptions = {}): Promise { throw REFUSED; } +async function failureOf(call: Promise): Promise { + const error = await call.catch((caught: unknown) => caught); + assert(error instanceof CapabilityError, "expected CapabilityError"); + return error; +} + describe("explainer", () => { it("answers what the wrapped method answers and explains nothing", async () => { - const { transport, calls } = recordingTransport(); + const { transport, reads } = probingTransport(); const { explain } = explainer(transport, "erd"); const get = explain("get", async (id: number) => ({ id })); expect(await get(4)).toEqual({ id: 4 }); - expect(calls).toEqual([]); + expect(reads()).toBe(0); }); - it("explains a failure by the method's own requirements, and throws what the transport answers", async () => { - const { transport, calls } = recordingTransport(); + it("explains a failure by the method's own requirements, carrying the server's refusal", async () => { + const { transport } = probingTransport(); const { explain } = explainer(transport, "erd"); const get = explain("get", refuse); - await expect(get("job", 1)).rejects.toBe(EXPLAINED); - expect(calls).toEqual([{ call: { parameters: [], method: ["erd"] }, error: REFUSED }]); + const error = await failureOf(get("job", 1)); + expect(error.developerDetail.feature).toBe("erd"); + expect(error.cause).toBe(REFUSED); }); it("puts the features the arguments bring ahead of the method's own", async () => { - const { transport, calls } = recordingTransport(); + const { transport } = probingTransport(); const { explain } = explainer(transport, "dependency"); const graph = explain("graph", refuse, (kind) => kind === "measure" ? [{ feature: "measureDependencyGraph", fields: ["type"] }] : [], ); - await expect(graph("measure", 1)).rejects.toBe(EXPLAINED); - await expect(graph("card", 1)).rejects.toBe(EXPLAINED); - expect(calls.map((recorded) => recorded.call)).toEqual([ - { - parameters: [{ feature: "measureDependencyGraph", fields: ["type"] }], - method: ["dependencyGraph"], - }, - { parameters: [], method: ["dependencyGraph"] }, + const measure = await failureOf(graph("measure", 1)); + const card = await failureOf(graph("card", 1)); + expect([measure.developerDetail.feature, card.developerDetail.feature]).toEqual([ + "measureDependencyGraph", + "dependencyGraph", + ]); + }); +}); + +describe("explainRefusal", () => { + const LICENSED_63 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: true }, + }); + const UNLICENSED_63 = createServerProfile({ + edition: "ee", + version: { kind: "release", tag: "v1.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: false }, + }); + + // A transport whose first request fails as `failure` and whose later ones are `rest`, handed the + // licensed profile a cache would hold. + function transportOver(failure: Response, rest: typeof fetch) { + let first = true; + const urls: string[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + if (first) { + first = false; + return failure; + } + return rest(input, init); + }; + const controller = new AbortController(); + const transport = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl, + server: LICENSED_63, + signal: controller.signal, + }); + return { transport, urls, stop: () => controller.abort(new Error("test over")) }; + } + + async function failedRequest(transport: Transport): Promise { + return transport + .requestParsed(PingResponse, "/api/ee/remote-sync/branches", { retries: 0 }) + .catch((caught: unknown) => caught); + } + + it("turns a premium refusal into the feature a fresh probe finds missing, whatever the cache said", async () => { + const { transport, urls } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(UNLICENSED_63)), + ); + const refusal = await failedRequest(transport); + + const explained = await explainRefusal( + transport, + { parameters: [], method: ["remoteSync"] }, + refusal, + ); + + assert(explained instanceof CapabilityError, "expected CapabilityError"); + expect(explained.developerDetail).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 58, + tokenFeature: "remote_sync", + serverVersion: "v1.63.0", + }); + expect(urls).toEqual([ + "https://m.example.com/api/ee/remote-sync/branches", + `https://m.example.com${PROBE_PATH}`, ]); }); + + it("lets the server's error stand when a fresh probe finds every feature", async () => { + const { transport } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(LICENSED_63)), + ); + const refusal = await failedRequest(transport); + + expect( + await explainRefusal(transport, { parameters: [], method: ["remoteSync"] }, refusal), + ).toBe(refusal); + }); + + it("never blames a missing row on a feature, and asks the server nothing", async () => { + const { transport, urls } = transportOver( + new Response("Not found.", { status: 404, headers: { "content-type": "text/plain" } }), + answering(() => probeResponse(UNLICENSED_63)), + ); + const missing = await failedRequest(transport); + + expect( + await explainRefusal(transport, { parameters: [], method: ["remoteSync"] }, missing), + ).toBe(missing); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); + }); + + it("leaves a failure that is no refusal unexplained, and asks the server nothing", async () => { + const { transport, urls } = transportOver( + new Response("boom", { status: 500 }), + answering(() => probeResponse(UNLICENSED_63)), + ); + const failure = await failedRequest(transport); + + expect( + await explainRefusal(transport, { parameters: [], method: ["remoteSync"] }, failure), + ).toBe(failure); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); + }); + + it("lets the refusal stand when the explaining probe fails", async () => { + const { transport } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => new Response("boom", { status: 500 })), + ); + const refusal = await failedRequest(transport); + + expect( + await explainRefusal(transport, { parameters: [], method: ["remoteSync"] }, refusal), + ).toBe(refusal); + }); + + it("surfaces the client-wide interrupt during the explaining probe rather than the refusal", async () => { + const { transport, stop } = transportOver(premiumRefusalResponse("Remote Sync"), HANGING_FETCH); + const refusal = await failedRequest(transport); + + const pending = explainRefusal( + transport, + { parameters: [], method: ["remoteSync"] }, + refusal, + ).catch((caught: unknown) => caught); + stop(); + + const error = await pending; + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("test over"); + }); + + it("explains nothing for a call that needs no feature", async () => { + const { transport, urls } = transportOver( + premiumRefusalResponse("Remote Sync"), + answering(() => probeResponse(UNLICENSED_63)), + ); + const refusal = await failedRequest(transport); + + expect(await explainRefusal(transport, { parameters: [], method: [] }, refusal)).toBe(refusal); + expect(urls).toEqual(["https://m.example.com/api/ee/remote-sync/branches"]); + }); }); diff --git a/packages/client/src/version/refusal.ts b/packages/client/src/version/refusal.ts index eab0c4e8..c58cbf33 100644 --- a/packages/client/src/version/refusal.ts +++ b/packages/client/src/version/refusal.ts @@ -1,19 +1,23 @@ -import { InternalError } from "../errors"; -import { FIELD_PATH_SEPARATOR, HttpError } from "../http/errors"; -import type { Transport } from "../http/transport"; +import type { ZodType } from "zod"; -import { BAD_REQUEST_STATUS, type RequirementFailure } from "./capability-error"; -import type { ServerProfile } from "./profile"; +import type { SessionProperties } from "../domain/session-properties"; +import { InternalError, isNonInterruptFailure } from "../errors"; import { - type CallRequirement, - callFeatures, - checkFeatures, - type ParameterRequirement, -} from "./requirement-check"; -import { isMethodKey, type MethodKey, methodRequirements } from "./requirements"; + BAD_REQUEST_STATUS, + FIELD_PATH_SEPARATOR, + HttpError, + NOT_FOUND_STATUS, + PAYMENT_REQUIRED_STATUS, +} from "../http/errors"; +import type { RequestOptions, Transport, WaitOptions } from "../http/transport"; +import { isPlainObject } from "../predicates"; -const PAYMENT_REQUIRED_STATUS = 402; -const NOT_FOUND_STATUS = 404; +import { CapabilityError, type RequirementFailure } from "./capability-error"; +import type { FeatureName } from "./features"; +import { profileFromProperties } from "./probe"; +import type { ServerProfile } from "./profile"; +import { checkFeatures, featureFailures } from "./requirement-check"; +import { isMethodKey, type MethodKey, methodRequirements } from "./requirements"; // What a server answers for what it does not serve: unrouted, a verb the route lacks included // (404); the premium refusal, which a gated prefix answers before routing (402); and a 400 for a @@ -25,12 +29,28 @@ const REFUSAL_STATUSES: ReadonlySet = new Set([ NOT_FOUND_STATUS, ]); +// A feature a call needs only because of an argument it was handed, with the request fields that +// argument travels in, as a server rejecting it names them. +export interface ParameterRequirement { + readonly feature: FeatureName; + readonly fields: readonly string[]; +} + +// What one call needs from the server: the features its arguments brought, then the method's own. +export interface CallRequirement { + readonly parameters: readonly ParameterRequirement[]; + readonly method: readonly FeatureName[]; +} + // The features a call needs only because of the arguments it was handed, where a server without // them rejects the request rather than dropping the argument. export type ParameterFeatures = ( ...args: A ) => readonly ParameterRequirement[]; +// A method's arguments, ending in the `RequestOptions` a caller may leave out. +type WithOptions = [...A, options?: RequestOptions | undefined]; + type NamespaceOf = K extends `${infer N}.${string}` ? N : never; type MethodNamespace = NamespaceOf; type NameIn = K extends `${N}.${infer M}` ? M : never; @@ -40,7 +60,12 @@ type MethodName = NameIn; // call is allowed, and a refusal it answers with reads as the feature it lacks. `explain` wraps a // method answering a promise, `explainWalk` one answering an async iterable, whose failure at any // step is explained as it is thrown. The method alone sets the exposed signature, so `parameters` -// is `NoInfer`: a callback reading fewer arguments must not drop the rest from it. +// is `NoInfer`: a callback reading fewer arguments must not drop the rest from it. `refuse` wraps +// the exception, a method a server without its features would answer wrongly without a word: the +// call is refused before the request, against a profile the client probed itself, and the server +// never gets to refuse it, unless the caller skips such refusals, which the transport's +// `refuseBeforeSending` decides. `refuseAfterReading` wraps such a method answering from the +// session properties alone, which it is handed read afresh. export function explainer(transport: Transport, namespace: N) { function explain( name: MethodName, @@ -52,7 +77,7 @@ export function explainer(transport: Transport, names try { return await method(...args); } catch (error) { - throw await transport.explainRefusal(callRequirement(key, parameters, args), error); + throw await explainRefusal(transport, callRequirement(key, parameters, args), error); } }; } @@ -67,12 +92,57 @@ export function explainer(transport: Transport, names try { yield* method(...args); } catch (error) { - throw await transport.explainRefusal(callRequirement(key, parameters, args), error); + throw await explainRefusal(transport, callRequirement(key, parameters, args), error); } }; } - return { explain, explainWalk }; + // The wait for the profile ends with the signal the call's trailing `RequestOptions` carries, as + // the call's own requests do. + function refuse( + name: MethodName, + method: (...args: WithOptions) => Promise, + ): (...args: WithOptions) => Promise { + const key = methodKey(namespace, name); + return async (...args: WithOptions) => { + await transport.requireFeatures(methodRequirements(key), trailingWait(args)); + return method(...args); + }; + } + + // The read is a probe of the call's own, never an older one settled, so a setting changed since + // is seen. The refusal judges the profile that same answer describes, not whichever probe is + // newest by the time it asks, so the call costs one request however other calls interleave. The + // call takes nothing but the options, so they never need telling apart from an argument of the + // method's own, and their budget is the read's. The read is also the method's own data, so a + // failed read fails the call even when the caller skips checks before the wire. + function refuseAfterReading

( + name: MethodName, + reader: ZodType

, + method: (properties: P) => R, + ): (options?: RequestOptions) => Promise { + const key = methodKey(namespace, name); + return async (options: RequestOptions = {}) => { + const properties = await transport.probe(reader, options); + const profile = profileFromProperties(properties); + for (const failure of featureFailures(methodRequirements(key), profile)) { + transport.refuseBeforeSending(new CapabilityError(failure)); + } + return method(properties); + }; + } + + return { explain, explainWalk, refuse, refuseAfterReading }; +} + +// The options are the last argument whenever a caller passes them; left out, the last argument is +// one of the method's own, and none of those carries an abort signal. +function trailingWait(args: readonly unknown[]): WaitOptions { + const last = args.at(-1); + if (isPlainObject(last) && last["signal"] instanceof AbortSignal) { + return { signal: last["signal"] }; + } + return {}; } function methodKey(namespace: string, name: string): MethodKey { @@ -92,12 +162,45 @@ function callRequirement( return { parameters: brought, method: methodRequirements(key) }; } +/** + * The error a call failed with, as a `CapabilityError` carrying the server's answer when the server + * refused it for lacking one of the features `call` needs, and unchanged otherwise. The server's own + * refusal is the verdict; the profile only names what was missing. The explaining probe is bounded + * by its own timeout and the client's signal; the error stands when the probe fails, and an + * interrupt surfaces as one. + */ +export async function explainRefusal( + transport: Pick, + call: CallRequirement, + error: unknown, +): Promise { + if (!mayBeRefusal(call, error)) { + return error; + } + let profile: ServerProfile; + try { + profile = await transport.verifiedServer(); + } catch (probeError) { + if (isNonInterruptFailure(probeError)) { + return error; + } + throw probeError; + } + const failure = explainedFailure(call, profile, error); + return failure === null ? error : new CapabilityError(failure, error); +} + +/** Every feature `call` needs, parameters first, since a parameter's floor sits above its method's. */ +function callFeatures(call: CallRequirement): FeatureName[] { + return [...call.parameters.map((parameter) => parameter.feature), ...call.method]; +} + /** * Whether `error` may be the server refusing `call` for a feature it lacks. A call that needs no * feature cannot be, and neither can a 404 for a missing row, which comes from a route the server * serves. */ -export function mayBeRefusal(call: CallRequirement, error: unknown): error is HttpError { +function mayBeRefusal(call: CallRequirement, error: unknown): error is HttpError { if (callFeatures(call).length === 0) { return false; } @@ -111,7 +214,7 @@ export function mayBeRefusal(call: CallRequirement, error: unknown): error is Ht * The feature `profile` lacks that explains `refusal` of `call`, or `null` when the server has every * feature the call needs, so it failed for some other reason, or when the refusal points elsewhere. */ -export function explainedFailure( +function explainedFailure( call: CallRequirement, profile: ServerProfile, refusal: HttpError, diff --git a/packages/client/src/version/requirement-check.ts b/packages/client/src/version/requirement-check.ts index 30dc6938..d0e22fcd 100644 --- a/packages/client/src/version/requirement-check.ts +++ b/packages/client/src/version/requirement-check.ts @@ -7,24 +7,6 @@ import { import { featureGap, type ServerProfile } from "./profile"; import { describeVersion } from "./tag"; -// A feature a call needs only because of an argument it was handed, with the request fields that -// argument travels in, as a server rejecting it names them. -export interface ParameterRequirement { - readonly feature: FeatureName; - readonly fields: readonly string[]; -} - -// What one call needs from the server: the features its arguments brought, then the method's own. -export interface CallRequirement { - readonly parameters: readonly ParameterRequirement[]; - readonly method: readonly FeatureName[]; -} - -/** Every feature `call` needs, parameters first, since a parameter's floor sits above its method's. */ -export function callFeatures(call: CallRequirement): FeatureName[] { - return [...call.parameters.map((parameter) => parameter.feature), ...call.method]; -} - /** The first of `features` the profile lacks, or `null` when it has every one. */ export function checkFeatures( features: readonly FeatureName[], @@ -39,6 +21,43 @@ export function checkFeatures( return null; } +/** + * The verdict that the profile has `feature`, which takes the value `detail` describes out of the + * server's vocabulary, or `null` when the profile lacks it. + */ +export function supersedingFeature( + feature: FeatureName, + profile: ServerProfile, + detail: string, +): RequirementFailure | null { + if (featureGap(profile, feature) !== null) { + return null; + } + const rule: FeatureRule = FEATURE_RULES[feature]; + return { + reason: "superseded-by-feature", + detail, + feature, + since: rule.since, + tokenFeature: rule.tokenFeature ?? null, + serverVersion: profile.version.tag, + }; +} + +/** + * Every one of `features` the profile lacks, each once and in the order given, so a caller that + * goes on past the first still hears of the rest. + */ +export function featureFailures( + features: readonly FeatureName[], + profile: ServerProfile, +): RequirementFailure[] { + return [...new Set(features)].flatMap((feature) => { + const failure = checkFeatures([feature], profile); + return failure === null ? [] : [failure]; + }); +} + function describeGap( gap: FeatureGap, feature: FeatureName, diff --git a/tests/e2e/field.e2e.test.ts b/tests/e2e/field.e2e.test.ts index 3fa0dabf..a1616853 100644 --- a/tests/e2e/field.e2e.test.ts +++ b/tests/e2e/field.e2e.test.ts @@ -13,6 +13,7 @@ import { parseJson } from "@metabase/client/json"; import { FieldSearchListEnvelope } from "../../packages/cli/src/commands/field/search"; import { FieldListEnvelope } from "../../packages/cli/src/commands/table/fields"; +import { PREFLIGHT_SKIP_REMEDY } from "../../packages/cli/src/output/notice"; import { FieldRemappingResult } from "../../packages/cli/src/output/views/field"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; @@ -24,7 +25,7 @@ import { requireServer, serverHas } from "./server-gate"; const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; function sensitivityRefusal(serverTag: string): string { - return `This operation requires Metabase v64+ (this server is ${serverTag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`; + return `This operation requires Metabase v64+ (this server is ${serverTag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}\n${PREFLIGHT_SKIP_REMEDY}`; } describe("field e2e", () => { diff --git a/tests/e2e/search.e2e.test.ts b/tests/e2e/search.e2e.test.ts index 1fc239cf..ac2fb06c 100644 --- a/tests/e2e/search.e2e.test.ts +++ b/tests/e2e/search.e2e.test.ts @@ -7,6 +7,7 @@ import { parseJson } from "@metabase/client/json"; import { pollUntil } from "@metabase/client/poll"; import { SearchListEnvelope } from "../../packages/cli/src/commands/search"; +import { PREFLIGHT_SKIP_REMEDY } from "../../packages/cli/src/output/notice"; import { listEnvelopeSchema } from "../../packages/cli/src/output/types"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -122,7 +123,7 @@ describe("search e2e", () => { expect(result.exitCode).toBe(2); expect(cliErrorCategory(result.stderr)).toBe("capability"); expect(cliErrorMessage(result.stderr)).toBe( - "This operation requires the 'content_verification' premium feature (not enabled on this server).", + `This operation requires the 'content_verification' premium feature (not enabled on this server).\n${PREFLIGHT_SKIP_REMEDY}`, ); expect(result.stdout).toBe(""); return; diff --git a/tests/e2e/subscription.e2e.test.ts b/tests/e2e/subscription.e2e.test.ts index 61ddee1e..6cf5d451 100644 --- a/tests/e2e/subscription.e2e.test.ts +++ b/tests/e2e/subscription.e2e.test.ts @@ -9,6 +9,7 @@ import { import { parseJson } from "@metabase/client/json"; import { SubscriptionListEnvelope } from "../../packages/cli/src/commands/subscription/list"; +import { PREFLIGHT_SKIP_REMEDY } from "../../packages/cli/src/output/notice"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -176,7 +177,7 @@ describe("subscription e2e", () => { expect(result.exitCode).toBe(2); expect(cliErrorCategory(result.stderr)).toBe("capability"); expect(cliErrorMessage(result.stderr)).toBe( - "This operation requires the 'dashboard_subscription_filters' premium feature (not enabled on this server).", + `This operation requires the 'dashboard_subscription_filters' premium feature (not enabled on this server).\n${PREFLIGHT_SKIP_REMEDY}`, ); expect(result.stdout).toBe(""); expect(await listed()).toBe(0); diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index f1ecf6e7..9065482b 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -15,6 +15,7 @@ import { FieldListEnvelope } from "../../packages/cli/src/commands/table/fields" import { TableForeignKeyListEnvelope } from "../../packages/cli/src/commands/table/fks"; import { tableFieldsOversizeHint } from "../../packages/cli/src/commands/table/hints"; import { TableListEnvelope } from "../../packages/cli/src/commands/table/list"; +import { PREFLIGHT_SKIP_REMEDY } from "../../packages/cli/src/output/notice"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -326,7 +327,7 @@ describe("table e2e", () => { } expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toBe( - `This operation requires Metabase v59+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, + `This operation requires Metabase v59+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}\n${PREFLIGHT_SKIP_REMEDY}`, ); }); @@ -342,7 +343,7 @@ describe("table e2e", () => { } expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toBe( - `This operation requires Metabase v60+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, + `This operation requires Metabase v60+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}\n${PREFLIGHT_SKIP_REMEDY}`, ); }); @@ -359,7 +360,7 @@ describe("table e2e", () => { } expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toBe( - "This operation requires the 'dependencies' premium feature (not enabled on this server).", + `This operation requires the 'dependencies' premium feature (not enabled on this server).\n${PREFLIGHT_SKIP_REMEDY}`, ); }); @@ -748,7 +749,7 @@ describe("table e2e", () => { } expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toBe( - `This operation requires Metabase v64+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}`, + `This operation requires Metabase v64+ (this server is ${bootstrap.server.version?.tag}). Upgrade Metabase to use it.\n${DOWNGRADE_REMEDY}\n${PREFLIGHT_SKIP_REMEDY}`, ); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index d78edbb2..b395fc54 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -160,9 +160,10 @@ describe("version skew notices e2e", () => { expect(result.stderr).toBe(""); }); - function versionChangedNote(cachedTag: string): string { + // `transform-job run` is a POST, so the note cannot offer a retry: the run may have started. + function versionChangedAfterWriteNote(cachedTag: string): string { const liveLabel = describeVersion(bootstrap.server.version); - return `The server's version changed since the last probe (was ${cachedTag}, now ${liveLabel}); the profile was refreshed — retry the command.`; + return `The server's version changed since the last probe (was ${cachedTag}, now ${liveLabel}); the profile was refreshed, but the request may have been applied — check before retrying.`; } async function expectRefreshedProbe(configHome: string, seededAt: string): Promise { @@ -203,7 +204,7 @@ describe("version skew notices e2e", () => { expect(cliErrorMessage(result.stderr)).toBe( "On Metabase v0.59.0 the response shape was unexpected:\n" + " job_run_id: Invalid input: expected string, received null\n" + - versionChangedNote("v0.59.0"), + versionChangedAfterWriteNote("v0.59.0"), ); await expectRefreshedProbe(configHome, seededAt); From 3006de19688257df530c0096ff1ebfdd56b7eaa1 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 7 Oct 2026 13:17:23 -0400 Subject: [PATCH 07/11] fix verify tests for the user read reaching fetch first --- packages/cli/src/core/auth/verify.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts index bedf150b..28eae5d9 100644 --- a/packages/cli/src/core/auth/verify.test.ts +++ b/packages/cli/src/core/auth/verify.test.ts @@ -94,10 +94,10 @@ describe("verifyAndProbe", () => { // A transport failure names no route, and a login that reports only "verification failed // (current user)" leaves a user unable to tell a blocked route from an unreachable host. it("reads a 401 on the user as a credential problem", async () => { - // The version probe reaches fetch first. + // The user read reaches fetch first. const capture = captureFetch([ - jsonResponse(SESSION_PROPERTIES), jsonResponse({ message: "Unauthenticated" }, 401), + jsonResponse(SESSION_PROPERTIES), ]); vi.stubGlobal("fetch", capture.fetch); @@ -110,10 +110,10 @@ describe("verifyAndProbe", () => { }); it("reads a 403 on the user as a server problem, since Metabase serves every identified user", async () => { - // The version probe reaches fetch first. + // The user read reaches fetch first. const capture = captureFetch([ - jsonResponse(SESSION_PROPERTIES), jsonResponse({ message: "Forbidden" }, 403), + jsonResponse(SESSION_PROPERTIES), ]); vi.stubGlobal("fetch", capture.fetch); From 31940cdabed508ee2990c419ba15a1a0ecbcfb3a Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 7 Oct 2026 13:57:30 -0400 Subject: [PATCH 08/11] fix --- tests/e2e/git-sync.e2e.test.ts | 66 +++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index 0b2ad542..42030b79 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -30,6 +30,10 @@ const preflightSkipReason = requireServer( "git-sync › export-preflight against a licensed server with the preflight route", ["remoteSyncExportPreflight"], ); +const branchGuardSkipReason = requireServer( + "git-sync › import and export refuse an unset branch on a server guarding it", + ["remoteSyncBranchGuard"], +); const preflightGap = requirementFailure(["remoteSyncExportPreflight"]); const remoteSyncGap = requirementFailure(["remoteSync"]); const withoutRemoteSyncSkipReason = requireServerWithout( @@ -421,37 +425,43 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(parseJson(result.stdout, WaitResult)).toEqual({ status: "idle" }); }); - it("import without git-sync configured refuses before the request, having read the unset branch", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["git-sync", "import", "--no-wait", "--json"], - configHome, - env: authEnv(), - }); + it.skipIf(branchGuardSkipReason !== null)( + "import without git-sync configured refuses before the request, having read the unset branch", + async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["git-sync", "import", "--no-wait", "--json"], + configHome, + env: authEnv(), + }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("config"); - expect(cliErrorMessage(result.stderr)).toBe( - "git-sync tracks no branch: the remote-sync-branch setting is unset", - ); - expect(result.stdout).toBe(""); - }); + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe( + "git-sync tracks no branch: the remote-sync-branch setting is unset", + ); + expect(result.stdout).toBe(""); + }, + ); - it("export without git-sync configured refuses before the request, having read the unset branch", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["git-sync", "export", "--no-wait", "--json"], - configHome, - env: authEnv(), - }); + it.skipIf(branchGuardSkipReason !== null)( + "export without git-sync configured refuses before the request, having read the unset branch", + async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["git-sync", "export", "--no-wait", "--json"], + configHome, + env: authEnv(), + }); - expect(result.exitCode).toBe(2); - expect(cliErrorCategory(result.stderr)).toBe("config"); - expect(cliErrorMessage(result.stderr)).toBe( - "git-sync tracks no branch: the remote-sync-branch setting is unset", - ); - expect(result.stdout).toBe(""); - }); + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe( + "git-sync tracks no branch: the remote-sync-branch setting is unset", + ); + expect(result.stdout).toBe(""); + }, + ); it.skipIf(remoteChangesSkipReason !== null)( "has-remote-changes without git-sync configured surfaces the server's 400 message", From b1bde2bd6f0d2f95ceef90dcea3ea66efc72b03f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 7 Oct 2026 14:00:49 -0400 Subject: [PATCH 09/11] trim the git-sync skill under its context budget --- packages/cli/skill-data/git-sync/SKILL.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/cli/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md index e27d11e3..746c0017 100644 --- a/packages/cli/skill-data/git-sync/SKILL.md +++ b/packages/cli/skill-data/git-sync/SKILL.md @@ -122,13 +122,13 @@ mb git-sync export-preflight --profile --json # → {has_changes, clean, c A dry run against the live remote branch, writing nothing. Read it between the state check and the export, and decide from the fields: -| Answer | Meaning | Move | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `has_changes: false` | The remote has not moved past the last sync, or nothing has been synced yet — and then `force_push_casualties` is empty whatever the remote holds. | `export -m "..."` applies as-is. Don't read empty casualties on a never-synced instance as leave to `--force`. | -| `has_changes: true`, `reason: null` | The remote moved on. `clean` says whether a three-way merge would apply; `conflicts` names the entities changed on both sides; `summary` counts what a merge would fold in. | `clean: true`: `export --merge -m "..."`. `clean: false`: the conflicts need the user's call — `create-branch ` then `export -m "..."` pushes Metabase's side to a new branch for a PR into the old one (once it merges, `import --branch ` tracks the old branch again), `import --force` takes the remote's side, `export --force` takes Metabase's. A plain `export` ends in a `conflict` task, and so does `stash`: its new branch starts at the moved remote tip. | -| `reason: "history-rewritten"` | The remote was force-pushed or rebased, so there is no merge base. | Only `export --force` can push, and only with the user's explicit go-ahead; `force_push_casualties` is exactly what it would delete or overwrite. | +| Answer | Meaning | Move | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `has_changes: false` | The remote has not moved past the last sync, or nothing has been synced yet — and then `force_push_casualties` is empty whatever the remote holds. | `export -m "..."` applies as-is. Don't read empty casualties on a never-synced instance as leave to `--force`. | +| `has_changes: true`, `reason: null` | The remote moved on. `clean` says whether a three-way merge would apply; `conflicts` names the entities changed on both sides; `summary` counts what a merge would fold in. | `clean: true`: `export --merge -m "..."`. `clean: false`: the conflicts need the user's call between the three options under "After a `conflict` task". A plain `export` ends in a `conflict` task, and so does `stash`: its new branch starts at the moved remote tip. | +| `reason: "history-rewritten"` | The remote was force-pushed or rebased, so there is no merge base. | Only `export --force` can push, and only with the user's explicit go-ahead; `force_push_casualties` is exactly what it would delete or overwrite. | -`force_push_casualties` is reported on every answer: it is the remote content a force push would discard instead of merging, so read it before ever passing `--force`. It is empty when nothing has been synced yet, yet `export --force` then replaces the remote's managed directories wholesale: check the remote before forcing a first export. `--branch` defaults to the tracked `remote-sync-branch`; the server rejects any other branch with a 409 naming the current one, so pass it only to assert the branch you believe is tracked. +`force_push_casualties` is reported on every answer: it is the remote content a force push would discard instead of merging, so read it before ever passing `--force`. It is empty when nothing has been synced yet, yet `export --force` then replaces the remote's managed directories wholesale: check the remote before forcing a first export. Read the preflight before every export: an export that finds the divergence itself ends in `conflict`, and after that these fields can't be trusted (below). @@ -193,7 +193,7 @@ mb git-sync remove-collection --profile --json `` is a **positive integer** (the bulk endpoint's schema is `pos-int? → boolean`; nano-id / `root` / `trash` refs are not supported). Get the id from `collection list` (see `core`). -Both verbs return `{ success: true, task_id?: }`. The optional `task_id` only appears when the toggle triggered a follow-up task (e.g., a finalization import after switching to read-only mode); for a normal add/remove in read-write, expect `{ success: true }` and nothing else. +Both verbs return `{ success: true }`, plus a `task_id` only when the toggle started a follow-up task (e.g. a finalization import after switching to read-only). **Cascade.** A toggle on a parent cascades to every descendant by `location` prefix — `add-collection 4` flips `4` plus every collection nested under it. `remove-collection 4` is the symmetric inverse. There is no per-leaf-only mode. @@ -253,12 +253,11 @@ Flagging the collection records it for the next export, which serializes its cur ## Don't (git-sync-specific) -- Don't turn instance-side changes into hand-written repo files. When the changes were made against the instance, export them (`stash` / `create-branch` + `export`) and PR the exported branch; reconstructing them as YAML by hand — or pushing files in paths/formats the serializer doesn't own — produces content that never applies on import, and pushing behind Metabase's back races its own sync tasks. Hand-editing YAML belongs to the repo-first workflow, in the serialized layout the repo already uses. +- Don't turn instance-side changes into hand-written repo files: export them and PR the exported branch (see the top of this skill). Files pushed behind Metabase's back race its own sync tasks. - Don't conclude from an empty `dirty` list that a change type isn't tracked. Dirty-tracking only records changes to _eligible_ objects; the usual cause is scope (the collection isn't flagged — see "Published table metadata"), not capability. Check `synced_collections` in `status` before concluding. - 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. From c68b6022a0d63dd042ad3cc2bcf886193d54370d Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 7 Oct 2026 14:29:46 -0400 Subject: [PATCH 10/11] fix(client): send Metabase 58 and 59 the remote settings a collection toggle validates --- packages/client/src/resources/git-sync.ts | 51 ++++++++++++++++++++++- packages/client/src/version/features.ts | 1 + 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 0a34cc68..5d73bf9b 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -71,6 +71,19 @@ const RemoteSyncBranchProperty = SessionProperties.extend({ }); type RemoteSyncBranchProperty = z.infer; +// Absent when the caller may not read the setting, null when no remote is configured. +const RemoteSyncUrlProperty = SessionProperties.extend({ + "remote-sync-url": z.string().nullable().optional(), +}); + +// The token is sensitive, so the server shows it masked, and keeps the stored token when an +// update carries that masked form back. +interface SyncSettingsUpdateBody { + collections: Record; + "remote-sync-url"?: string; + "remote-sync-token"?: string; +} + const BRANCH_UNREADABLE_MESSAGE = "the remote-sync-branch setting is not readable: only admins see it, and a server without remote sync has none"; const BRANCH_UNSET_MESSAGE = "git-sync tracks no branch: the remote-sync-branch setting is unset"; @@ -345,7 +358,12 @@ export function gitSyncResource(transport: Transport) { /** * Mark one collection as git-synced, or unmark it. The server cascades the flag to descendants - * by location prefix, and may queue an export task to carry the change to the remote. + * by location prefix, and may queue an import task to bring the instance in line with the + * remote. A server without `remoteSyncCollectionsOnlyUpdate` validates every settings update as + * a remote configuration and fails one naming no remote, after it has applied the flag, so it is + * sent the remote's URL and its token as the server shows it, which it checks against the remote + * and keeps as they are, or a blank URL when no remote is configured, which clears the remote + * settings that are already empty. */ async function setCollectionSynced( collectionId: number, @@ -355,10 +373,39 @@ export function gitSyncResource(transport: Transport) { return transport.requestParsed(SyncSettingsUpdateResult, "/api/ee/remote-sync/settings", { ...options, method: "PUT", - body: { collections: { [collectionId]: synced } }, + body: await settingsUpdateBody({ [collectionId]: synced }, options), }); } + async function settingsUpdateBody( + collections: SyncSettingsUpdateBody["collections"], + options: RequestOptions, + ): Promise { + const { features } = await transport.server(options); + if (features.remoteSyncCollectionsOnlyUpdate) { + return { collections }; + } + const properties = await transport.probe(RemoteSyncUrlProperty, options); + const url = properties["remote-sync-url"]; + // Hidden from a caller who is not an admin, whose update the server refuses before reading it. + if (url === undefined) { + return { collections }; + } + if (url === null) { + return { collections, "remote-sync-url": "" }; + } + const token = await fetchOptionalParsed( + transport, + "/api/setting/remote-sync-token", + RemoteSyncSetting, + options, + ); + if (token === null) { + return { collections, "remote-sync-url": url }; + } + return { collections, "remote-sync-url": url, "remote-sync-token": token }; + } + /** The collections currently in git-sync's scope. */ async function syncedCollections( options: RequestOptions = {}, diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts index 94046813..5cbdeb36 100644 --- a/packages/client/src/version/features.ts +++ b/packages/client/src/version/features.ts @@ -21,6 +21,7 @@ export const FEATURE_RULES = { library: { since: 59, tokenFeature: "library" }, remoteSync: { since: 58, tokenFeature: "remote_sync" }, remoteSyncRemoteChanges: { since: 59, tokenFeature: "remote_sync" }, + remoteSyncCollectionsOnlyUpdate: { since: 60 }, remoteSyncExportPreflight: { since: 63, tokenFeature: "remote_sync" }, remoteSyncBranchGuard: { since: 63 }, remoteSyncMerge: { since: 63, tokenFeature: "remote_sync" }, From f8205cc76e261db54c4545355d0312fc201c7cee Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 7 Oct 2026 13:01:46 -0400 Subject: [PATCH 11/11] bump --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index adac9969..cb31024f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "Metabase CLI", "license": "AGPL-3.0", "repository": {