From 56afe805075f7905e2d9733f1f52a2498ef9a0e8 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 28 Jul 2026 15:15:56 -0400 Subject: [PATCH] transform-index command --- README.md | 49 ++ packages/cli/package.json | 2 +- packages/cli/skill-data/transform/SKILL.md | 15 + .../src/commands/transform-index/create.ts | 37 ++ .../src/commands/transform-index/delete.ts | 31 ++ .../cli/src/commands/transform-index/get.ts | 26 ++ .../cli/src/commands/transform-index/index.ts | 14 + .../cli/src/commands/transform-index/list.ts | 33 ++ .../src/commands/transform-index/update.ts | 44 ++ packages/cli/src/main.ts | 1 + .../src/output/views/transform-index.test.ts | 73 +++ .../cli/src/output/views/transform-index.ts | 67 +++ packages/cli/src/runtime/command-help.test.ts | 22 + packages/client/README.md | 3 +- packages/client/src/client.ts | 2 + packages/client/src/domain/transform-index.ts | 166 +++++++ packages/client/src/index.test.ts | 4 + packages/client/src/index.ts | 9 + .../src/resources/transform-index.test.ts | 163 +++++++ .../client/src/resources/transform-index.ts | 81 ++++ tests/e2e/transform-index.e2e.test.ts | 424 ++++++++++++++++++ 21 files changed, 1264 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/transform-index/create.ts create mode 100644 packages/cli/src/commands/transform-index/delete.ts create mode 100644 packages/cli/src/commands/transform-index/get.ts create mode 100644 packages/cli/src/commands/transform-index/index.ts create mode 100644 packages/cli/src/commands/transform-index/list.ts create mode 100644 packages/cli/src/commands/transform-index/update.ts create mode 100644 packages/cli/src/output/views/transform-index.test.ts create mode 100644 packages/cli/src/output/views/transform-index.ts create mode 100644 packages/client/src/domain/transform-index.ts create mode 100644 packages/client/src/resources/transform-index.test.ts create mode 100644 packages/client/src/resources/transform-index.ts create mode 100644 tests/e2e/transform-index.e2e.test.ts diff --git a/README.md b/README.md index a542490..0444a7c 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,55 @@ mb transform-tag delete 5 --yes | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | +## Transform indexes + +Manage indexes on a transform's target table via `/api/index`. Requires Metabase v64 or newer. An index request declares a physical index (or clustering/sort/dist key, depending on the warehouse) on a transform's output; Metabase reapplies it on each full transform run. Create, update and delete only set a pending state — the physical index is created or dropped when the target table is next rebuilt. + +### `mb transform-index list ` + +Lists a transform's indexes: those physically in the warehouse, merged with its managed requests. + +```sh +mb transform-index list 1 --json +``` + +### `mb transform-index get ` + +```sh +mb transform-index get 1 --json +``` + +### `mb transform-index create` + +The body is `{ transform_id, structured }`, where `structured` is a definition dispatched on `kind` (`btree`, `hash`, `sortkey`, `distkey`, `clustering`, `order-by`, `skip-index`, …). + +```sh +mb transform-index create --body '{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `mb transform-index update ` + +Replaces the `structured` definition and marks the request update-pending. The index name, kind and type are fixed at creation — delete and recreate to change those. + +```sh +mb transform-index update 1 --body '{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}' +``` + +### `mb transform-index delete ` + +```sh +mb transform-index delete 1 --yes +``` + +| Flag | Description | +| ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | + ## Databases Read warehouse metadata from `/api/database`. The `db` group exposes the full database list, the per-database record with optional table/field hydration, schema and table inspection, and the two manual-sync triggers. diff --git a/packages/cli/package.json b/packages/cli/package.json index a1c3b38..c717300 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/cli", - "version": "0.3.0", + "version": "0.3.1", "description": "Metabase CLI", "license": "AGPL-3.0", "repository": { diff --git a/packages/cli/skill-data/transform/SKILL.md b/packages/cli/skill-data/transform/SKILL.md index b7fd015..cb6a896 100644 --- a/packages/cli/skill-data/transform/SKILL.md +++ b/packages/cli/skill-data/transform/SKILL.md @@ -188,6 +188,21 @@ mb transform delete --yes --profile Removes the definition. Whether the materialized table is dropped depends on the server — check with `mb table list --db-id --profile --json` if it matters. Same `--yes` rule and message as `delete-table`. +## Indexes on the target table + +Metabase v64+. An **index request** (`transform-index`) declares a physical index — or a clustering / sort / dist key, whichever the warehouse speaks — on a transform's output table, and reapplies it on every full run. Create, update and delete only move the request's `status`; the warehouse changes when the target table is next rebuilt in full, so a fresh request reads `create-pending` and `present_in_warehouse: false` until then. + +```bash +mb transform-index list --profile --json # warehouse indexes merged with managed requests +mb transform-index create --body '{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}' --profile --json +mb transform-index update --body '{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}' --profile --json +mb transform-index delete --yes --profile +``` + +`structured` dispatches on `kind`: `btree`/`hash`/`gin`/`gist`/`brin`/`spgist`/`fulltext`/`spatial`/`clustered`/`nonclustered`/`columnstore` take `name` + `columns` (plus optional `include`, `unique`); `sortkey` and `distkey` take a `style`; `clustering` and `order-by` take `columns` alone; `skip-index` adds a `type` of `minmax` or `bloom_filter`. + +Two rules the server enforces: an index name is unique per transform, and `update` may not change a request's name, kind or type — delete it and create another instead. Both answer 400. Read `status` and `error_message` off `transform-index get ` to see how the last rebuild went. + ## Transform jobs (schedules) A schedule lives in a separate resource (`transform-job`). A job carries **tags** (`tag_ids`), not transform ids: each run executes every transform carrying one of the job's tags. You add a transform to a job by tagging the transform (`transform update --body '{"tag_ids":[…]}'`), not by listing it on the job. Create/update with the same body-input pattern (`--file body.json`). diff --git a/packages/cli/src/commands/transform-index/create.ts b/packages/cli/src/commands/transform-index/create.ts new file mode 100644 index 0000000..7f67500 --- /dev/null +++ b/packages/cli/src/commands/transform-index/create.ts @@ -0,0 +1,37 @@ +import { + TransformIndexCreateInput, + TransformIndexRequest, +} from "@metabase/client/domain/transform-index"; + +import { renderSummary } from "../../output/render"; +import { transformIndexRequestView } from "../../output/views/transform-index"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create an index request on a transform's target table" }, + details: + "Body keys: `transform_id` and `structured`, a definition dispatched on `kind` (`btree`, `hash`, `sortkey`, `distkey`, `clustering`, `order-by`, `skip-index`, …). The request lands pending — the physical index is created the next time the target table is rebuilt in full.", + capabilities: { minVersion: 64 }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + inputSchema: TransformIndexCreateInput, + outputSchema: TransformIndexRequest, + examples: [ + 'mb transform-index create --body \'{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}\'', + "mb transform-index create --file index.json", + 'echo \'{"transform_id":1,"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"}]}}\' | mb transform-index create', + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, TransformIndexCreateInput); + const client = await getClient(); + const created = await client.transformIndex.create(body); + renderSummary( + created, + transformIndexRequestView, + `Created index request ${created.id} "${created.index_name}".`, + ctx, + ); + }, +}); diff --git a/packages/cli/src/commands/transform-index/delete.ts b/packages/cli/src/commands/transform-index/delete.ts new file mode 100644 index 0000000..f1d352a --- /dev/null +++ b/packages/cli/src/commands/transform-index/delete.ts @@ -0,0 +1,31 @@ +import { confirmAndDelete, DeleteResult } from "../delete-runtime"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "delete", description: "Mark an index request for deletion by id" }, + capabilities: { minVersion: 64 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + yes: { type: "boolean", description: "Skip confirmation", default: false }, + id: { type: "positional", description: "Index request id", required: true }, + }, + outputSchema: DeleteResult, + examples: ["mb transform-index delete 1 --yes", "mb transform-index delete 1"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + await confirmAndDelete({ + id, + yes: args.yes, + promptMessage: `Delete index request ${id}? The physical index drops on the next full rebuild.`, + successMessage: `Marked index request ${id} for deletion; it drops on the next full rebuild.`, + abortMessage: `Aborted; index request ${id} was not deleted.`, + deleteResource: () => client.transformIndex.delete(id), + ctx, + }); + }, +}); diff --git a/packages/cli/src/commands/transform-index/get.ts b/packages/cli/src/commands/transform-index/get.ts new file mode 100644 index 0000000..6deb712 --- /dev/null +++ b/packages/cli/src/commands/transform-index/get.ts @@ -0,0 +1,26 @@ +import { TransformIndexRequest } from "@metabase/client/domain/transform-index"; + +import { renderItem } from "../../output/render"; +import { transformIndexRequestView } from "../../output/views/transform-index"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a transform index request by id" }, + capabilities: { minVersion: 64 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Index request id", required: true }, + }, + outputSchema: TransformIndexRequest, + examples: ["mb transform-index get 1", "mb transform-index get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const request = await client.transformIndex.get(id); + renderItem(request, transformIndexRequestView, ctx); + }, +}); diff --git a/packages/cli/src/commands/transform-index/index.ts b/packages/cli/src/commands/transform-index/index.ts new file mode 100644 index 0000000..653a895 --- /dev/null +++ b/packages/cli/src/commands/transform-index/index.ts @@ -0,0 +1,14 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "transform-index", + description: "Manage indexes on a transform's target table", + skills: [{ skill: "transform", purpose: "indexes reapplied on each full transform run" }], + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + delete: () => import("./delete").then((mod) => mod.default), + }, +}); diff --git a/packages/cli/src/commands/transform-index/list.ts b/packages/cli/src/commands/transform-index/list.ts new file mode 100644 index 0000000..916ac4b --- /dev/null +++ b/packages/cli/src/commands/transform-index/list.ts @@ -0,0 +1,33 @@ +import { TransformIndexCompact } from "@metabase/client/domain/transform-index"; + +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { transformIndexView } from "../../output/views/transform-index"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformIndexListEnvelope = listEnvelopeSchema(TransformIndexCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List a transform's target-table indexes" }, + details: + "Returns the indexes observed in the warehouse merged with the index requests Metabase manages for the transform, so a request that has not been applied yet shows up with `present_in_warehouse: false`.", + capabilities: { minVersion: 64 }, + args: { + ...outputFlags, + ...listFlags, + ...profileFlag, + ...connectionFlags, + transformId: { type: "positional", description: "Transform id", required: true }, + }, + outputSchema: TransformIndexListEnvelope, + examples: ["mb transform-index list 1", "mb transform-index list 1 --json"], + async run({ args, ctx, getClient }) { + const transformId = parseId(args.transformId); + const client = await getClient(); + const { data } = await client.transformIndex.list(transformId); + renderList(windowList(data, ctx.range), transformIndexView, ctx); + }, +}); diff --git a/packages/cli/src/commands/transform-index/update.ts b/packages/cli/src/commands/transform-index/update.ts new file mode 100644 index 0000000..ebdb2ca --- /dev/null +++ b/packages/cli/src/commands/transform-index/update.ts @@ -0,0 +1,44 @@ +import { + TransformIndexRequest, + TransformIndexUpdateInput, +} from "@metabase/client/domain/transform-index"; + +import { renderSummary } from "../../output/render"; +import { transformIndexRequestView } from "../../output/views/transform-index"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Replace an index request's definition by id" }, + details: + "Replaces the `structured` definition and marks the request update-pending. The index name, kind and type are fixed at creation — changing those means deleting the request and creating another.", + capabilities: { minVersion: 64 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Index request id", required: true }, + }, + inputSchema: TransformIndexUpdateInput, + outputSchema: TransformIndexRequest, + examples: [ + 'mb transform-index update 1 --body \'{"structured":{"kind":"btree","name":"idx_id","columns":[{"name":"id"},{"name":"created_at"}]}}\'', + "mb transform-index update 1 --file index.json", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, TransformIndexUpdateInput); + const client = await getClient(); + const updated = await client.transformIndex.update(id, body); + renderSummary( + updated, + transformIndexRequestView, + `Updated index request ${updated.id} "${updated.index_name}".`, + ctx, + ); + }, +}); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 8765d50..706420a 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -27,6 +27,7 @@ const main: CommandDef = defineCommand({ transform: () => import("./commands/transform").then((mod) => mod.default), "transform-job": () => import("./commands/transform-job").then((mod) => mod.default), "transform-tag": () => import("./commands/transform-tag").then((mod) => mod.default), + "transform-index": () => import("./commands/transform-index").then((mod) => mod.default), setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), "git-sync": () => import("./commands/git-sync").then((mod) => mod.default), diff --git a/packages/cli/src/output/views/transform-index.test.ts b/packages/cli/src/output/views/transform-index.test.ts new file mode 100644 index 0000000..742b65b --- /dev/null +++ b/packages/cli/src/output/views/transform-index.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { MALFORMED_CELL } from "../table"; +import type { ResourceView } from "../view"; +import { transformIndexRequestView, transformIndexView } from "./transform-index"; + +function renderCell(view: ResourceView, key: keyof T & string, value: unknown): string { + const column = view.tableColumns.find((candidate) => candidate.key === key); + if (column?.format === undefined) { + throw new Error(`the view declares no formatter for "${key}"`); + } + return column.format(value); +} + +describe("transformIndexRequestView definition cell", () => { + it("renders a column-carrying definition as kind(columns)", () => { + expect( + renderCell(transformIndexRequestView, "structured", { + kind: "btree", + name: "idx_id", + columns: [{ name: "id" }, { name: "created_at" }], + }), + ).toBe("btree(id, created_at)"); + }); + + it("renders a definition that names no columns as the bare kind", () => { + expect( + renderCell(transformIndexRequestView, "structured", { kind: "distkey", style: "even" }), + ).toBe("distkey"); + }); + + // An index kind this Metabase writes and the CLI does not know misses the discriminated union. + // Blanking the cell would read as "this request declares nothing". + it("renders a definition that fails its schema as the malformed marker", () => { + expect( + renderCell(transformIndexRequestView, "structured", { + kind: "zorder", + columns: [{ name: "id" }], + }), + ).toBe(MALFORMED_CELL); + }); +}); + +describe("transformIndexView cells", () => { + it("renders the key columns as a comma-separated list", () => { + expect(renderCell(transformIndexView, "key_columns", ["id", "created_at"])).toBe( + "id, created_at", + ); + }); + + it("renders a managed request as its id and status", () => { + expect( + renderCell(transformIndexView, "request", { + id: 3, + transform_id: 1, + index_name: "idx_id", + status: "create-pending", + structured: { kind: "btree", name: "idx_id", columns: [{ name: "id" }] }, + error_message: null, + }), + ).toBe("#3 create-pending"); + }); + + it("renders an index Metabase does not manage as an empty request cell", () => { + expect(renderCell(transformIndexView, "request", undefined)).toBe(""); + }); + + it("renders a request that fails its schema as the malformed marker", () => { + expect(renderCell(transformIndexView, "request", { id: 3, status: "invented-status" })).toBe( + MALFORMED_CELL, + ); + }); +}); diff --git a/packages/cli/src/output/views/transform-index.ts b/packages/cli/src/output/views/transform-index.ts new file mode 100644 index 0000000..da2b7f1 --- /dev/null +++ b/packages/cli/src/output/views/transform-index.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { + type TransformIndex, + TransformIndexCompact, + type TransformIndexRequest, + TransformIndexRequestCompact, + TransformIndexStructured, +} from "@metabase/client/domain/transform-index"; + +import { MALFORMED_CELL } from "../table"; +import type { ResourceView } from "../view"; + +const ColumnNames = z.array(z.string()); +const OptionalRequest = TransformIndexRequestCompact.optional(); + +function formatStructured(value: unknown): string { + const parsed = TransformIndexStructured.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + const columns = parsed.data.columns; + if (columns === undefined) { + return parsed.data.kind; + } + return `${parsed.data.kind}(${columns.map((column) => column.name).join(", ")})`; +} + +function formatColumnNames(value: unknown): string { + const parsed = ColumnNames.safeParse(value); + return parsed.success ? parsed.data.join(", ") : MALFORMED_CELL; +} + +// An index the warehouse holds but Metabase does not manage carries no request, which is a fact +// about the index rather than a rendering failure. +function formatRequestSummary(value: unknown): string { + const parsed = OptionalRequest.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + return parsed.data === undefined ? "" : `#${parsed.data.id} ${parsed.data.status}`; +} + +export const transformIndexRequestView: ResourceView = { + compactPick: TransformIndexRequestCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "transform_id", label: "Transform" }, + { key: "index_name", label: "Name" }, + { key: "status", label: "Status" }, + { key: "structured", label: "Definition", format: formatStructured }, + { key: "error_message", label: "Error" }, + ], +}; + +export const transformIndexView: ResourceView = { + compactPick: TransformIndexCompact, + tableColumns: [ + { key: "name", label: "Name" }, + { key: "kind", label: "Kind" }, + { key: "key_columns", label: "Columns", format: formatColumnNames }, + { key: "is_unique", label: "Unique" }, + { key: "metabase_managed", label: "Managed" }, + { key: "present_in_warehouse", label: "In warehouse" }, + { key: "request", label: "Request", format: formatRequestSummary }, + ], +}; diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index 4faecb4..9b2a893 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -388,6 +388,11 @@ const ALL_COMMANDS = [ "transform-tag create", "transform-tag update", "transform-tag delete", + "transform-index list", + "transform-index get", + "transform-index create", + "transform-index update", + "transform-index delete", "setting list", "setting get", "setting set", @@ -446,6 +451,7 @@ const ALL_COMMANDS = [ const MEASURE_CAPABILITIES = { minVersion: 59 } as const; const TRANSFORM_CAPABILITIES = { minVersion: 59 } as const; const TRANSFORM_JOB_SET_ACTIVE_CAPABILITIES = { minVersion: 61 } as const; +const TRANSFORM_INDEX_CAPABILITIES = { minVersion: 64 } as const; let cachedEntries: Promise | null = null; @@ -539,6 +545,22 @@ describe("command tree contract", () => { }); }); + it("gates every transform-index command at v64", async () => { + const entries = await allEntries(); + const transformIndexCapabilities = Object.fromEntries( + entries + .filter((entry) => entry.command.startsWith("transform-index ")) + .map((entry) => [entry.command, entry.capabilities]), + ); + expect(transformIndexCapabilities).toEqual({ + "transform-index list": TRANSFORM_INDEX_CAPABILITIES, + "transform-index get": TRANSFORM_INDEX_CAPABILITIES, + "transform-index create": TRANSFORM_INDEX_CAPABILITIES, + "transform-index update": TRANSFORM_INDEX_CAPABILITIES, + "transform-index delete": TRANSFORM_INDEX_CAPABILITIES, + }); + }); + it("advertises a --limit default on search alone, so the shared flag description stays true", async () => { const entries = await allEntries(); const withDefault = Object.fromEntries( diff --git a/packages/client/README.md b/packages/client/README.md index a450b28..f64e6be 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -396,7 +396,8 @@ Every Metabase resource exports a full schema and a compact projection: `Card`/` `Collection`/`CollectionCompact`, `Dashboard`/`DashboardCompact`, `Database`/`DatabaseCompact`, `Document`, `Field`, `FieldValues`, `Library`, `Measure`, `Notification`, `ParameterValues`, `Pulse`, `SearchResult`, `Segment`, `Setting`, `Snippet`, `Table`, `Timeline`, `TimelineEvent`, `Transform`, -`TransformRun`, `TransformJob`, `TransformTag`, `CurrentUser`, `CardQueryResult`, +`TransformRun`, `TransformJob`, `TransformTag`, `TransformIndex`, `TransformIndexRequest`, +`CurrentUser`, `CardQueryResult`, `EidTranslateResult`, `SetupResult`, `SyncTask`, `SyncDirtyItem`, `DashboardTab`, and the nested shapes they compose (`Dashcard`, `CollectionItem`, `PulseChannel`, `NotificationHandler`, …). diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 6301dac..38334d2 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -20,6 +20,7 @@ import { snippetResource } from "./resources/snippet"; import { tableResource } from "./resources/table"; import { timelineEventResource } from "./resources/timeline-event"; import { timelineResource } from "./resources/timeline"; +import { transformIndexResource } from "./resources/transform-index"; import { transformJobResource } from "./resources/transform-job"; import { transformResource } from "./resources/transform"; import { transformTagResource } from "./resources/transform-tag"; @@ -54,6 +55,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) timeline: timelineResource(transport), timelineEvent: timelineEventResource(transport), transform: transformResource(transport), + transformIndex: transformIndexResource(transport), transformJob: transformJobResource(transport), transformTag: transformTagResource(transport), upload: uploadResource(transport), diff --git a/packages/client/src/domain/transform-index.ts b/packages/client/src/domain/transform-index.ts new file mode 100644 index 0000000..5f1767b --- /dev/null +++ b/packages/client/src/domain/transform-index.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +// Warehouse identifier caps, declared so an over-long name fails before the round trip. +const IndexName = z.string().min(1).max(63); +const ColumnName = z.string().min(1).max(255); + +const IndexColumn = z + .object({ + name: ColumnName, + direction: z.enum(["asc", "desc"]).optional(), + }) + .loose(); + +const ClassicalIndexStructured = z + .object({ + kind: z.enum([ + "btree", + "hash", + "gin", + "gist", + "brin", + "spgist", + "fulltext", + "spatial", + "clustered", + "nonclustered", + "columnstore", + ]), + name: IndexName, + columns: z.array(IndexColumn).min(1), + include: z.array(ColumnName).optional(), + unique: z.boolean().optional(), + }) + .loose(); + +const SortKeyStructured = z + .object({ + kind: z.literal("sortkey"), + style: z.enum(["compound", "interleaved"]), + columns: z.array(IndexColumn).min(1), + }) + .loose(); + +const DistKeyStructured = z + .object({ + kind: z.literal("distkey"), + style: z.enum(["key", "all", "even"]), + columns: z.tuple([IndexColumn]).optional(), + }) + .loose(); + +const ClusteringStructured = z + .object({ + kind: z.literal("clustering"), + name: IndexName.optional(), + columns: z.array(IndexColumn).min(1), + }) + .loose(); + +const OrderByStructured = z + .object({ + kind: z.literal("order-by"), + columns: z.array(IndexColumn).min(1), + }) + .loose(); + +const SkipIndexStructured = z + .object({ + kind: z.literal("skip-index"), + name: IndexName, + columns: z.array(IndexColumn).min(1), + type: z.enum(["minmax", "bloom_filter"]), + granularity: z.number().int().positive().optional(), + }) + .loose(); + +export const TransformIndexStructured = z.discriminatedUnion("kind", [ + ClassicalIndexStructured, + SortKeyStructured, + DistKeyStructured, + ClusteringStructured, + OrderByStructured, + SkipIndexStructured, +]); +export type TransformIndexStructured = z.infer; + +const TransformIndexStatus = z.enum([ + "create-pending", + "update-pending", + "delete-pending", + "running", + "succeeded", + "failed", +]); + +export const TransformIndexRequest = z + .object({ + id: z.number().int(), + transform_id: z.number().int(), + index_name: z.string(), + structured: TransformIndexStructured, + status: TransformIndexStatus, + error_message: z.string().nullable(), + created_by: z.number().int().nullable(), + created_at: z.string(), + updated_at: z.string(), + last_executed_at: z.string().nullable(), + }) + .loose(); +export type TransformIndexRequest = z.infer; + +export const TransformIndexRequestCompact = TransformIndexRequest.pick({ + id: true, + transform_id: true, + index_name: true, + status: true, + structured: true, + error_message: true, +}).strip(); +export type TransformIndexRequestCompact = z.infer; + +export const TransformIndex = z + .object({ + metabase_managed: z.boolean(), + present_in_warehouse: z.boolean(), + name: z.string().nullable(), + kind: z.string(), + key_columns: z.array(z.string()), + include_columns: z.array(z.string().nullable()), + is_unique: z.boolean(), + is_primary: z.boolean(), + is_valid: z.boolean(), + partial_predicate: z.string().nullable(), + access_method: z.string().nullable(), + request: TransformIndexRequest.optional(), + }) + .loose(); +export type TransformIndex = z.infer; + +export const TransformIndexCompact = TransformIndex.pick({ + name: true, + kind: true, + key_columns: true, + is_unique: true, + is_primary: true, + metabase_managed: true, + present_in_warehouse: true, +}) + .strip() + .extend({ request: TransformIndexRequestCompact.optional() }); +export type TransformIndexCompact = z.infer; + +export const TransformIndexCreateInput = z + .object({ + transform_id: z.number().int().positive(), + structured: TransformIndexStructured, + }) + .loose(); +export type TransformIndexCreateInput = z.infer; + +export const TransformIndexUpdateInput = z + .object({ + structured: TransformIndexStructured, + }) + .loose(); +export type TransformIndexUpdateInput = z.infer; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7a46af2..99be54c 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -38,6 +38,8 @@ import { TimeoutError, TipTapNodeInput, TransformCreateInput, + TransformIndexCreateInput, + TransformIndexUpdateInput, TransformJobCreateInput, TransformJobUpdateInput, TransformTagCreateInput, @@ -101,6 +103,8 @@ const writePath = { TimelineUpdateInput: pin(TimelineUpdateInput), TipTapNodeInput: pin(TipTapNodeInput), TransformCreateInput: pin(TransformCreateInput), + TransformIndexCreateInput: pin(TransformIndexCreateInput), + TransformIndexUpdateInput: pin(TransformIndexUpdateInput), TransformJobCreateInput: pin(TransformJobCreateInput), TransformJobUpdateInput: pin(TransformJobUpdateInput), TransformTagCreateInput: pin(TransformTagCreateInput), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3ea5b8b..f70206a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -257,6 +257,15 @@ export { TransformTarget, TransformUpdateInput, } from "./domain/transform"; +export { + TransformIndex, + TransformIndexCompact, + TransformIndexCreateInput, + TransformIndexRequest, + TransformIndexRequestCompact, + TransformIndexStructured, + TransformIndexUpdateInput, +} from "./domain/transform-index"; export { TransformJob, TransformJobActiveResult, diff --git a/packages/client/src/resources/transform-index.test.ts b/packages/client/src/resources/transform-index.test.ts new file mode 100644 index 0000000..1af2ec8 --- /dev/null +++ b/packages/client/src/resources/transform-index.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { TransformIndexStructured } from "../domain/transform-index"; +import type { ClientCredentials } from "../http/transport"; +import { + captureFetch, + type FetchScript, + jsonResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const STRUCTURED: TransformIndexStructured = { + kind: "btree", + name: "idx_one", + columns: [{ name: "one" }], +}; + +const UNIQUE_STRUCTURED: TransformIndexStructured = { ...STRUCTURED, unique: true }; + +const REQUEST = { + id: 7, + transform_id: 1, + index_name: "idx_one", + structured: STRUCTURED, + status: "create-pending", + error_message: null, + created_by: 2, + created_at: "2026-07-23T15:00:00Z", + updated_at: "2026-07-23T15:00:00Z", + last_executed_at: null, +}; + +const INDEX = { + metabase_managed: true, + present_in_warehouse: false, + name: "idx_one", + kind: "btree", + key_columns: ["one"], + include_columns: [], + is_unique: false, + is_primary: false, + is_valid: true, + partial_predicate: null, + access_method: null, + request: REQUEST, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: FetchScript) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("transform-index resource wire requests", () => { + it("sends the list request scoped to the transform", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [INDEX] })]); + + await mb.transformIndex.list(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/index?transform-id=1", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("unwraps the list envelope and reports no total, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse({ data: [INDEX] })]); + + expect(await mb.transformIndex.list(1)).toEqual({ data: [INDEX], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(REQUEST)]); + + await mb.transformIndex.get(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/index/request/7", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body it was given", async () => { + const { mb, capture } = clientOver([jsonResponse(REQUEST)]); + + await mb.transformIndex.create({ transform_id: 1, structured: STRUCTURED }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/index/request", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"transform_id":1,"structured":{"kind":"btree","name":"idx_one","columns":[{"name":"one"}]}}', + }, + ]); + }); + + it("sends the update request as a PUT carrying the replacement definition", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...REQUEST, status: "update-pending" })]); + + await mb.transformIndex.update(7, { structured: UNIQUE_STRUCTURED }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/index/request/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"structured":{"kind":"btree","name":"idx_one","columns":[{"name":"one"}],"unique":true}}', + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transformIndex.delete(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/index/request/7", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/transform-index.ts b/packages/client/src/resources/transform-index.ts new file mode 100644 index 0000000..d29900f --- /dev/null +++ b/packages/client/src/resources/transform-index.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { + TransformIndex, + type TransformIndexCreateInput, + TransformIndexRequest, + type TransformIndexUpdateInput, +} from "../domain/transform-index"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/index` wraps its rows in a `{ data }` envelope carrying no count, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const TransformIndexApiList = z.object({ data: z.array(TransformIndex) }).loose(); + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function transformIndexResource(transport: Transport) { + /** + * List the indexes on a transform's target table: those observed in the warehouse, merged with + * the index requests Metabase manages for it. + */ + async function list( + transformId: number, + options: RequestOptions = {}, + ): Promise> { + const { data } = await transport.requestParsed(TransformIndexApiList, "/api/index", { + ...options, + query: { "transform-id": transformId }, + }); + return { data, total: null }; + } + + /** Get one index request by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(TransformIndexRequest, `/api/index/request/${id}`, { + ...options, + }); + } + + /** + * Request an index on a transform's target table. The request lands pending; the physical index + * is created the next time the target table is rebuilt in full. + */ + async function create( + params: TransformIndexCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformIndexRequest, "/api/index/request", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Replace an index request's definition by id. The name, kind and type are fixed at creation — + * changing those means deleting the request and creating another. + */ + async function update( + id: number, + params: TransformIndexUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformIndexRequest, `/api/index/request/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Mark an index request for deletion by id; the physical index drops on the next full rebuild. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/index/request/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + return { list, get, create, update, delete: remove }; +} diff --git a/tests/e2e/transform-index.e2e.test.ts b/tests/e2e/transform-index.e2e.test.ts new file mode 100644 index 0000000..4658cf9 --- /dev/null +++ b/tests/e2e/transform-index.e2e.test.ts @@ -0,0 +1,424 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { TransformCompact, TransformRunResult } from "@metabase/client/domain/transform"; +import { + TransformIndex, + TransformIndexRequest, + TransformIndexRequestCompact, + type TransformIndexStructured, +} from "@metabase/client/domain/transform-index"; +import { parseJson } from "@metabase/client/json"; + +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { TransformIndexListEnvelope } from "../../packages/cli/src/commands/transform-index/list"; +import { listEnvelopeSchema } from "../../packages/cli/src/output/types"; +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; +import { requireServer } from "./server-gate"; + +const INDEX_NAME = "idx_one"; +const TARGET_COLUMN = "one"; +const TRANSFORM_TARGET_TABLE = "e2e_index_transform"; + +// The body it rides on fails the client-side parse, so this never reaches a transform. +const UNSENT_TRANSFORM_ID = 1; + +const STRUCTURED: TransformIndexStructured = { + kind: "btree", + name: INDEX_NAME, + columns: [{ name: TARGET_COLUMN }], +}; + +const UNIQUE_STRUCTURED: TransformIndexStructured = { ...STRUCTURED, unique: true }; + +const TRANSFORM_BODY = { + name: TRANSFORM_TARGET_TABLE, + source: { + type: "query", + query: { + type: "native", + database: SEEDED.warehouseDbId, + native: { query: `SELECT 1 AS ${TARGET_COLUMN}` }, + }, + }, + target: { + type: "table", + database: SEEDED.warehouseDbId, + schema: "public", + name: TRANSFORM_TARGET_TABLE, + }, +}; + +interface SeededIndex { + transformId: number; + request: TransformIndexRequestCompact; +} + +function expectedRequest( + id: unknown, + transformId: number, + status: string, + structured: object = STRUCTURED, +) { + return { + id, + transform_id: transformId, + index_name: INDEX_NAME, + status, + structured, + error_message: null, + }; +} + +const skipReason = requireServer("transform-index › transform-index e2e", { minVersion: 64 }); + +describe.skipIf(skipReason !== null)("transform-index e2e", () => { + 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 authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createSeedTransform(): Promise { + const result = await runCli({ + args: ["transform", "create", "--json"], + stdin: JSON.stringify(TRANSFORM_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, TransformCompact).id; + } + + async function seedTransformIndex(): Promise { + const transformId = await createSeedTransform(); + const result = await runCli({ + args: ["transform-index", "create", "--json"], + stdin: JSON.stringify({ transform_id: transformId, structured: STRUCTURED }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return { transformId, request: parseJson(result.stdout, TransformIndexRequestCompact) }; + } + + // A first run of a table-target transform is a full-create run, which applies pending index + // requests and settles their status before the run is marked succeeded. + async function runTransformToCompletion(transformId: number): Promise { + const result = await runCli({ + args: ["transform", "run", String(transformId), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: 60_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformRunResult).final?.status).toBe("succeeded"); + } + + it("create returns a create-pending request; get round-trips it by id", async () => { + const { transformId, request } = await seedTransformIndex(); + expect(request).toEqual(expectedRequest(expect.any(Number), transformId, "create-pending")); + + const getResult = await runCli({ + args: ["transform-index", "get", String(request.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode, getResult.stderr).toBe(0); + expect(parseJson(getResult.stdout, TransformIndexRequestCompact)).toEqual( + expectedRequest(request.id, transformId, "create-pending"), + ); + }); + + it("list surfaces the managed request as a not-yet-present declared index", async () => { + const { transformId, request } = await seedTransformIndex(); + + const result = await runCli({ + args: ["transform-index", "list", String(transformId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformIndexListEnvelope)).toEqual({ + data: [ + { + name: INDEX_NAME, + kind: "btree", + key_columns: [TARGET_COLUMN], + is_unique: false, + is_primary: false, + metabase_managed: true, + present_in_warehouse: false, + request: expectedRequest(request.id, transformId, "create-pending"), + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + }); + + it("a full run creates the index in the warehouse and reports it as observed there", async () => { + const { transformId, request } = await seedTransformIndex(); + await runTransformToCompletion(transformId); + + const result = await runCli({ + args: ["transform-index", "list", String(transformId), "--full", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, listEnvelopeSchema(TransformIndex))).toEqual({ + data: [ + { + metabase_managed: true, + present_in_warehouse: true, + name: INDEX_NAME, + kind: "btree", + key_columns: [TARGET_COLUMN], + include_columns: [], + is_unique: false, + is_primary: false, + is_valid: true, + partial_predicate: null, + access_method: "btree", + request: { + id: request.id, + transform_id: transformId, + index_name: INDEX_NAME, + structured: STRUCTURED, + status: "succeeded", + error_message: null, + created_by: expect.any(Number), + created_at: expect.any(String), + updated_at: expect.any(String), + last_executed_at: expect.any(String), + }, + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + }); + + it("a full run settles the index request entity to succeeded with an execution timestamp", async () => { + const { transformId, request } = await seedTransformIndex(); + await runTransformToCompletion(transformId); + + const result = await runCli({ + args: ["transform-index", "get", String(request.id), "--full", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformIndexRequest)).toEqual({ + id: request.id, + transform_id: transformId, + index_name: INDEX_NAME, + structured: STRUCTURED, + status: "succeeded", + error_message: null, + created_by: expect.any(Number), + created_at: expect.any(String), + updated_at: expect.any(String), + last_executed_at: expect.any(String), + }); + }); + + it("update replaces the definition and marks the request update-pending", async () => { + const { transformId, request } = await seedTransformIndex(); + + const updateResult = await runCli({ + args: ["transform-index", "update", String(request.id), "--json"], + stdin: JSON.stringify({ structured: UNIQUE_STRUCTURED }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(updateResult.exitCode, updateResult.stderr).toBe(0); + expect(parseJson(updateResult.stdout, TransformIndexRequestCompact)).toEqual( + expectedRequest(request.id, transformId, "update-pending", UNIQUE_STRUCTURED), + ); + }); + + it("update refuses to change the index kind, which is fixed at creation", async () => { + const { request } = await seedTransformIndex(); + + const result = await runCli({ + args: ["transform-index", "update", String(request.id), "--json"], + stdin: JSON.stringify({ + structured: { kind: "hash", name: INDEX_NAME, columns: [{ name: TARGET_COLUMN }] }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400."); + }); + + it("delete --yes marks the request delete-pending; get still shows it in that state", async () => { + const { transformId, request } = await seedTransformIndex(); + + const deleteResult = await runCli({ + args: ["transform-index", "delete", String(request.id), "--yes", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(deleteResult.exitCode, deleteResult.stderr).toBe(0); + expect(parseJson(deleteResult.stdout, DeleteResult)).toEqual({ + deleted: true, + aborted: false, + id: request.id, + }); + + const getResult = await runCli({ + args: ["transform-index", "get", String(request.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode, getResult.stderr).toBe(0); + expect(parseJson(getResult.stdout, TransformIndexRequestCompact)).toEqual( + expectedRequest(request.id, transformId, "delete-pending"), + ); + }); + + it("create rejects a second request under an index name the transform already carries", async () => { + const { transformId } = await seedTransformIndex(); + + const result = await runCli({ + args: ["transform-index", "create", "--json"], + stdin: JSON.stringify({ transform_id: transformId, structured: STRUCTURED }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400."); + }); + + it("create with an empty body fails Zod validation before sending", async () => { + const result = await runCli({ + args: ["transform-index", "create", "--json"], + stdin: JSON.stringify({}), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("create with an unknown structured kind fails Zod validation before sending", async () => { + const result = await runCli({ + args: ["transform-index", "create", "--json"], + stdin: JSON.stringify({ transform_id: UNSENT_TRANSFORM_ID, structured: { kind: "nope" } }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["transform-index", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["transform-index", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: GET /api/index/request/9999999."); + }); + + it("update against a missing id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["transform-index", "update", "9999999", "--json"], + stdin: JSON.stringify({ structured: STRUCTURED }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: PUT /api/index/request/9999999."); + }); + + it("delete without --yes refuses in non-TTY and exits 2", async () => { + const { request } = await seedTransformIndex(); + + const result = await runCli({ + args: ["transform-index", "delete", String(request.id), "--json"], + stdin: "", + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + `refusing to delete ${request.id} without confirmation — pass --yes to proceed non-interactively`, + ); + expect(result.stdout).toBe(""); + }); + + it("list without the transform id positional fails on the missing argument", async () => { + const result = await runCli({ + args: ["transform-index", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Missing required positional argument: TRANSFORMID"); + }); + + it("list with a non-integer transform id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["transform-index", "list", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("list against a missing transform surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["transform-index", "list", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: GET /api/index?transform-id=9999999."); + }); +});