From 4390ab64188ff5da55b55fe5b1ef96cfd7dae4b9 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 24 Apr 2026 17:27:33 -0400 Subject: [PATCH] manage cli output --- bin/cli.ts | 47 +++++++++++++++++++++++++++++++++++-- package.json | 2 +- src/upload-metadata.test.ts | 40 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/bin/cli.ts b/bin/cli.ts index b74361c..9177dac 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -21,6 +21,8 @@ type ParsedValues = { "no-field-values"?: boolean; "no-extract"?: boolean; "api-key"?: string; + verbose?: boolean; + strict?: boolean; }; const DEFAULT_PATHS = { @@ -29,6 +31,8 @@ const DEFAULT_PATHS = { extract: ".metabase/databases", } as const; +const MAX_CAPTURED_WARNINGS = 50; + const HELP = `Usage: database-metadata [arguments] [options] Commands: @@ -50,6 +54,8 @@ Commands: --field-values Override field-values.json path (default: .metabase/field-values.json) --no-field-values Skip uploading field values --api-key API key. Defaults to METABASE_API_KEY env var. + --verbose, -v Stream per-row warnings (default: aggregate into a summary) + --strict Exit non-zero when any row was rejected (default: exit 0 on per-row errors) download-metadata Stream metadata + field values from a Metabase instance into .metabase/ and @@ -76,6 +82,8 @@ function parseArguments() { "no-field-values": { type: "boolean", default: false }, "no-extract": { type: "boolean", default: false }, "api-key": { type: "string" }, + verbose: { type: "boolean", short: "v", default: false }, + strict: { type: "boolean", default: false }, }, }); } @@ -145,14 +153,31 @@ async function handleUploadMetadata( ? undefined : (values["field-values"] ?? DEFAULT_PATHS.fieldValues); + const warnings: string[] = []; + let truncated = 0; + const onWarning = values.verbose + ? (message: string) => console.warn(message) + : (message: string) => { + if (warnings.length < MAX_CAPTURED_WARNINGS) { + warnings.push(message); + } else { + truncated += 1; + } + }; + const stats = await uploadMetadata({ metadataFile, fieldValuesFile, instanceUrl, apiKey, + onWarning, }); console.log(formatUploadReport(stats, Boolean(fieldValuesFile))); - process.exit(hasAnyErrors(stats) ? 1 : 0); + const summary = formatWarningSummary(warnings, truncated); + if (summary) { + console.log(summary); + } + process.exit(values.strict && hasAnyItemErrors(stats) ? 1 : 0); } function formatStepLine(label: string, step: UploadStepStats): string { @@ -192,10 +217,28 @@ function formatUploadReport( return lines.join("\n"); } -function hasAnyErrors(stats: UploadMetadataResult): boolean { +function hasAnyItemErrors(stats: UploadMetadataResult): boolean { return Object.values(stats).some((step) => step.errors > 0); } +function formatWarningSummary(warnings: string[], truncated: number): string { + if (warnings.length === 0) { + return ""; + } + const total = warnings.length + truncated; + const lines = [ + "", + `Warnings: ${total} captured (showing first ${warnings.length})`, + ...warnings.map((message) => ` - ${message}`), + ]; + if (truncated > 0) { + lines.push( + `${truncated} more suppressed. Re-run with --verbose to stream individual warnings.`, + ); + } + return lines.join("\n"); +} + async function handleDownloadMetadata( positionals: string[], values: ParsedValues, diff --git a/package.json b/package.json index a63fd5f..ca45561 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/database-metadata", - "version": "1.0.3", + "version": "1.0.4", "description": "CLI tool to extract Metabase database metadata into YAML files", "license": "SEE LICENSE IN LICENSE.txt", "repository": { diff --git a/src/upload-metadata.test.ts b/src/upload-metadata.test.ts index d9f54ee..771a309 100644 --- a/src/upload-metadata.test.ts +++ b/src/upload-metadata.test.ts @@ -27,6 +27,7 @@ type MockServerControl = { stop: () => Promise; setFieldInsertBehavior: (behavior: FieldInsertBehavior) => void; setFieldFailure: (oldId: number) => void; + setDatabaseFailure: (oldId: number) => void; }; type FieldInsertBehavior = "new" | "existing" | "alternate"; @@ -74,6 +75,7 @@ function startMockServer(): MockServerControl { const calls: RecordedCall[] = []; let fieldInsertBehavior: FieldInsertBehavior = "new"; const fieldFailures = new Set(); + const databaseFailures = new Set(); let fieldInsertCounter = 0; const server = Bun.serve({ @@ -99,6 +101,14 @@ function startMockServer(): MockServerControl { case "/api/database/metadata/databases": { async function* responses() { for (const line of lines as IdLine[]) { + if (databaseFailures.has(line.id)) { + yield { + old_id: line.id, + error: "no_match", + detail: "test failure", + }; + continue; + } yield { old_id: line.id, new_id: line.id + DB_OFFSET }; } } @@ -168,6 +178,9 @@ function startMockServer(): MockServerControl { setFieldFailure: (oldId) => { fieldFailures.add(oldId); }, + setDatabaseFailure: (oldId) => { + databaseFailures.add(oldId); + }, }; } @@ -312,6 +325,33 @@ describe("uploadMetadata", () => { } }); + it("skips downstream rows when the databases endpoint returns no_match for a row", async () => { + mock.setDatabaseFailure(1); + const warnings: string[] = []; + const stats = await uploadMetadata({ + metadataFile: EXAMPLE_METADATA, + fieldValuesFile: EXAMPLE_FIELD_VALUES, + instanceUrl: mock.baseUrl, + apiKey: "k", + onWarning: (message) => warnings.push(message), + }); + + expect(stats).toEqual({ + databases: { mapped: 0, errors: 1 }, + tables: { mapped: 0, errors: 0 }, + fieldsInsert: { mapped: 0, errors: 0, inserted: 0, matched: 0 }, + fieldsFinalize: { mapped: 0, errors: 0 }, + fieldValues: { mapped: 0, errors: 0 }, + }); + expect( + warnings.some((w) => w.includes("Database 1") && w.includes("no_match")), + ).toBe(true); + const tableCall = mock.calls.find( + (call) => call.path === "/api/database/metadata/tables", + ); + expect(tableCall?.lines ?? []).toEqual([]); + }); + it("counts per-row errors without aborting the pipeline", async () => { mock.setFieldFailure(1); const warnings: string[] = [];