Skip to content

Commit c3c8fa2

Browse files
authored
Merge branch 'trunk' into fix/server-side-parameter-binding
2 parents 99a124b + d1bd753 commit c3c8fa2

12 files changed

Lines changed: 888 additions & 38 deletions

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
sleep 2
131131
132132
- name: Run Node.js local runtime tests
133-
run: npm run test:node -- test/local-runtime.test.ts test/user-agent.test.ts
133+
run: npm run test:node -- test/local-runtime.test.ts test/user-agent.test.ts test/grpc-http-fallback.test.ts
134134

135135
- name: Run performance tests
136136
run: npm run test:perf

README.md

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ const table = await client.sql(
8282

8383
The SDK handles all protocol negotiation automatically - you just write standard SQL with parameters.
8484

85+
### Search
86+
87+
`search()` runs vector similarity, keyword, and hybrid search against datasets that have
88+
an embedding column and a loaded embedding model.
89+
90+
```js
91+
const results = await client.search('trips near the airport', {
92+
datasets: ['taxi_trips'],
93+
limit: 5,
94+
additional_columns: ['trip_distance'],
95+
keywords: ['airport'],
96+
});
97+
98+
console.log(`${results.results.length} matches in ${results.duration_ms}ms`);
99+
100+
for (const match of results.results) {
101+
console.log(match.dataset, match.score, match.primary_key, match.data);
102+
}
103+
```
104+
105+
Each match carries the `dataset` it was found in, its similarity `score`, the matched
106+
column values in `matches`, the dataset's `primary_key`, any `additional_columns` you
107+
requested in `data`, and `metadata`. The four object fields are always present — they
108+
default to `{}` when the runtime returns nothing for them, so you can read into them
109+
without a guard.
110+
85111
## Upgrading from v2 to v3
86112

87113
Version 3.0 represents a major evolution of the SDK with cross-platform support, new APIs, and enhanced reliability.
@@ -560,6 +586,35 @@ Options:
560586
- `refresh_sql`: Custom SQL query to use for the refresh
561587
- `refresh_jitter_max`: Maximum jitter time for refresh scheduling
562588

589+
#### `listActiveQueries()` / `cancelActiveQuery(queryId)` - List and cancel running queries
590+
591+
`listActiveQueries()` reports the synchronous queries this client currently has running — those started by `sql()`, `query()`, `sqlJson()`, FlightSQL, `nsql()` and `search()` — and `cancelActiveQuery()` stops one by id.
592+
593+
The runtime does not hand a query's id back to the client that submitted it, so the two are used together: list to find the query, then cancel it. Both are scoped to the caller, so a client only ever sees and cancels its own queries.
594+
595+
```js
596+
const queries = await spiceClient.listActiveQueries();
597+
598+
for (const query of queries) {
599+
console.log(`${query.query_id} [${query.protocol}] ${query.sql_preview}`);
600+
console.log(` started at ${new Date(query.started_at_ms).toISOString()}`);
601+
}
602+
603+
// Cancel a long-running query by id.
604+
if (queries.length > 0) {
605+
const result = await spiceClient.cancelActiveQuery(queries[0].query_id);
606+
console.log(`${result.query_id} is now ${result.status}`);
607+
}
608+
```
609+
610+
Each `ActiveQuery` carries `query_id`, `protocol` (`http`, `flight`, `flightsql`, or `internal`), a truncated `sql_preview`, and `started_at_ms` as milliseconds since the Unix epoch.
611+
612+
`cancelActiveQuery()` throws when the id is not a UUID, when the API key lacks write access, or when no such query is running — including the case where the id belongs to a different caller, which the runtime reports as not found rather than cancelling.
613+
614+
The boundary is the **caller's identity, not the client instance**: the runtime scopes both `listActiveQueries()` and `cancelActiveQuery()` to the authenticated principal. Two clients using the same API key therefore share one set and can cancel each other's queries, and unauthenticated requests all share the runtime's public scope. Do not rely on one `SpiceClient` seeing only its own queries.
615+
616+
Both work on Node and in the browser, since they use the HTTP control plane rather than Flight.
617+
563618
#### `nsql(request)` - Natural language to SQL (NSQL)
564619

565620
The `nsql()` method converts natural language queries into SQL and executes them, returning both the results and the generated SQL.
@@ -637,7 +692,26 @@ The `SpiceClient` automatically handles environments where Apache Arrow Flight g
637692
2. **Automatic**: If the Flight proto file is missing, it's automatically downloaded from `https://data.spiceai.io/v1/proto/flight` and cached
638693
3. **Fallback**: If gRPC cannot be initialized, automatically falls back to the HTTP `/v1/sql` endpoint
639694

640-
Both gRPC and HTTP modes support compression (gzip, deflate) to reduce bandwidth usage. This ensures the SDK works efficiently in any environment without configuration changes. See [docs/http-fallback.md](./docs/http-fallback.md) for more details.
695+
Both gRPC and HTTP modes support compression (gzip, deflate) to reduce bandwidth usage. This ensures the SDK works efficiently in any environment without configuration changes.
696+
697+
### TLS and mTLS (Node.js only)
698+
699+
> **Note:** mTLS (client certificate authentication) is an [Enterprise](https://docs.spice.ai/docs/enterprise) feature of the Spice.ai runtime.
700+
701+
The client accepts PEM certificate file paths for custom server verification and mutual TLS:
702+
703+
```js
704+
const client = new SpiceClient({
705+
flightUrl: 'my-spice-host:50051',
706+
httpUrl: 'https://my-spice-host:8090',
707+
tlsRootCertFile: './certs/ca.pem', // custom CA for server verification (optional)
708+
tlsClientCertFile: './certs/client.pem', // ┐ provide both to enable mTLS
709+
tlsClientKeyFile: './certs/client.key', //
710+
});
711+
```
712+
713+
- `tlsClientCertFile` and `tlsClientKeyFile` must be provided together; the client certificate is presented during the TLS handshake on both the gRPC and HTTP transports.
714+
- The Spice runtime must be configured with `client_auth_mode: request` or `required`. See the [mTLS cookbook recipe](https://github.com/spiceai/cookbook/tree/trunk/mtls) for a complete walkthrough.
641715

642716
## Advanced
643717

src/client-common.ts

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import {
1616
type QueryParameters,
1717
type SearchOptions,
1818
type SearchResponse,
19+
type WireSearchResponse,
20+
type ActiveQuery,
21+
type ActiveQueriesResponse,
22+
type CancelActiveQueryResponse,
1923
} from './interfaces';
2024
import type { GrpcFlightClient } from './grpc/client.node';
2125
import {
@@ -24,6 +28,7 @@ import {
2428
normalizeSchema,
2529
serializeArrowField,
2630
} from './arrow-utils';
31+
import { normalizeSearchResponse } from './search-utils';
2732
import { Logger } from './logger';
2833
import { Param } from './param';
2934

@@ -685,7 +690,39 @@ export class SpiceClient {
685690
if (this._grpcClient) {
686691
const useGrpc = await this._grpcClient.ensureInitialized();
687692
if (useGrpc) {
688-
return this.doGrpcQueryRequest(queryText, parameters, onData, headers);
693+
// Track whether any chunk has reached the caller's callback — once it
694+
// has, falling back to HTTP would deliver duplicate data
695+
let dataSent = false;
696+
const trackingOnData = onData
697+
? (table: Table) => {
698+
dataSent = true;
699+
onData(table);
700+
}
701+
: undefined;
702+
703+
try {
704+
return await this.doGrpcQueryRequest(
705+
queryText,
706+
parameters,
707+
trackingOnData,
708+
headers,
709+
);
710+
} catch (error) {
711+
if (this._flightOnly || dataSent) {
712+
throw error;
713+
}
714+
this._logger.warn(
715+
`[spice.js] Arrow Flight query failed, falling back to HTTP: ${
716+
error instanceof Error ? error.message : String(error)
717+
}`,
718+
);
719+
return this.doHttpQueryRequest(
720+
queryText,
721+
parameters,
722+
onData,
723+
headers,
724+
);
725+
}
689726
}
690727

691728
// If flightOnly mode is enabled and gRPC failed, throw error
@@ -773,23 +810,39 @@ export class SpiceClient {
773810
? 'application/vnd.spiceai.sql.v1+json' // data.spiceai.io returns schema with 'data' field
774811
: 'application/json'; // OSS returns plain JSON array
775812

776-
// Prepare request body with parameters
777813
const httpParameters = this.convertParametersForHttp(parameters);
778-
const requestBody = JSON.stringify({
779-
sql: queryText,
780-
parameters: httpParameters,
781-
});
782814

815+
// The JSON envelope ({sql, parameters}) is only understood by the OSS
816+
// runtime, and only when Content-Type is exactly application/json.
817+
// Spice Cloud parses every request body as raw SQL, so queries without
818+
// parameters are sent as plain text — the format every endpoint accepts.
819+
let requestBody: string;
820+
let contentType: string;
821+
if (httpParameters.length === 0) {
822+
requestBody = queryText;
823+
contentType = 'text/plain';
824+
} else if (this._isSpiceCloud) {
825+
throw new Error(
826+
'Parameterized queries over HTTP are not supported by Spice Cloud. Use Arrow Flight (gRPC) for parameterized queries.',
827+
);
828+
} else {
829+
requestBody = JSON.stringify({
830+
sql: queryText,
831+
parameters: httpParameters,
832+
});
833+
contentType = 'application/json';
834+
}
835+
836+
// Custom headers merge first — the computed Content-Type/Accept always
837+
// win, because the SDK picks the body format (raw SQL vs JSON envelope)
838+
// and parses the response according to these values; a caller override
839+
// would desync the headers from the body.
783840
const requestHeaders: { [key: string]: string } = {
784-
'Content-Type': 'application/json',
841+
...headers,
842+
'Content-Type': contentType,
785843
Accept: acceptHeader,
786844
};
787845

788-
// Merge custom headers if provided
789-
if (headers) {
790-
Object.assign(requestHeaders, headers);
791-
}
792-
793846
const response = await this.fetchInternal(
794847
'POST',
795848
'/v1/sql',
@@ -1397,8 +1450,8 @@ export class SpiceClient {
13971450
);
13981451
}
13991452

1400-
const result = await response.json();
1401-
return result as SearchResponse;
1453+
const result = (await response.json()) as WireSearchResponse;
1454+
return normalizeSearchResponse(result);
14021455
}
14031456

14041457
/**
@@ -1446,6 +1499,99 @@ export class SpiceClient {
14461499
return await response.json();
14471500
}
14481501

1502+
/**
1503+
* Lists the synchronous queries this client currently has running.
1504+
*
1505+
* Synchronous queries are the ones started by `sql()`, `query()`, `sqlJson()`,
1506+
* FlightSQL, `nsql()` and `search()` — not async query jobs, which the runtime
1507+
* only serves in cluster mode.
1508+
*
1509+
* The runtime does not return a query's id to the client that submitted it, so
1510+
* this is how to find the id that {@link cancelActiveQuery} needs. Results are
1511+
* scoped to this client, so another caller's in-flight queries are never listed.
1512+
*
1513+
* @returns Promise resolving to the active queries
1514+
*/
1515+
async listActiveQueries(): Promise<ActiveQuery[]> {
1516+
if (!this._httpUrl) {
1517+
throw new Error('HTTP URL is required for listing active queries');
1518+
}
1519+
1520+
const response = await this.fetchInternal('GET', '/v1/sql/active');
1521+
1522+
if (response.status === 403) {
1523+
throw new Error(
1524+
'The configured API key does not allow listing queries. Use a key with write access.',
1525+
);
1526+
}
1527+
1528+
if (!response.ok) {
1529+
const errorText = await response.text();
1530+
throw new Error(
1531+
`Failed to list active queries: ${response.status} ${response.statusText} - ${errorText}`,
1532+
);
1533+
}
1534+
1535+
const payload = (await response.json()) as ActiveQueriesResponse | null;
1536+
return payload?.queries ?? [];
1537+
}
1538+
1539+
/**
1540+
* Cancels a running synchronous query by id.
1541+
*
1542+
* `queryId` comes from {@link listActiveQueries}. Cancellation is scoped to this
1543+
* client: an id belonging to another caller is reported as not found rather than
1544+
* cancelled.
1545+
*
1546+
* @param queryId - The id of the query to cancel
1547+
* @returns Promise resolving to the cancellation response
1548+
*/
1549+
async cancelActiveQuery(
1550+
queryId: string,
1551+
): Promise<CancelActiveQueryResponse> {
1552+
if (!this._httpUrl) {
1553+
throw new Error('HTTP URL is required for cancelling a query');
1554+
}
1555+
1556+
if (!queryId) {
1557+
throw new Error(
1558+
'queryId is required. Use listActiveQueries() to find one.',
1559+
);
1560+
}
1561+
1562+
const response = await this.fetchInternal(
1563+
'POST',
1564+
`/v1/sql/${encodeURIComponent(queryId)}/cancel`,
1565+
);
1566+
1567+
if (response.status === 400) {
1568+
throw new Error(
1569+
`Query id '${queryId}' is not a valid UUID. Use the query_id from listActiveQueries().`,
1570+
);
1571+
}
1572+
1573+
if (response.status === 403) {
1574+
throw new Error(
1575+
'The configured API key does not allow cancelling queries. Use a key with write access.',
1576+
);
1577+
}
1578+
1579+
if (response.status === 404) {
1580+
throw new Error(
1581+
`No active query '${queryId}' found. It may have already finished, or it was submitted by a different client.`,
1582+
);
1583+
}
1584+
1585+
if (!response.ok) {
1586+
const errorText = await response.text();
1587+
throw new Error(
1588+
`Failed to cancel query '${queryId}': ${response.status} ${response.statusText} - ${errorText}`,
1589+
);
1590+
}
1591+
1592+
return (await response.json()) as CancelActiveQueryResponse;
1593+
}
1594+
14491595
/**
14501596
* Checks if the Spice runtime is ready to accept requests.
14511597
* This endpoint is authenticated and requires an API key.

src/index.browser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,8 @@ export type {
4040
SearchResponse,
4141
SearchMatch,
4242
QueryHeaders,
43+
ActiveQuery,
44+
ActiveQueriesResponse,
45+
CancelActiveQueryResponse,
4346
RefreshOverrides, // deprecated, kept for backward compatibility
4447
} from './interfaces';

src/index.node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,8 @@ export type {
4141
SearchResponse,
4242
SearchMatch,
4343
QueryHeaders,
44+
ActiveQuery,
45+
ActiveQueriesResponse,
46+
CancelActiveQueryResponse,
4447
RefreshOverrides, // deprecated, kept for backward compatibility
4548
} from './interfaces';

0 commit comments

Comments
 (0)