@@ -51,15 +51,39 @@ function getDbt(): ResolvedDbt {
5151 return resolvedDbt
5252}
5353
54+ /** Shape of an execFile rejection — carries stdout/stderr alongside message. */
55+ interface ExecFileError extends Error {
56+ stdout ?: string | Buffer
57+ stderr ?: string | Buffer
58+ code ?: number | string
59+ signal ?: string
60+ }
61+
62+ /** Coerce an unknown rejection into something the catch blocks can read safely. */
63+ function toExecFileError ( e : unknown ) : ExecFileError {
64+ if ( e instanceof Error ) return e as ExecFileError
65+ return new Error ( String ( e ) ) as ExecFileError
66+ }
67+
5468function run ( args : string [ ] ) : Promise < { stdout : string ; stderr : string } > {
5569 const dbt = getDbt ( )
5670 const env = buildDbtEnv ( dbt )
5771 const cwd = globalOptions . projectRoot ?? process . cwd ( )
5872
5973 return new Promise ( ( resolve , reject ) => {
6074 execFile ( dbt . path , args , { timeout : 120_000 , maxBuffer : 10 * 1024 * 1024 , env, cwd } , ( err , stdout , stderr ) => {
61- if ( err ) reject ( err )
62- else resolve ( { stdout, stderr } )
75+ if ( err ) {
76+ // Node's execFile passes stdout/stderr as separate callback arguments,
77+ // not as properties on the error. Attach them here so callers can
78+ // surface the real dbt failure text instead of Node's generic
79+ // "Command failed: ..." message.
80+ const execErr = err as ExecFileError
81+ execErr . stdout = stdout
82+ execErr . stderr = stderr
83+ reject ( execErr )
84+ } else {
85+ resolve ( { stdout, stderr } )
86+ }
6387 } )
6488 } )
6589}
@@ -221,71 +245,82 @@ export async function execDbtShow(sql: string, limit?: number) {
221245 const args = [ "show" , "--inline" , sql , "--output" , "json" , "--log-format" , "json" ]
222246 if ( limit !== undefined ) args . push ( "--limit" , String ( limit ) )
223247
224- let lines : Record < string , unknown > [ ]
225- // Capture the run() error so we can bubble the real dbt failure up if all
248+ // Capture the run() errors so we can bubble the real dbt failure up if all
226249 // parse tiers fail; the generic "Could not parse" alone misleads callers
227250 // into treating structural project errors as transient.
228251 let primaryRunError : ExecFileError | undefined
252+ let lines : Record < string , unknown > [ ] = [ ]
229253 try {
230254 const { stdout } = await run ( args )
231255 lines = parseJsonLines ( stdout )
232256 } catch ( e ) {
233- primaryRunError = e as ExecFileError
234- lines = parseJsonLines ( primaryRunError . stdout ?? "" )
257+ primaryRunError = toExecFileError ( e )
258+ // Deliberately do NOT feed crashed-run stdout into `lines` for the
259+ // heuristic tiers below. Crash logs can contain incidental arrays that
260+ // `looksLikeRowData` would happily return as "rows" (silent wrong data).
261+ // The crashed stdout is still consulted by extractDbtError below for the
262+ // structured `level: "error"` event.
235263 }
236264
237- // --- Tier 1: known field paths ---
238- const previewLine =
239- lines . find ( ( l : any ) => l . data ?. preview ) ??
240- lines . find ( ( l : any ) => l . data ?. rows ) ??
241- lines . find ( ( l : any ) => l . result ?. preview ) ??
242- lines . find ( ( l : any ) => l . result ?. rows )
243-
244- const sqlLine =
245- lines . find ( ( l : any ) => l . data ?. sql ) ??
246- lines . find ( ( l : any ) => l . data ?. compiled_sql ) ??
247- lines . find ( ( l : any ) => l . result ?. sql )
248-
249- if ( previewLine ) {
250- const preview =
251- ( previewLine as any ) . data ?. preview ??
252- ( previewLine as any ) . data ?. rows ??
253- ( previewLine as any ) . result ?. preview ??
254- ( previewLine as any ) . result ?. rows
255-
256- // Guard JSON.parse — fall through to Tier 2 on malformed strings
257- let rows : Record < string , unknown > [ ]
258- if ( typeof preview === "string" ) {
259- const parsed = safeJsonParse ( preview )
260- if ( Array . isArray ( parsed ) ) {
261- rows = parsed
265+ // Skip the success-only tiers when the primary run failed — see comment
266+ // above. We still try Tier 3 (a separate plain-text run) because that can
267+ // recover from JSON-mode-specific failures.
268+ if ( ! primaryRunError ) {
269+ // --- Tier 1: known field paths ---
270+ const previewLine =
271+ lines . find ( ( l : any ) => l . data ?. preview ) ??
272+ lines . find ( ( l : any ) => l . data ?. rows ) ??
273+ lines . find ( ( l : any ) => l . result ?. preview ) ??
274+ lines . find ( ( l : any ) => l . result ?. rows )
275+
276+ const sqlLine =
277+ lines . find ( ( l : any ) => l . data ?. sql ) ??
278+ lines . find ( ( l : any ) => l . data ?. compiled_sql ) ??
279+ lines . find ( ( l : any ) => l . result ?. sql )
280+
281+ if ( previewLine ) {
282+ const preview =
283+ ( previewLine as any ) . data ?. preview ??
284+ ( previewLine as any ) . data ?. rows ??
285+ ( previewLine as any ) . result ?. preview ??
286+ ( previewLine as any ) . result ?. rows
287+
288+ // Guard JSON.parse — fall through to Tier 2 on malformed strings
289+ let rows : Record < string , unknown > [ ]
290+ if ( typeof preview === "string" ) {
291+ const parsed = safeJsonParse ( preview )
292+ if ( Array . isArray ( parsed ) ) {
293+ rows = parsed
294+ } else {
295+ rows = [ ] // Malformed — will fall through below
296+ }
262297 } else {
263- rows = [ ] // Malformed — will fall through below
298+ rows = preview
264299 }
265- } else {
266- rows = preview
267- }
268300
269- // Return the result — even if empty. An empty preview means the query returned
270- // zero rows, which is a valid result. Do NOT fall through to Tier 2, which could
271- // match spurious log metadata as row data.
272- const columnNames = rows . length > 0 && rows [ 0 ] ? Object . keys ( rows [ 0 ] ) : [ ]
273- const compiledSql = ( sqlLine as any ) ?. data ?. sql ?? ( sqlLine as any ) ?. data ?. compiled_sql ?? ( sqlLine as any ) ?. result ?. sql ?? sql
274- return { columnNames, columnTypes : columnNames . map ( ( ) => "string" ) , data : rows , rawSql : sql , compiledSql }
275- }
276-
277- // --- Tier 2: heuristic deep scan ---
278- for ( const line of lines ) {
279- const found = deepFind ( line , ( val ) => looksLikeRowData ( val ) )
280- if ( found ) {
281- const rows : Record < string , unknown > [ ] = typeof found === "string" ? JSON . parse ( found as string ) : ( found as Record < string , unknown > [ ] )
301+ // Return the result — even if empty. An empty preview means the query returned
302+ // zero rows, which is a valid result. Do NOT fall through to Tier 2, which could
303+ // match spurious log metadata as row data.
282304 const columnNames = rows . length > 0 && rows [ 0 ] ? Object . keys ( rows [ 0 ] ) : [ ]
283- const compiledSql = ( deepFind ( line , ( val ) => looksLikeSql ( val ) ) as string ) ?? sql
305+ const compiledSql = ( sqlLine as any ) ?. data ?. sql ?? ( sqlLine as any ) ?. data ?. compiled_sql ?? ( sqlLine as any ) ?. result ?. sql ?? sql
284306 return { columnNames, columnTypes : columnNames . map ( ( ) => "string" ) , data : rows , rawSql : sql , compiledSql }
285307 }
308+
309+ // --- Tier 2: heuristic deep scan ---
310+ for ( const line of lines ) {
311+ const found = deepFind ( line , ( val ) => looksLikeRowData ( val ) )
312+ if ( found ) {
313+ const rows : Record < string , unknown > [ ] = typeof found === "string" ? JSON . parse ( found as string ) : ( found as Record < string , unknown > [ ] )
314+ const columnNames = rows . length > 0 && rows [ 0 ] ? Object . keys ( rows [ 0 ] ) : [ ]
315+ const compiledSql = ( deepFind ( line , ( val ) => looksLikeSql ( val ) ) as string ) ?? sql
316+ return { columnNames, columnTypes : columnNames . map ( ( ) => "string" ) , data : rows , rawSql : sql , compiledSql }
317+ }
318+ }
286319 }
287320
288321 // --- Tier 3: plain text fallback (ASCII table) ---
322+ // Tried unconditionally — even if JSON-mode crashed, the plain-text mode
323+ // sometimes succeeds and gives us a usable table.
289324 let plainRunError : ExecFileError | undefined
290325 try {
291326 const plainArgs = [ "show" , "--inline" , sql ]
@@ -302,14 +337,43 @@ export async function execDbtShow(sql: string, limit?: number) {
302337 }
303338 }
304339 } catch ( e ) {
305- plainRunError = e as ExecFileError
340+ plainRunError = toExecFileError ( e )
341+ }
342+
343+ // Two distinct failure modes; don't conflate them:
344+ //
345+ // (a) JSON-mode `dbt show` actually crashed → surface the real dbt error.
346+ // This is the original motivation for the PR.
347+ //
348+ // (b) JSON-mode succeeded (exit 0) but emitted output we can't decode,
349+ // AND the plain-text retry then failed for some other reason. The
350+ // `dbt show` command itself didn't fail; our parser did. Throwing
351+ // "dbt show failed: <plain-mode error>" here would misattribute a
352+ // parser regression as a dbt execution failure.
353+ if ( primaryRunError ) {
354+ const errorLogLines = parseJsonLines ( primaryRunError . stdout ?. toString ( ) ?? "" )
355+ const realError = extractDbtError ( errorLogLines , primaryRunError , plainRunError )
356+ if ( realError ) {
357+ // Avoid doubling the "failed:" prefix when dbt's own category prefix
358+ // is already in the message (e.g. "Database Error: ...",
359+ // "Compilation Error: ...").
360+ const hasDbtCategoryPrefix = / ^ ( C o m p i l a t i o n | D a t a b a s e | R u n t i m e | P a r s i n g | V a l i d a t i o n | D e p e n d e n c y ) \s + E r r o r \b / . test (
361+ realError ,
362+ )
363+ throw new Error ( hasDbtCategoryPrefix ? realError : `dbt show failed: ${ realError } ` )
364+ }
306365 }
307366
308- // If either run() rejected, dbt actually crashed — surface the real error
309- // instead of the generic "Could not parse" message.
310- const realError = extractDbtError ( lines , primaryRunError , plainRunError )
311- if ( realError ) {
312- throw new Error ( `dbt show failed: ${ realError } ` )
367+ if ( plainRunError ) {
368+ // Both branches stay SQL-safe: extractDbtError already strips ANSI and
369+ // redacts via fallbackExitMessage; the fallback here uses the same helper
370+ // explicitly so this code path can't regress to a raw err.message even if
371+ // extractDbtError is refactored.
372+ const fallback =
373+ extractDbtError ( [ ] , undefined , plainRunError ) ??
374+ fallbackExitMessage ( undefined , plainRunError ) ??
375+ "unknown error"
376+ throw new Error ( `Could not parse dbt show JSON output, and plain-text fallback failed: ${ fallback } ` )
313377 }
314378
315379 throw new Error (
@@ -318,48 +382,86 @@ export async function execDbtShow(sql: string, limit?: number) {
318382 )
319383}
320384
321- /** Shape of an execFile rejection — carries stdout/stderr alongside message. */
322- interface ExecFileError extends Error {
323- stdout ?: string
324- stderr ?: string
325- code ?: number | string
326- }
327-
328385/**
329386 * Pick the best human-readable error from a failed `dbt show` invocation.
330387 *
331388 * Preference order:
332- * 1. A structured `level: "error"` event in the JSON log (dbt's own error msg).
389+ * 1. The LAST structured `level: "error"` event in the JSON log. dbt often
390+ * emits a generic header (e.g. "Encountered an error:") before the
391+ * actionable message; we want the actionable one.
333392 * 2. Stderr from the JSON-mode run.
334393 * 3. Stderr from the plain-text-mode run.
335- * 4. The exception message itself.
394+ * 4. A concise "dbt exited with status N" fallback. We deliberately do NOT
395+ * surface `err.message` directly when it's an execFile rejection — Node
396+ * embeds the full command line (including the inline SQL) in that
397+ * message, which would leak the user's query into logs and UI.
336398 *
337399 * Returns undefined if neither run rejected — caller falls back to the generic
338400 * "Could not parse" message, which is correct when dbt exited 0 but emitted
339401 * something we can't decode.
402+ *
403+ * ANSI escape codes are stripped from the returned message so logs and UI
404+ * bubbles stay clean (dbt may colour-code stderr and structured events).
340405 */
406+ interface DbtLogLine {
407+ info ?: { level ?: string ; msg ?: string }
408+ level ?: string
409+ msg ?: string
410+ }
411+
341412function extractDbtError (
342413 lines : Record < string , unknown > [ ] ,
343414 primary ?: ExecFileError ,
344415 plain ?: ExecFileError ,
345416) : string | undefined {
346417 if ( ! primary && ! plain ) return undefined
347418
348- const errorEvent = lines . find (
349- ( l : any ) => l . info ?. level === "error" || l . level === "error" ,
350- ) as any
351- const structuredMsg = errorEvent ?. info ?. msg ?? errorEvent ?. msg
419+ const errorMessages = lines
420+ . map ( ( l ) => {
421+ const line = l as DbtLogLine
422+ const isError = line . info ?. level === "error" || line . level === "error"
423+ if ( ! isError ) return undefined
424+ return line . info ?. msg ?? line . msg
425+ } )
426+ . filter ( ( m ) : m is string => typeof m === "string" && m . trim ( ) . length > 0 )
427+ const structuredMsg = errorMessages . at ( - 1 )
352428
353429 const primaryStderr = primary ?. stderr ?. toString ( ) . trim ( )
354430 const plainStderr = plain ?. stderr ?. toString ( ) . trim ( )
355431
356- return (
357- ( typeof structuredMsg === "string" && structuredMsg . length > 0 ? structuredMsg : undefined ) ??
432+ const chosen =
433+ ( structuredMsg && structuredMsg . length > 0 ? structuredMsg : undefined ) ??
358434 ( primaryStderr && primaryStderr . length > 0 ? primaryStderr : undefined ) ??
359435 ( plainStderr && plainStderr . length > 0 ? plainStderr : undefined ) ??
360- primary ?. message ??
361- plain ?. message
362- )
436+ fallbackExitMessage ( primary , plain )
437+
438+ return chosen ? stripAnsi ( chosen ) : undefined
439+ }
440+
441+ /**
442+ * Build a concise exit-status message that does NOT leak the dbt command line.
443+ *
444+ * Node's `execFile` rejection has `err.message` = `"Command failed: <dbt-path>
445+ * show --inline '<entire SQL>' ..."` whenever the spawned process actually
446+ * ran — exit-non-zero AND timeout/signal kills both produce this message,
447+ * embedding the user's full query (potentially with secrets, PII, multi-KB
448+ * literals) into any log/UI surface that displays the error.
449+ *
450+ * Spawn-time failures (ENOENT etc.) have a different message shape that does
451+ * NOT embed args, so they're safe to surface directly.
452+ */
453+ function fallbackExitMessage ( primary ?: ExecFileError , plain ?: ExecFileError ) : string | undefined {
454+ const err = primary ?? plain
455+ if ( ! err ) return undefined
456+
457+ const looksLikeCommandFailed = typeof err . message === "string" && err . message . startsWith ( "Command failed:" )
458+ if ( ! looksLikeCommandFailed ) return err . message
459+
460+ // The process ran; redact the embedded command line.
461+ if ( typeof err . code === "number" ) return `dbt exited with status ${ err . code } `
462+ if ( err . signal ) return `dbt killed by signal ${ err . signal } `
463+ if ( typeof err . code === "string" ) return `dbt failed: ${ err . code } `
464+ return "dbt failed (no exit code reported)"
363465}
364466
365467/**
0 commit comments