From 0bf07a7ca078c7776fbd51bf45d9da6fef1205cc Mon Sep 17 00:00:00 2001 From: santichausis Date: Wed, 29 Jul 2026 23:22:46 -0300 Subject: [PATCH 1/3] fix(internals): fall back to raw stderr when schema engine error has no message parseJsonFromStderr drops the schema engine's first stderr line as a discardable preamble. When the engine emits only that one line for a given failure, the only line with real diagnostic information gets dropped, `logs` ends up empty, and the error thrown was a bare "Schema engine error:" with nothing after it. Extract the message-joining into formatSchemaEngineError and fall back to the raw stderr whenever no log line yields a usable message, so the thrown error always carries some diagnostic content instead of silently swallowing the only information available. Closes #29838 --- .../__tests__/schemaEngineCommands.test.ts | 34 ++++++++++++++++++- .../internals/src/schemaEngineCommands.ts | 16 +++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/internals/src/__tests__/schemaEngineCommands.test.ts b/packages/internals/src/__tests__/schemaEngineCommands.test.ts index cde744eff882..dfa5331c412e 100644 --- a/packages/internals/src/__tests__/schemaEngineCommands.test.ts +++ b/packages/internals/src/__tests__/schemaEngineCommands.test.ts @@ -3,7 +3,14 @@ import tempy from 'tempy' import { describe, expect, test, vi } from 'vitest' import { credentialsToUri, uriToCredentials } from '../convertCredentials' -import { canConnectToDatabase, createDatabase, dropDatabase, execaCommand } from '../schemaEngineCommands' +import { + canConnectToDatabase, + createDatabase, + dropDatabase, + execaCommand, + formatSchemaEngineError, + type SchemaEngineLogLine, +} from '../schemaEngineCommands' if (process.env.CI) { // 5s is often not enough for the "postgresql - create database" test on macOS CI. @@ -29,6 +36,31 @@ describe('execaCommand', () => { }) }) +describe('formatSchemaEngineError', () => { + const log = (message: string): SchemaEngineLogLine => ({ + timestamp: '2021-06-11T15:35:34.084486+00:00', + level: 'ERROR', + target: 'schema_engine::logger', + fields: { message }, + }) + + test('joins messages from multiple log lines', () => { + expect(formatSchemaEngineError([log('first'), log('second')], 'raw stderr')).toBe('first\nsecond') + }) + + test('falls back to the raw stderr when no log line has a message', () => { + // e.g. when parseJsonFromStderr's `.slice(1)` drops the engine's only stderr line, + // leaving no logs to extract a message from. + expect(formatSchemaEngineError([], 'the only line of stderr, with the real error')).toBe( + 'the only line of stderr, with the real error', + ) + }) + + test('falls back to the raw stderr when log lines have empty messages', () => { + expect(formatSchemaEngineError([log('')], 'raw stderr')).toBe('raw stderr') + }) +}) + describe('canConnectToDatabase', () => { test('sqlite - can', async () => { await expect(canConnectToDatabase('file:./introspection/blog.db', __dirname)).resolves.toEqual(true) diff --git a/packages/internals/src/schemaEngineCommands.ts b/packages/internals/src/schemaEngineCommands.ts index 8fa5d9ad6353..6ba53fce2ae6 100644 --- a/packages/internals/src/schemaEngineCommands.ts +++ b/packages/internals/src/schemaEngineCommands.ts @@ -64,6 +64,16 @@ function parseJsonFromStderr(stderr: string): SchemaEngineLogLine[] { return logs } +// `parseJsonFromStderr` drops the engine's first stderr line as a discardable +// preamble. When the engine only emits that one line for a given failure, the +// only line with real information is dropped, `logs` ends up empty, and this +// used to produce a bare "Schema engine error:" with nothing after it. Fall +// back to the raw stderr so the error always carries some diagnostic content. +export function formatSchemaEngineError(logs: SchemaEngineLogLine[], stderr: string): string { + const messages = logs.map((log) => log.fields.message).filter(Boolean) + return messages.length > 0 ? messages.join('\n') : stderr +} + // could be refactored with engines using JSON RPC instead and just passing the schema export async function canConnectToDatabase( connectionString: string, @@ -94,7 +104,7 @@ export async function canConnectToDatabase( message: error.fields.message, } } else { - throw new Error(`Schema engine error:\n${logs.map((log) => log.fields.message).join('\n')}`) + throw new Error(`Schema engine error:\n${formatSchemaEngineError(logs, e.stderr)}`) } } else { throw new Error(`Schema engine exited. ${_e}`) @@ -132,7 +142,7 @@ export async function createDatabase(connectionString: string, cwd = process.cwd if (error && error.fields.error_code && error.fields.message) { throw new Error(`${error.fields.error_code}: ${error.fields.message}`) } else { - throw new Error(`Schema engine error:\n${logs.map((log) => log.fields.message).join('\n')}`) + throw new Error(`Schema engine error:\n${formatSchemaEngineError(logs, e.stderr)}`) } } else { throw new Error(`Schema engine exited. ${_e}`) @@ -158,7 +168,7 @@ export async function dropDatabase(connectionString: string, cwd = process.cwd() if (e.stderr) { const logs = parseJsonFromStderr(e.stderr) - throw new Error(`Schema engine error:\n${logs.map((log) => log.fields.message).join('\n')}`) + throw new Error(`Schema engine error:\n${formatSchemaEngineError(logs, String(e.stderr))}`) } else { throw new Error(`Schema engine exited. ${e}`) } From 4fba30e939debd17efdcb766b2fe17a92d893e0d Mon Sep 17 00:00:00 2001 From: santichausis Date: Mon, 24 Aug 2026 10:43:13 -0300 Subject: [PATCH 2/3] fix(internals): filter blank lines so a trailing newline reaches the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseJsonFromStderr's `.slice(1)` on a single-line stderr with a trailing newline (e.g. "real error\n") left a lone blank-string element, which JSON.parse rejected before formatSchemaEngineError's fallback ever ran — the exact scenario the previous commit meant to handle. Filter out blank lines after the slice, export parseJsonFromStderr for direct testing, and document formatSchemaEngineError with a proper doc comment. Found by CodeRabbit's review on #29845. --- .../__tests__/schemaEngineCommands.test.ts | 13 +++++++++++++ .../internals/src/schemaEngineCommands.ts | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/internals/src/__tests__/schemaEngineCommands.test.ts b/packages/internals/src/__tests__/schemaEngineCommands.test.ts index dfa5331c412e..7410d4a08627 100644 --- a/packages/internals/src/__tests__/schemaEngineCommands.test.ts +++ b/packages/internals/src/__tests__/schemaEngineCommands.test.ts @@ -9,6 +9,7 @@ import { dropDatabase, execaCommand, formatSchemaEngineError, + parseJsonFromStderr, type SchemaEngineLogLine, } from '../schemaEngineCommands' @@ -61,6 +62,18 @@ describe('formatSchemaEngineError', () => { }) }) +describe('parseJsonFromStderr', () => { + test('does not throw on a single-line stderr with a trailing newline', () => { + // stderr.split(/\r?\n/).slice(1) on "real error\n" leaves [''], which used to + // reach JSON.parse('') and throw before formatSchemaEngineError's fallback ever ran. + expect(parseJsonFromStderr('real error\n')).toEqual([]) + }) + + test('does not throw on a single-line stderr with no trailing newline', () => { + expect(parseJsonFromStderr('real error')).toEqual([]) + }) +}) + describe('canConnectToDatabase', () => { test('sqlite - can', async () => { await expect(canConnectToDatabase('file:./introspection/blog.db', __dirname)).resolves.toEqual(true) diff --git a/packages/internals/src/schemaEngineCommands.ts b/packages/internals/src/schemaEngineCommands.ts index 6ba53fce2ae6..1356531d7685 100644 --- a/packages/internals/src/schemaEngineCommands.ts +++ b/packages/internals/src/schemaEngineCommands.ts @@ -46,9 +46,12 @@ export interface ConnectionError { code: DatabaseErrorCodes } -function parseJsonFromStderr(stderr: string): SchemaEngineLogLine[] { +export function parseJsonFromStderr(stderr: string): SchemaEngineLogLine[] { // split by new line - const lines = stderr.split(/\r?\n/).slice(1) // Remove first element + const lines = stderr + .split(/\r?\n/) + .slice(1) // Remove first element + .filter((line) => line.trim() !== '') // A trailing newline leaves a blank line that isn't valid JSON const logs: any = [] for (const line of lines) { @@ -64,11 +67,13 @@ function parseJsonFromStderr(stderr: string): SchemaEngineLogLine[] { return logs } -// `parseJsonFromStderr` drops the engine's first stderr line as a discardable -// preamble. When the engine only emits that one line for a given failure, the -// only line with real information is dropped, `logs` ends up empty, and this -// used to produce a bare "Schema engine error:" with nothing after it. Fall -// back to the raw stderr so the error always carries some diagnostic content. +/** + * `parseJsonFromStderr` drops the engine's first stderr line as a discardable + * preamble. When the engine only emits that one line for a given failure, the + * only line with real information is dropped, `logs` ends up empty, and this + * used to produce a bare "Schema engine error:" with nothing after it. Fall + * back to the raw stderr so the error always carries some diagnostic content. + */ export function formatSchemaEngineError(logs: SchemaEngineLogLine[], stderr: string): string { const messages = logs.map((log) => log.fields.message).filter(Boolean) return messages.length > 0 ? messages.join('\n') : stderr From c27f0c6a3efd086c18e2549f2c4cc5485df26be3 Mon Sep 17 00:00:00 2001 From: santichausis Date: Mon, 24 Aug 2026 10:55:52 -0300 Subject: [PATCH 3/3] fix(internals): treat whitespace-only messages as empty too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter(Boolean) let a whitespace-only fields.message (e.g. ' ') through as a "real" message, which formatSchemaEngineError would then return instead of falling back to the raw stderr — leaving an equally unhelpful error. Filter on the trimmed value instead. Found by CodeRabbit's review on #29845. --- packages/internals/src/__tests__/schemaEngineCommands.test.ts | 4 ++++ packages/internals/src/schemaEngineCommands.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/internals/src/__tests__/schemaEngineCommands.test.ts b/packages/internals/src/__tests__/schemaEngineCommands.test.ts index 7410d4a08627..bab32980c0b3 100644 --- a/packages/internals/src/__tests__/schemaEngineCommands.test.ts +++ b/packages/internals/src/__tests__/schemaEngineCommands.test.ts @@ -60,6 +60,10 @@ describe('formatSchemaEngineError', () => { test('falls back to the raw stderr when log lines have empty messages', () => { expect(formatSchemaEngineError([log('')], 'raw stderr')).toBe('raw stderr') }) + + test('falls back to the raw stderr when log lines have whitespace-only messages', () => { + expect(formatSchemaEngineError([log(' ')], 'raw stderr')).toBe('raw stderr') + }) }) describe('parseJsonFromStderr', () => { diff --git a/packages/internals/src/schemaEngineCommands.ts b/packages/internals/src/schemaEngineCommands.ts index 1356531d7685..a58b1a4e6b0b 100644 --- a/packages/internals/src/schemaEngineCommands.ts +++ b/packages/internals/src/schemaEngineCommands.ts @@ -75,7 +75,7 @@ export function parseJsonFromStderr(stderr: string): SchemaEngineLogLine[] { * back to the raw stderr so the error always carries some diagnostic content. */ export function formatSchemaEngineError(logs: SchemaEngineLogLine[], stderr: string): string { - const messages = logs.map((log) => log.fields.message).filter(Boolean) + const messages = logs.map((log) => log.fields.message).filter((message) => Boolean(message?.trim())) return messages.length > 0 ? messages.join('\n') : stderr }