@@ -17,10 +17,6 @@ import {
1717 getIpcMessage ,
1818} from './flight' ;
1919import {
20- AsyncQueryRequest ,
21- AsyncQueryResponse ,
22- QueryCompleteNotification ,
23- QueryResultsResponse ,
2420 RefreshOverrides ,
2521 type SpiceClientConfig ,
2622} from './interfaces' ;
@@ -109,10 +105,9 @@ async function loadProtoFile(): Promise<string | null> {
109105
110106let flightProto : any = null ;
111107let grpcAvailable = false ;
112- let protoInitPromise : Promise < void > | null = null ;
113108
114109/**
115- * Initialize the proto file (sync attempt, async fallback )
110+ * Initialize the proto file (sync attempt)
116111 */
117112function initializeProto ( ) : void {
118113 try {
@@ -129,15 +124,7 @@ function initializeProto(): void {
129124 flightProto = arrow . flight . protocol ;
130125 grpcAvailable = true ;
131126 } catch ( error : any ) {
132- if ( error . code === 'ENOENT' ) {
133- console . warn (
134- '[spice.js] Local Flight.proto not found. Will attempt to download on first query.' ,
135- ) ;
136- } else {
137- console . warn (
138- `[spice.js] Failed to load gRPC Flight proto: ${ error . message } ` ,
139- ) ;
140- }
127+ // Silent failure - will attempt download during client initialization
141128 grpcAvailable = false ;
142129 }
143130}
@@ -153,7 +140,7 @@ class SpiceClient {
153140 private _flightTlsEnabled : boolean = true ;
154141 private _maxRetries : number = retry . FLIGHT_QUERY_MAX_RETRIES ;
155142 private _useGrpc : boolean = grpcAvailable ;
156- private _grpcInitAttempted : boolean = false ;
143+ private _initPromise : Promise < void > ;
157144
158145 public constructor ( params : string | SpiceClientConfig = { } ) {
159146 // support legacy constructor with api_key as first agument
@@ -172,42 +159,34 @@ class SpiceClient {
172159 this . _flightTlsEnabled =
173160 flightTlsEnabled !== undefined
174161 ? flightTlsEnabled
175- : this . _flightUrl . includes ( '127.0.0.1' )
176- ? false
177- : true ;
162+ : ! this . _flightUrl . includes ( '127.0.0.1' ) ;
178163 // Prepend the user-supplied user agent (if any) with the default user agent
179164 this . _userAgent = userAgent
180165 ? `${ userAgent } ${ getUserAgent ( ) } `
181166 : getUserAgent ( ) ;
182167 }
168+
169+ // Initialize gRPC during construction
170+ this . _initPromise = this . initializeGrpc ( ) ;
183171 }
184172
185173 /**
186- * Attempts to initialize gRPC by downloading the proto file if needed.
187- * Returns true if gRPC is available, false otherwise .
174+ * Initializes gRPC by downloading the proto file if needed.
175+ * Called during SpiceClient construction .
188176 */
189- private async ensureGrpcAvailable ( ) : Promise < boolean > {
177+ private async initializeGrpc ( ) : Promise < void > {
190178 // If already available, return immediately
191- if ( this . _useGrpc && grpcAvailable ) {
192- return true ;
179+ if ( this . _useGrpc && grpcAvailable && flightProto ) {
180+ return ;
193181 }
194182
195- // If we've already tried and failed, don't try again
196- if ( this . _grpcInitAttempted ) {
197- return false ;
198- }
199-
200- this . _grpcInitAttempted = true ;
201-
202183 try {
203184 // Try to load proto file (download if needed)
204185 const protoPath = await loadProtoFile ( ) ;
205186
206187 if ( ! protoPath ) {
207- console . warn (
208- '[spice.js] Unable to initialize gRPC. Falling back to HTTP endpoint.' ,
209- ) ;
210- return false ;
188+ this . _useGrpc = false ;
189+ return ;
211190 }
212191
213192 // Load the proto file
@@ -220,21 +199,36 @@ class SpiceClient {
220199 } ) ;
221200
222201 const arrow = grpc . loadPackageDefinition ( packageDefinition ) . arrow as any ;
202+
203+ if ( ! arrow ?. flight ?. protocol ?. FlightService ) {
204+ throw new Error ( 'Invalid proto file structure' ) ;
205+ }
206+
223207 flightProto = arrow . flight . protocol ;
224208 grpcAvailable = true ;
225209 this . _useGrpc = true ;
226-
227- console . log ( '[spice.js] gRPC Flight protocol initialized successfully' ) ;
228- return true ;
229210 } catch ( error : any ) {
230211 console . warn (
231- `[spice.js] Failed to initialize gRPC : ${ error . message } . Falling back to HTTP.` ,
212+ `[spice.js] gRPC initialization failed : ${ error . message } . Using HTTP endpoint .` ,
232213 ) ;
233- return false ;
214+ this . _useGrpc = false ;
234215 }
235216 }
236217
237- private createClient ( meta : any ) : any {
218+ /**
219+ * Ensures the client is fully initialized before use.
220+ * @returns true if gRPC is available, false otherwise.
221+ */
222+ private async ensureInitialized ( ) : Promise < boolean > {
223+ await this . _initPromise ;
224+ return this . _useGrpc && grpcAvailable && flightProto !== null ;
225+ }
226+
227+ private createClient ( meta : grpc . Metadata ) : FlightClient {
228+ if ( ! flightProto ?. FlightService ) {
229+ throw new Error ( 'gRPC Flight protocol not initialized' ) ;
230+ }
231+
238232 // gRPC channel options
239233 // Compression support is advertised via metadata (grpc-accept-encoding header)
240234 // The server will use compression if it supports it
@@ -270,17 +264,17 @@ class SpiceClient {
270264 getFlightClient : ( ( client : FlightClient ) => void ) | undefined = undefined ,
271265 ) : Promise < EventEmitter > {
272266 const meta = new grpc . Metadata ( ) ;
273- const client : FlightClient = this . createClient ( meta ) ;
274- meta . set ( 'authorization' , 'Bearer ' + this . _apiKey ) ;
267+ meta . set ( 'authorization' , `Bearer ${ this . _apiKey || '' } ` ) ;
275268 meta . set ( 'User-Agent' , this . _userAgent ) ;
276-
277269 // Advertise that we accept compressed responses
278270 // The server can choose to compress if it supports it
279271 meta . set ( 'grpc-accept-encoding' , 'gzip,deflate' ) ;
280272
281- let queryBuff = Buffer . from ( queryText , 'utf8' ) ;
273+ const client : FlightClient = this . createClient ( meta ) ;
274+
275+ const queryBuff = Buffer . from ( queryText , 'utf8' ) ;
282276
283- let flightTicket = await new Promise < Ticket > ( ( resolve , reject ) => {
277+ const flightTicket = await new Promise < Ticket > ( ( resolve , reject ) => {
284278 // GetFlightInfo returns FlightInfo that have endpoints with ticket to call DoGet with
285279 client . GetFlightInfo (
286280 { type : DescriptorType . CMD , cmd : queryBuff } ,
@@ -289,6 +283,10 @@ class SpiceClient {
289283 reject ( err ) ;
290284 return ;
291285 }
286+ if ( ! result ?. endpoint ?. [ 0 ] ?. ticket ) {
287+ reject ( new Error ( 'Invalid FlightInfo response: missing ticket' ) ) ;
288+ return ;
289+ }
292290 resolve ( result . endpoint [ 0 ] . ticket ) ;
293291 } ,
294292 ) ;
@@ -314,59 +312,59 @@ class SpiceClient {
314312 queryText : string ,
315313 onData : ( ( data : Table ) => void ) | undefined = undefined ,
316314 ) : Promise < Table > {
317- // Try to ensure gRPC is available (will download proto if needed)
318- if ( ! this . _useGrpc ) {
319- const grpcReady = await this . ensureGrpcAvailable ( ) ;
320- if ( ! grpcReady ) {
321- console . log ( '[spice.js] Using HTTP endpoint for query' ) ;
322- return this . doHttpQueryRequest ( queryText , onData ) ;
323- }
324- }
315+ // Wait for initialization to complete
316+ const useGrpc = await this . ensureInitialized ( ) ;
325317
326- // If gRPC is still not available after initialization attempt, fall back to HTTP
327- if ( ! this . _useGrpc ) {
318+ if ( ! useGrpc ) {
328319 return this . doHttpQueryRequest ( queryText , onData ) ;
329320 }
330321
331- let client : FlightClient ;
332-
333- const resultStream = await this . getResultStream (
334- queryText ,
335- ( c : FlightClient ) => {
336- client = c ;
337- } ,
338- ) ;
322+ let client : FlightClient | undefined ;
339323
340- // indicates that data has been partially or fully sent
341- let isDataAlreadySent = false ;
342-
343- let schema : Buffer | undefined ;
344- let chunks : Buffer [ ] = [ ] ;
345- resultStream . on ( 'data' , ( response : FlightData ) => {
346- let ipcMessage = getIpcMessage ( response ) ;
347- chunks . push ( ipcMessage ) ;
348- if ( ! schema ) {
349- schema = ipcMessage ;
350- } else if ( onData ) {
351- isDataAlreadySent = true ;
352- onData ( tableFromIPC ( [ schema , ipcMessage ] ) ) ;
353- }
354- } ) ;
324+ try {
325+ const resultStream = await this . getResultStream (
326+ queryText ,
327+ ( c : FlightClient ) => {
328+ client = c ;
329+ } ,
330+ ) ;
355331
356- return new Promise ( ( resolve , reject ) => {
357- resultStream . on ( 'status' , ( response : FlightStatus ) => {
358- const table = tableFromIPC ( chunks ) ;
359- client . close ( ) ;
360- resolve ( table ) ;
332+ // indicates that data has been partially or fully sent
333+ let isDataAlreadySent = false ;
334+
335+ let schema : Buffer | undefined ;
336+ const chunks : Buffer [ ] = [ ] ;
337+
338+ resultStream . on ( 'data' , ( response : FlightData ) => {
339+ const ipcMessage = getIpcMessage ( response ) ;
340+ chunks . push ( ipcMessage ) ;
341+ if ( ! schema ) {
342+ schema = ipcMessage ;
343+ } else if ( onData ) {
344+ isDataAlreadySent = true ;
345+ onData ( tableFromIPC ( [ schema , ipcMessage ] ) ) ;
346+ }
361347 } ) ;
362348
363- resultStream . on ( 'error' , ( err : any ) => {
364- client . close ( ) ;
365- if ( isDataAlreadySent ) retry . dontRetry ( err ) ;
366-
367- reject ( err ) ;
349+ return new Promise ( ( resolve , reject ) => {
350+ resultStream . on ( 'status' , ( _response : FlightStatus ) => {
351+ const table = tableFromIPC ( chunks ) ;
352+ client ?. close ( ) ;
353+ resolve ( table ) ;
354+ } ) ;
355+
356+ resultStream . on ( 'error' , ( err : any ) => {
357+ client ?. close ( ) ;
358+ if ( isDataAlreadySent ) {
359+ retry . dontRetry ( err ) ;
360+ }
361+ reject ( err ) ;
362+ } ) ;
368363 } ) ;
369- } ) ;
364+ } catch ( error ) {
365+ client ?. close ( ) ;
366+ throw error ;
367+ }
370368 }
371369
372370 private async doHttpQueryRequest (
@@ -446,7 +444,6 @@ class SpiceClient {
446444
447445 private jsonToArrowTable ( schema : any [ ] , rows : any [ ] ) : Table {
448446 // Convert JSON response to Arrow Table format
449- // Create a simple object representation that Arrow can understand
450447 const columns : { [ key : string ] : any [ ] } = { } ;
451448
452449 // Initialize columns
@@ -471,7 +468,7 @@ class SpiceClient {
471468 * Sets the maximum number of times to retry Query calls. The default is 3
472469 * @param maxRetries Num of max retries. Setting to 0 will disable retries
473470 */
474- public setMaxRetries ( maxRetries : number ) {
471+ public setMaxRetries ( maxRetries : number ) : void {
475472 if ( maxRetries < 0 ) {
476473 throw new Error ( 'maxRetries must be greater than or equal to 0' ) ;
477474 }
@@ -482,28 +479,22 @@ class SpiceClient {
482479 public async refreshDataset (
483480 dataset : string ,
484481 refresh_overrides ?: RefreshOverrides ,
485- ) {
486- if ( ! refresh_overrides ) {
487- refresh_overrides = {
488- refresh_sql : null ,
489- refresh_mode : null ,
490- refresh_jitter_max : null ,
491- } ;
492- }
493-
494- refresh_overrides . refresh_sql = refresh_overrides . refresh_sql || null ;
495- refresh_overrides . refresh_mode = refresh_overrides . refresh_mode || null ;
496- refresh_overrides . refresh_jitter_max =
497- refresh_overrides . refresh_jitter_max || null ;
482+ ) : Promise < void > {
483+ const overrides : RefreshOverrides = {
484+ refresh_sql : refresh_overrides ?. refresh_sql || null ,
485+ refresh_mode : refresh_overrides ?. refresh_mode || null ,
486+ refresh_jitter_max : refresh_overrides ?. refresh_jitter_max || null ,
487+ } ;
498488
499- const body = JSON . stringify ( refresh_overrides ) ;
489+ const body = JSON . stringify ( overrides ) ;
500490
501491 const response = await this . fetchInternal (
502492 'POST' ,
503493 `/v1/datasets/${ dataset } /acceleration/refresh` ,
504494 undefined ,
505495 body ,
506496 ) ;
497+
507498 if ( response . status !== 201 ) {
508499 const responseText = await response . text ( ) ;
509500 throw new Error (
@@ -519,35 +510,42 @@ class SpiceClient {
519510 body ?: string ,
520511 customHeaders ?: { [ key : string ] : string } ,
521512 ) {
522- let url ;
523- if ( params && Object . keys ( params ) . length ) {
524- url = `${ this . _httpUrl } ${ path } ?${ new URLSearchParams ( params ) } ` ;
525- } else {
526- url = `${ this . _httpUrl } ${ path } ` ;
527- }
513+ const url = params && Object . keys ( params ) . length
514+ ? `${ this . _httpUrl } ${ path } ?${ new URLSearchParams ( params ) } `
515+ : `${ this . _httpUrl } ${ path } ` ;
528516
529- const headers = [
517+ const headers = new Headers ( [
530518 [ 'Content-Type' , 'application/json' ] ,
531519 [ 'Accept-Encoding' , 'zstd, br, gzip, deflate' ] ,
532520 [ 'User-Agent' , this . _userAgent ] ,
533- ] ;
521+ ] ) ;
534522
535523 // Add custom headers
536524 if ( customHeaders ) {
537525 Object . entries ( customHeaders ) . forEach ( ( [ key , value ] ) => {
538- headers . push ( [ key , value ] ) ;
526+ headers . set ( key , value ) ;
539527 } ) ;
540528 }
541529
542530 if ( this . _apiKey ) {
543- headers . push ( [ 'X-API-Key' , this . _apiKey || '' ] ) ;
531+ headers . set ( 'X-API-Key' , this . _apiKey ) ;
544532 }
545533
534+ const fetchOptions : RequestInit = {
535+ headers,
536+ method,
537+ body,
538+ } ;
539+
546540 if ( this . _httpUrl . startsWith ( 'https://' ) ) {
547- return fetch ( url , {
548- headers : new Headers ( headers ) ,
549- agent : httpsAgent ,
550- method,
541+ fetchOptions . agent = httpsAgent ;
542+ }
543+
544+ return fetch ( url , fetchOptions ) ;
545+ }
546+ }
547+
548+ export { SpiceClient } ;
551549 body ,
552550 } ) ;
553551 } else {
0 commit comments