Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type ParsedValues = {
"no-field-values"?: boolean;
"no-extract"?: boolean;
"api-key"?: string;
verbose?: boolean;
strict?: boolean;
};

const DEFAULT_PATHS = {
Expand All @@ -29,6 +31,8 @@ const DEFAULT_PATHS = {
extract: ".metabase/databases",
} as const;

const MAX_CAPTURED_WARNINGS = 50;

const HELP = `Usage: database-metadata <command> [arguments] [options]

Commands:
Expand All @@ -50,6 +54,8 @@ Commands:
--field-values <path> Override field-values.json path (default: .metabase/field-values.json)
--no-field-values Skip uploading field values
--api-key <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 <instance-url> Stream metadata + field values from a
Metabase instance into .metabase/ and
Expand All @@ -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 },
},
});
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
40 changes: 40 additions & 0 deletions src/upload-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type MockServerControl = {
stop: () => Promise<void>;
setFieldInsertBehavior: (behavior: FieldInsertBehavior) => void;
setFieldFailure: (oldId: number) => void;
setDatabaseFailure: (oldId: number) => void;
};

type FieldInsertBehavior = "new" | "existing" | "alternate";
Expand Down Expand Up @@ -74,6 +75,7 @@ function startMockServer(): MockServerControl {
const calls: RecordedCall[] = [];
let fieldInsertBehavior: FieldInsertBehavior = "new";
const fieldFailures = new Set<number>();
const databaseFailures = new Set<number>();
let fieldInsertCounter = 0;

const server = Bun.serve({
Expand All @@ -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 };
}
}
Expand Down Expand Up @@ -168,6 +178,9 @@ function startMockServer(): MockServerControl {
setFieldFailure: (oldId) => {
fieldFailures.add(oldId);
},
setDatabaseFailure: (oldId) => {
databaseFailures.add(oldId);
},
};
}

Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading