diff --git a/docs-mintlify/reference/javascript-sdk/reference/cubejs-client-core.mdx b/docs-mintlify/reference/javascript-sdk/reference/cubejs-client-core.mdx index f526c3de8c844..c81e559ea69e6 100644 --- a/docs-mintlify/reference/javascript-sdk/reference/cubejs-client-core.mdx +++ b/docs-mintlify/reference/javascript-sdk/reference/cubejs-client-core.mdx @@ -143,7 +143,7 @@ callback? | [LoadMethodCallback](#loadmethodcallback)‹[CubeSqlResult](#cubesql > **cubeSqlStream**(**sqlQuery**: string, **options?**: [CubeSqlOptions](#cubesqloptions)): *AsyncGenerator‹CubeSqlStreamChunk›* Same as [`cubeSql`](#cubesql), but yields the results as they stream in. Each -chunk is either `{ type: 'schema', schema, lastRefreshTime? }`, +chunk is either `{ type: 'schema', schema, lastRefreshTime?, external?, usedPreAggregations? }`, `{ type: 'data', data }` (one row), or `{ type: 'error', error }`. ```js @@ -919,11 +919,13 @@ Name | Type | Optional? | Description | ### `CubeSqlResult` -Name | Type | ------- | ------ | -schema | `{ name: string, column_type: string, format?: string }[]` | -data | `(string \| number \| boolean \| null)[][]` | -lastRefreshTime? | string | +Name | Type | Description | +------ | ------ | ------ | +schema | `{ name: string, column_type: string, format?: string }[]` | - | +data | `(string \| number \| boolean \| null)[][]` | - | +lastRefreshTime? | string | - | +external? | boolean | Whether the result was served from the external (pre-aggregation) store. Only reported when true. | +usedPreAggregations? | `Record` | Pre-aggregations this result was served from, keyed by pre-aggregation table name. Absent when the query hit none, and on deployments older than the field. Entries also carry `targetTableName` in dev mode and for the Playground. | ### `DateRange` diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts index 074bb674a68af..b38b9ad0b7c4e 100644 --- a/packages/cubejs-client-core/src/index.ts +++ b/packages/cubejs-client-core/src/index.ts @@ -18,7 +18,8 @@ import { Query, QueryOrder, QueryType, - TransformedQuery + TransformedQuery, + UsedPreAggregation } from './types.js'; export type LoadMethodCallback = (error: Error | null, resultSet: T) => void; @@ -141,17 +142,51 @@ export type CubeSqlSchemaColumn = { format?: DimensionFormat | MeasureFormat; }; +/** + * Metadata the SQL API reports alongside the schema, describing the result as a + * whole rather than its columns. Optional throughout: a deployment older than the + * field, or a query that hit no pre-aggregation, simply omits it. + */ +export type CubeSqlResultMetadata = { + lastRefreshTime?: string; + /** + * Whether the result was served from the external (pre-aggregation) store. + * Only ever reported as `true`; absent means "not external, or not reported". + */ + external?: boolean; + /** + * Pre-aggregations this result was served from, keyed by pre-aggregation table + * name. Absent when the query hit none. Carries identity only, so a client can + * match a result to the pre-aggregation build behind it. + */ + usedPreAggregations?: Record; +}; + export type CubeSqlResult = { schema: CubeSqlSchemaColumn[]; data: (string | number | boolean | null)[][]; - lastRefreshTime?: string; -}; +} & CubeSqlResultMetadata; + +/** + * Pick the result-level metadata out of a parsed SQL API schema line. + * + * Must cover every result-level field the writer puts on that line + * (`node_export.rs`), and must leave an absent field absent rather than set it to + * an explicit `undefined`. Shared by all three emitters — `cubeSql`, and + * `cubeSqlStream` for both its per-chunk and trailing-buffer paths. + */ +function pickCubeSqlResultMetadata(parsed: any): CubeSqlResultMetadata { + return { + ...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}), + ...(parsed.external ? { external: parsed.external } : {}), + ...(parsed.usedPreAggregations ? { usedPreAggregations: parsed.usedPreAggregations } : {}), + }; +} -export type CubeSqlStreamChunk = { +export type CubeSqlStreamChunk = ({ type: 'schema'; schema: CubeSqlSchemaColumn[]; - lastRefreshTime?: string; -} | { +} & CubeSqlResultMetadata) | { type: 'data'; data: (string | number | boolean | null)[]; } | { @@ -864,7 +899,7 @@ class CubeApi { return { schema: parsedSchema.schema, data: rows, - ...(parsedSchema.lastRefreshTime ? { lastRefreshTime: parsedSchema.lastRefreshTime } : {}), + ...pickCubeSqlResultMetadata(parsedSchema), }; }, options, @@ -914,7 +949,7 @@ class CubeApi { yield { type: 'schema' as const, schema: parsed.schema, - ...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}), + ...pickCubeSqlResultMetadata(parsed), }; } else if (parsed.data) { yield { @@ -945,7 +980,7 @@ class CubeApi { yield { type: 'schema' as const, schema: parsed.schema, - ...(parsed.lastRefreshTime ? { lastRefreshTime: parsed.lastRefreshTime } : {}), + ...pickCubeSqlResultMetadata(parsed), }; } else if (parsed.data) { yield { diff --git a/packages/cubejs-client-core/test/CubeApi.test.ts b/packages/cubejs-client-core/test/CubeApi.test.ts index 6c7ec7597a582..9fa39b67389e5 100644 --- a/packages/cubejs-client-core/test/CubeApi.test.ts +++ b/packages/cubejs-client-core/test/CubeApi.test.ts @@ -418,6 +418,27 @@ describe('CubeApi cubeSql', () => { JSON.stringify({ data: [['Shipped', '45102']] }), ].join('\n'); + // The SQL API reports the pre-aggregations behind a result next to + // `lastRefreshTime` on the schema line, so a client can match the result to the + // build behind it (CORE-664). + const cubeSqlResponseBodyWithPreAggregations = [ + JSON.stringify({ + schema: [ + { name: 'status', column_type: 'String' }, + ], + lastRefreshTime: '2026-02-24T00:34:01.594Z', + external: true, + usedPreAggregations: { + 'dev_pre_aggregations.orders_main': { + preAggregationId: 'Orders.main', + lastUpdatedAt: 1771893241594, + type: 'rollup', + }, + }, + }), + JSON.stringify({ data: [['Active']] }), + ].join('\n'); + const cubeSqlResponseBodyNoRefreshTime = [ JSON.stringify({ schema: [ @@ -616,6 +637,109 @@ describe('CubeApi cubeSql', () => { // requestStream's query-string builder, so it never reaches the wire. expect(requestStreamSpy.mock.calls[0]?.[1]?.params?.timezone).toBeUndefined(); }); + + test('should parse usedPreAggregations from response', async () => { + vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ error: cubeSqlResponseBodyWithPreAggregations })), + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.cubeSql('SELECT status FROM orders'); + expect(res.usedPreAggregations).toEqual({ + 'dev_pre_aggregations.orders_main': { + preAggregationId: 'Orders.main', + lastUpdatedAt: 1771893241594, + type: 'rollup', + }, + }); + // The metadata fields are independent: reading one must not drop the others. + expect(res.lastRefreshTime).toBe('2026-02-24T00:34:01.594Z'); + expect(res.external).toBe(true); + expect(res.data).toEqual([['Active']]); + }); + + test('should omit usedPreAggregations when the query hit no pre-aggregation', async () => { + vi.spyOn(HttpTransport.prototype, 'request').mockImplementation(() => ({ + subscribe: (cb) => Promise.resolve(cb({ + status: 200, + text: () => Promise.resolve(JSON.stringify({ error: cubeSqlResponseBodyNoRefreshTime })), + } as any, + async () => undefined as any)) + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const res = await cubeApi.cubeSql('SELECT status FROM users'); + expect(res.usedPreAggregations).toBeUndefined(); + expect(res.external).toBeUndefined(); + // Absent must stay ABSENT, not become an explicit `undefined` key. + expect('usedPreAggregations' in res).toBe(false); + expect('external' in res).toBe(false); + }); + + test('should emit usedPreAggregations on the stream schema chunk', async () => { + vi.spyOn(HttpTransport.prototype, 'requestStream').mockImplementation(() => ({ + stream: async () => (async function* generate() { + yield new TextEncoder().encode(`${cubeSqlResponseBodyWithPreAggregations}\n`); + }()), + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const chunks: any[] = []; + for await (const chunk of cubeApi.cubeSqlStream('SELECT status FROM orders')) { + chunks.push(chunk); + } + + const schemaChunk = chunks.find((chunk) => chunk.type === 'schema'); + expect(schemaChunk?.usedPreAggregations).toEqual({ + 'dev_pre_aggregations.orders_main': { + preAggregationId: 'Orders.main', + lastUpdatedAt: 1771893241594, + type: 'rollup', + }, + }); + expect(schemaChunk?.lastRefreshTime).toBe('2026-02-24T00:34:01.594Z'); + }); + + test('should emit usedPreAggregations when the schema arrives in the trailing buffer', async () => { + // No newline after the schema line, so it is only flushed by the + // end-of-stream drain — a second, easily-forgotten copy of the same spread. + vi.spyOn(HttpTransport.prototype, 'requestStream').mockImplementation(() => ({ + stream: async () => (async function* generate() { + yield new TextEncoder().encode(cubeSqlResponseBodyWithPreAggregations.split('\n')[0]); + }()), + })); + + const cubeApi = new CubeApi('token', { + apiUrl: 'http://localhost:4000/cubejs-api/v1', + }); + + const chunks: any[] = []; + for await (const chunk of cubeApi.cubeSqlStream('SELECT status FROM orders')) { + chunks.push(chunk); + } + + const schemaChunk = chunks.find((chunk) => chunk.type === 'schema'); + expect(schemaChunk?.usedPreAggregations).toEqual({ + 'dev_pre_aggregations.orders_main': { + preAggregationId: 'Orders.main', + lastUpdatedAt: 1771893241594, + type: 'rollup', + }, + }); + }); }); describe('CubeApi with baseRequestId', () => {