From fa70bcf02689f6f00f817269705cbb0e041d711e Mon Sep 17 00:00:00 2001 From: Artyom Keydunov Date: Tue, 16 Jun 2026 16:54:46 -0700 Subject: [PATCH 1/2] fix(cubejs-client-core): surface cubeSql error chunks instead of dropping rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cubesql endpoint streams JSONL (schema line, then data lines), and on a post-processing failure appends a trailing `{ error }` chunk. The non-streaming `cubeSql()` parser mapped every non-schema line through `JSON.parse(d).data`, so the error line became an `undefined` phantom row and the failure was silently swallowed — callers saw a "successful" result with a null row. Classify each line and throw on an `error` chunk, mirroring how `cubeSqlStream()` already handles it. Genuine parse failures still fall back to the raw response body. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cubejs-client-core/src/index.ts | 48 +++++++++++++++---- .../cubejs-client-core/test/CubeApi.test.ts | 33 +++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts index 1bac5d7708989..c6c3b376e4f14 100644 --- a/packages/cubejs-client-core/src/index.ts +++ b/packages/cubejs-client-core/src/index.ts @@ -786,19 +786,49 @@ class CubeApi { const [schema, ...data] = response.error.split('\n'); + let parsedSchema: any; try { - const parsedSchema = JSON.parse(schema); - return { - schema: parsedSchema.schema, - data: data - .filter((d: string) => d.trim().length) - .map((d: string) => JSON.parse(d).data) - .reduce((a: any, b: any) => a.concat(b), []), - ...(parsedSchema.lastRefreshTime ? { lastRefreshTime: parsedSchema.lastRefreshTime } : {}), - }; + parsedSchema = JSON.parse(schema); } catch (err) { + // Schema line isn't valid JSON — the whole `error` payload is a real error. throw new Error(response.error); } + + const rows: any[] = []; + + for (const line of data) { + if (!line.trim().length) { + continue; + } + + let parsed: any; + try { + parsed = JSON.parse(line); + } catch (err) { + // A non-JSON line after a valid schema means a malformed payload — fall + // back to surfacing the raw response rather than dropping rows silently. + throw new Error(response.error); + } + + // The stream can interleave an error chunk after the schema (e.g. a + // post-processing/cast error surfaced mid-result). Such a line has no + // `data`, so the previous `JSON.parse(d).data` concat pushed an `undefined` + // "phantom" row and silently swallowed the failure. Surface it instead — + // matching how `cubeSqlStream` classifies `error` chunks. + if (parsed.error) { + throw new Error(parsed.error); + } + + if (parsed.data) { + rows.push(...parsed.data); + } + } + + return { + schema: parsedSchema.schema, + data: rows, + ...(parsedSchema.lastRefreshTime ? { lastRefreshTime: parsedSchema.lastRefreshTime } : {}), + }; }, options, callback diff --git a/packages/cubejs-client-core/test/CubeApi.test.ts b/packages/cubejs-client-core/test/CubeApi.test.ts index 8819bff257100..a0eb922d2937d 100644 --- a/packages/cubejs-client-core/test/CubeApi.test.ts +++ b/packages/cubejs-client-core/test/CubeApi.test.ts @@ -427,6 +427,21 @@ describe('CubeApi cubeSql', () => { JSON.stringify({ data: [['Active']] }), ].join('\n'); + // The backend streams a schema chunk, then (on a post-processing failure) an error + // chunk. The error must surface as a rejection instead of being concatenated as an + // `undefined` phantom row. + const cubeSqlResponseBodyWithError = [ + JSON.stringify({ + schema: [ + { name: 'created_date', column_type: 'String' }, + ], + }), + JSON.stringify({ + error: "Post-Processing Error: Cast error: Error parsing '2026-05-01' as timestamp", + requestId: '2fbe44e4-df6f-420d-ae39-376c802323b4-span-1', + }), + ].join('\n'); + test('should parse lastRefreshTime from response', async () => { vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ subscribe: (cb) => Promise.resolve(cb({ @@ -471,6 +486,24 @@ describe('CubeApi cubeSql', () => { expect(res.schema).toEqual([{ name: 'status', column_type: 'String' }]); expect(res.data).toEqual([['Active']]); }); + + test('should surface an error chunk that follows the schema instead of swallowing it', async () => { + vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ error: cubeSqlResponseBodyWithError })), + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + await expect( + cubeApi.cubeSql('SELECT created_date FROM deals') + ).rejects.toThrow("Post-Processing Error: Cast error: Error parsing '2026-05-01' as timestamp"); + }); }); describe('CubeApi with baseRequestId', () => { From f8607888c70bd63bb70345d1d5a9fd90f75889a4 Mon Sep 17 00:00:00 2001 From: Artyom Keydunov Date: Wed, 17 Jun 2026 11:24:38 -0700 Subject: [PATCH 2/2] fix(cubejs-client-core): satisfy eslint in cubeSql error handling Drop the `no-continue` violation by inverting the loop guard, and switch the regression test's error strings to single quotes per the lint rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cubejs-client-core/src/index.ts | 42 +++++++++---------- .../cubejs-client-core/test/CubeApi.test.ts | 4 +- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts index c6c3b376e4f14..3dc2f9907625c 100644 --- a/packages/cubejs-client-core/src/index.ts +++ b/packages/cubejs-client-core/src/index.ts @@ -797,30 +797,28 @@ class CubeApi { const rows: any[] = []; for (const line of data) { - if (!line.trim().length) { - continue; - } - - let parsed: any; - try { - parsed = JSON.parse(line); - } catch (err) { - // A non-JSON line after a valid schema means a malformed payload — fall - // back to surfacing the raw response rather than dropping rows silently. - throw new Error(response.error); - } + if (line.trim().length) { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch (err) { + // A non-JSON line after a valid schema means a malformed payload — fall + // back to surfacing the raw response rather than dropping rows silently. + throw new Error(response.error); + } - // The stream can interleave an error chunk after the schema (e.g. a - // post-processing/cast error surfaced mid-result). Such a line has no - // `data`, so the previous `JSON.parse(d).data` concat pushed an `undefined` - // "phantom" row and silently swallowed the failure. Surface it instead — - // matching how `cubeSqlStream` classifies `error` chunks. - if (parsed.error) { - throw new Error(parsed.error); - } + // The stream can interleave an error chunk after the schema (e.g. a + // post-processing/cast error surfaced mid-result). Such a line has no + // `data`, so the previous `JSON.parse(d).data` concat pushed an `undefined` + // "phantom" row and silently swallowed the failure. Surface it instead — + // matching how `cubeSqlStream` classifies `error` chunks. + if (parsed.error) { + throw new Error(parsed.error); + } - if (parsed.data) { - rows.push(...parsed.data); + if (parsed.data) { + rows.push(...parsed.data); + } } } diff --git a/packages/cubejs-client-core/test/CubeApi.test.ts b/packages/cubejs-client-core/test/CubeApi.test.ts index a0eb922d2937d..8560ade1c3dff 100644 --- a/packages/cubejs-client-core/test/CubeApi.test.ts +++ b/packages/cubejs-client-core/test/CubeApi.test.ts @@ -437,7 +437,7 @@ describe('CubeApi cubeSql', () => { ], }), JSON.stringify({ - error: "Post-Processing Error: Cast error: Error parsing '2026-05-01' as timestamp", + error: 'Post-Processing Error: Cast error: Error parsing \'2026-05-01\' as timestamp', requestId: '2fbe44e4-df6f-420d-ae39-376c802323b4-span-1', }), ].join('\n'); @@ -502,7 +502,7 @@ describe('CubeApi cubeSql', () => { await expect( cubeApi.cubeSql('SELECT created_date FROM deals') - ).rejects.toThrow("Post-Processing Error: Cast error: Error parsing '2026-05-01' as timestamp"); + ).rejects.toThrow('Post-Processing Error: Cast error: Error parsing \'2026-05-01\' as timestamp'); }); });