From 746edd8b41aeaee0b7ed6db8bc9e9a3e6d9f50f6 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 12:31:51 -0800 Subject: [PATCH 01/18] Implement Core Authentication --- .cursor/rules/architecture/RULE.md | 70 +++++++ src/auth/core.ts | 52 +++++ src/auth/index.ts | 299 +-------------------------- src/auth/managed.ts | 318 +++++++++++++++++++++++++++++ src/client/index.ts | 72 +++++++ src/connection/base.ts | 12 ++ src/connection/connection_core.ts | 61 ++++++ src/connection/connection_v2.ts | 12 -- src/connection/index.ts | 9 +- src/core/index.ts | 38 +++- src/firebolt.ts | 4 +- src/http/index.ts | 5 +- src/http/node.ts | 22 +- src/index.ts | 7 +- src/service/index.ts | 5 + src/types.ts | 9 +- 16 files changed, 656 insertions(+), 339 deletions(-) create mode 100644 .cursor/rules/architecture/RULE.md create mode 100644 src/auth/core.ts create mode 100644 src/auth/managed.ts create mode 100644 src/client/index.ts create mode 100644 src/connection/connection_core.ts diff --git a/.cursor/rules/architecture/RULE.md b/.cursor/rules/architecture/RULE.md new file mode 100644 index 00000000..ae47c203 --- /dev/null +++ b/.cursor/rules/architecture/RULE.md @@ -0,0 +1,70 @@ +--- +alwaysApply: false +--- +# Firebolt Node.js SDK Architecture Rules + +## Version Architecture (Critical) + +**V1 (Legacy)**: Username/password auth → `ConnectionV1`, `QueryFormatterV1`, `DatabaseServiceV1`, `EngineServiceV1` +**V2 (Current)**: Service account auth (client_id/secret) → `ConnectionV2`, `QueryFormatterV2`, `DatabaseServiceV2`, `EngineServiceV2` + +Version selected in `makeConnection()` based on auth type. **Always support both versions** unless explicitly V2-only. + +## Core Patterns + +**Dependency Injection**: Factory functions (`FireboltClient()`, `ResourceClient()`) accept `logger` and `httpClient`, return configured instances. Context object passes dependencies. + +**Abstract Base Classes**: `Connection` (base) → `ConnectionV1`/`ConnectionV2`. `QueryFormatter` (base) → `QueryFormatterV1`/`QueryFormatterV2`. Base classes contain shared logic. + +**Statement Types**: +- `Statement`: `execute()` → `fetchResult()` (in-memory) or `streamResult()` (in-memory stream, not true streaming) +- `StreamStatement`: `executeStream()` → true server-side streaming +- `AsyncStatement`: `executeAsync()` → returns token, no immediate data + +## Query Execution Flow + +1. `prepareQuery()`: Format with `QueryFormatter` (handles `?`, `:name`, or `$1`/$2` for server-side) +2. `getRequestUrl()`: Add query params and settings +3. `executeQuery()`: POST to engine endpoint +4. `processHeaders()`: Update session parameters from response headers +5. Parse JSON, handle errors via `throwErrorIfErrorBody()` +6. Return appropriate Statement type + +## Parameter Management + +Connection maintains session parameters: +- **Immutable**: `database`, `account_id`, `output_format` (cannot be removed) +- **Mutable**: Updated via `SET` statements or response headers +- **Server headers**: `Firebolt-Update-Parameters`, `Firebolt-Update-Endpoint`, `Firebolt-Reset-Session`, `Firebolt-Remove-Parameters` + +## Authentication & Caching + +`Authenticator` handles OAuth tokens with thread-safe caching (read/write locks via `rwlock`). Cache key: `{clientId, secret, apiEndpoint}`. Tokens expire at 50% of actual expiry for safety. Disable with `useCache: false`. + +## Engine Endpoint Resolution + +**V2**: Get system engine URL → connect → `USE DATABASE` → `USE ENGINE` (if specified) +**V1**: Resolve account ID → get engine URL by database/engine → direct connection + +## Error Handling + +Use `CompositeError` for multiple errors. Custom errors: `AccountNotFoundError`, `AuthenticationError`, `ApiError` (from `src/common/errors.ts`). + +## Important Details + +- **Prepared statements**: `native` (default, client-side `?`/`:name`) vs `fb_numeric` (server-side `$1`/`$2`) +- **Transactions**: `begin()`, `commit()`, `rollback()` on Connection (state per connection) +- **Async queries**: `async: true` setting → token for `isAsyncQueryRunning()`, `isAsyncQuerySuccessful()`, `cancelAsyncQuery()` +- **Result hydration**: SQL types → JS types (dates, BigNumber for large ints, normalization) +- **Connection cleanup**: `destroy()` aborts all active requests +- **Response formats**: `JSON_COMPACT` (default), `JSON`, `JSON_LINES` + +## Development Rules + +1. Support both V1 and V2 unless V2-only feature +2. Use abstract base classes for shared functionality +3. Keep DI pattern for testability +4. Use custom error types from `src/common/errors.ts` +5. Maintain separate V1/V2 test suites +6. V1 is legacy; prioritize V2 for new features + diff --git a/src/auth/core.ts b/src/auth/core.ts new file mode 100644 index 00000000..1e755005 --- /dev/null +++ b/src/auth/core.ts @@ -0,0 +1,52 @@ +import { Context, ConnectionOptions } from "../types"; + +/** + * No-op authenticator for Firebolt Core connections. + * Core doesn't require authentication, so all methods are no-ops. + */ +export class CoreAuthenticator { + context: Context; + options: ConnectionOptions; + + constructor(context: Context, options: ConnectionOptions) { + context.httpClient.authenticator = this; + this.context = context; + this.options = options; + } + + async getToken(): Promise { + return undefined; + } + + async authenticate(): Promise { + // No-op for Core + } + + async reAuthenticate(): Promise { + // No-op for Core + } + + clearCache(): void { + // No-op for Core + } + + isUsernamePassword(): boolean { + return false; + } + + isServiceAccount(): boolean { + return false; + } + + isFireboltCore(): boolean { + return true; + } + + async addAuthHeaders( + requestHeaders: Record + ): Promise> { + // No-op for Core - no authentication needed + return requestHeaders; + } +} + diff --git a/src/auth/index.ts b/src/auth/index.ts index aa94689a..8c1927bd 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,297 +1,4 @@ -import { SERVICE_ACCOUNT_LOGIN, USERNAME_PASSWORD_LOGIN } from "../common/api"; -import { assignProtocol } from "../common/util"; -import { - Context, - ConnectionOptions, - ServiceAccountAuth, - UsernamePasswordAuth -} from "../types"; -import { - TokenKey, - TokenRecord, - inMemoryCache, - noneCache, - rwLock -} from "../common/tokenCache"; +// Re-export for convenience - allows importing from "../auth" +export { Authenticator } from "./managed"; +export { CoreAuthenticator } from "./core"; -type Login = { - access_token: string; - token_type: string; - expires_in: number; -}; - -type TokenInfo = { - access_token: string; - // seconds until expiration - expires_in: number; -}; - -const AUTH_AUDIENCE = "https://api.firebolt.io"; -const AUTH_GRANT_TYPE = "client_credentials"; - -export class Authenticator { - context: Context; - options: ConnectionOptions; - - accessToken?: string; - // Expiration time is half way to the actual expiration time - // to allow for some buffer time before the token expires - tokenExpiryTimestampMs?: number; - - constructor(context: Context, options: ConnectionOptions) { - context.httpClient.authenticator = this; - this.context = context; - this.options = options; - } - - private getCacheKey(): TokenKey | undefined { - if (this.isUsernamePassword()) { - const auth = this.options.auth as UsernamePasswordAuth; - return { - clientId: auth.username, - secret: auth.password, - apiEndpoint: this.context.apiEndpoint - }; - } else if (this.isServiceAccount()) { - const auth = this.options.auth as ServiceAccountAuth; - return { - clientId: auth.client_id, - secret: auth.client_secret, - apiEndpoint: this.context.apiEndpoint - }; - } - return undefined; - } - - private getCache() { - return this.options.useCache ?? true - ? inMemoryCache.tokenStorage - : noneCache.tokenStorage; - } - - clearCache() { - const key = this.getCacheKey(); - key && this.getCache().clear(key); - } - - private setToken(token: string, expiresIn: number) { - // Set expiration to half of the expiresIn value - // to allow for some buffer time before the token expires - const tokenExpiryTimestampMs = Date.now() + (expiresIn * 1000) / 2; - this.accessToken = token; - this.tokenExpiryTimestampMs = tokenExpiryTimestampMs; - // Update cache - const key = this.getCacheKey(); - key && - this.getCache().set(key, { - token, - tokenExpiryTimestampMs - }); - } - - private getCachedTokenRecord(): TokenRecord | undefined { - const key = this.getCacheKey(); - if (!key) return undefined; - - const cachedTokenRecord = this.getCache().get(key); - // Check if token exists and is not expired - if ( - cachedTokenRecord && - Date.now() < cachedTokenRecord.tokenExpiryTimestampMs - ) { - return { - token: cachedTokenRecord.token, - tokenExpiryTimestampMs: cachedTokenRecord.tokenExpiryTimestampMs - }; - } - - return undefined; - } - - async getToken(): Promise { - if ( - (this.tokenExpiryTimestampMs && - this.tokenExpiryTimestampMs < Date.now()) || - !this.accessToken - ) { - await this.authenticate(); - } - return this.accessToken; - } - - private static getAuthEndpoint(apiEndpoint: string) { - const myURL = new URL(assignProtocol(apiEndpoint)); - const hostStrings = myURL.hostname.split("."); - // We expect an apiEndpoint to be of format api..firebolt.io - // Since we got something else, assume it's a test - if (hostStrings[0] != "api") { - return new URL(assignProtocol(apiEndpoint)).toString(); - } - hostStrings[0] = "id"; - myURL.hostname = hostStrings.join("."); - return myURL.toString(); - } - - private async authenticateUsernamePassword( - auth: UsernamePasswordAuth - ): Promise { - const { httpClient, apiEndpoint } = this.context; - const { username, password } = auth; - const url = `${apiEndpoint}/${USERNAME_PASSWORD_LOGIN}`; - const body = JSON.stringify({ - username, - password - }); - - // Expiration is in seconds - const { access_token, expires_in } = await httpClient - .request("POST", url, { - body, - retry: false, - noAuth: true - }) - .ready(); - - return { access_token, expires_in }; - } - - private async authenticateServiceAccount( - auth: ServiceAccountAuth - ): Promise { - const { httpClient, apiEndpoint } = this.context; - const { client_id, client_secret } = auth; - - const authEndpoint = Authenticator.getAuthEndpoint(apiEndpoint); - const params = new URLSearchParams({ - client_id, - client_secret, - grant_type: AUTH_GRANT_TYPE, - audience: AUTH_AUDIENCE - }); - const url = `${authEndpoint}${SERVICE_ACCOUNT_LOGIN}`; - - // Expiration is in seconds - const { access_token, expires_in } = await httpClient - .request("POST", url, { - retry: false, - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: params, - noAuth: true - }) - .ready(); - - return { access_token, expires_in }; - } - - isUsernamePassword() { - const options = this.options.auth || this.options; - return !!( - (options as UsernamePasswordAuth).username && - (options as UsernamePasswordAuth).password - ); - } - - isServiceAccount() { - const options = this.options.auth || this.options; - return !!( - (options as ServiceAccountAuth).client_id && - (options as ServiceAccountAuth).client_secret - ); - } - - async authenticate(): Promise { - // Try to get token from cache using read lock - const cachedToken = await this.tryGetCachedTokenRecord(); - if (cachedToken) { - this.accessToken = cachedToken.token; - this.tokenExpiryTimestampMs = cachedToken.tokenExpiryTimestampMs; - return; - } - // No cached token, acquire write lock and authenticate - await this.acquireWriteLockAndAuthenticate(); - } - - async reAuthenticate(): Promise { - // Acquire write lock, clear cache and authenticate - return new Promise((resolve, reject) => { - rwLock.writeLock(async releaseWriteLock => { - try { - // Clear the cache under write lock - const key = this.getCacheKey(); - key && this.getCache().clear(key); - - // Perform authentication directly rather than calling acquireWriteLockAndAuthenticate - // since we already have the write lock - await this.performAuthentication(); - - resolve(); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } finally { - releaseWriteLock(); - } - }); - }); - } - - private async tryGetCachedTokenRecord(): Promise { - return new Promise((resolve, reject) => { - rwLock.readLock(releaseReadLock => { - try { - const cachedTokenRecord = this.getCachedTokenRecord(); - resolve(cachedTokenRecord); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } finally { - releaseReadLock(); - } - }); - }); - } - - private async acquireWriteLockAndAuthenticate(): Promise { - return new Promise((resolve, reject) => { - rwLock.writeLock(async releaseWriteLock => { - try { - // Double-check cache in case another thread authenticated while waiting - const cachedTokenInfo = this.getCachedTokenRecord(); - if (cachedTokenInfo) { - this.accessToken = cachedTokenInfo.token; - this.tokenExpiryTimestampMs = - cachedTokenInfo.tokenExpiryTimestampMs; - return resolve(); - } - await this.performAuthentication(); - - resolve(); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } finally { - releaseWriteLock(); - } - }); - }); - } - - private async performAuthentication(): Promise { - const options = this.options.auth || this.options; - - let auth: TokenInfo; - - if (this.isUsernamePassword()) { - auth = await this.authenticateUsernamePassword( - options as UsernamePasswordAuth - ); - } else if (this.isServiceAccount()) { - auth = await this.authenticateServiceAccount( - options as ServiceAccountAuth - ); - } else { - throw new Error("Please provide valid auth credentials"); - } - - this.setToken(auth.access_token, auth.expires_in); - } -} diff --git a/src/auth/managed.ts b/src/auth/managed.ts new file mode 100644 index 00000000..5867b9f8 --- /dev/null +++ b/src/auth/managed.ts @@ -0,0 +1,318 @@ +import { SERVICE_ACCOUNT_LOGIN, USERNAME_PASSWORD_LOGIN } from "../common/api"; +import { assignProtocol } from "../common/util"; +import { AuthenticationError } from "../common/errors"; +import { + Context, + ConnectionOptions, + ServiceAccountAuth, + UsernamePasswordAuth +} from "../types"; +import { + TokenKey, + TokenRecord, + inMemoryCache, + noneCache, + rwLock +} from "../common/tokenCache"; + +type Login = { + access_token: string; + token_type: string; + expires_in: number; +}; + +type TokenInfo = { + access_token: string; + // seconds until expiration + expires_in: number; +}; + +const AUTH_AUDIENCE = "https://api.firebolt.io"; +const AUTH_GRANT_TYPE = "client_credentials"; + +export class Authenticator { + context: Context; + options: ConnectionOptions; + + accessToken?: string; + // Expiration time is half way to the actual expiration time + // to allow for some buffer time before the token expires + tokenExpiryTimestampMs?: number; + + constructor(context: Context, options: ConnectionOptions) { + context.httpClient.authenticator = this; + this.context = context; + this.options = options; + } + + private getCacheKey(): TokenKey | undefined { + if (this.isUsernamePassword()) { + const auth = this.options.auth as UsernamePasswordAuth; + return { + clientId: auth.username, + secret: auth.password, + apiEndpoint: this.context.apiEndpoint + }; + } else if (this.isServiceAccount()) { + const auth = this.options.auth as ServiceAccountAuth; + return { + clientId: auth.client_id, + secret: auth.client_secret, + apiEndpoint: this.context.apiEndpoint + }; + } + return undefined; + } + + private getCache() { + return this.options.useCache ?? true + ? inMemoryCache.tokenStorage + : noneCache.tokenStorage; + } + + clearCache() { + const key = this.getCacheKey(); + key && this.getCache().clear(key); + } + + private setToken(token: string, expiresIn: number) { + // Set expiration to half of the expiresIn value + // to allow for some buffer time before the token expires + const tokenExpiryTimestampMs = Date.now() + (expiresIn * 1000) / 2; + this.accessToken = token; + this.tokenExpiryTimestampMs = tokenExpiryTimestampMs; + // Update cache + const key = this.getCacheKey(); + key && + this.getCache().set(key, { + token, + tokenExpiryTimestampMs + }); + } + + private getCachedTokenRecord(): TokenRecord | undefined { + const key = this.getCacheKey(); + if (!key) return undefined; + + const cachedTokenRecord = this.getCache().get(key); + // Check if token exists and is not expired + if ( + cachedTokenRecord && + Date.now() < cachedTokenRecord.tokenExpiryTimestampMs + ) { + return { + token: cachedTokenRecord.token, + tokenExpiryTimestampMs: cachedTokenRecord.tokenExpiryTimestampMs + }; + } + + return undefined; + } + + async getToken(): Promise { + if ( + (this.tokenExpiryTimestampMs && + this.tokenExpiryTimestampMs < Date.now()) || + !this.accessToken + ) { + await this.authenticate(); + } + return this.accessToken; + } + + private static getAuthEndpoint(apiEndpoint: string) { + const myURL = new URL(assignProtocol(apiEndpoint)); + const hostStrings = myURL.hostname.split("."); + // We expect an apiEndpoint to be of format api..firebolt.io + // Since we got something else, assume it's a test + if (hostStrings[0] != "api") { + return new URL(assignProtocol(apiEndpoint)).toString(); + } + hostStrings[0] = "id"; + myURL.hostname = hostStrings.join("."); + return myURL.toString(); + } + + private async authenticateUsernamePassword( + auth: UsernamePasswordAuth + ): Promise { + const { httpClient, apiEndpoint } = this.context; + const { username, password } = auth; + const url = `${apiEndpoint}/${USERNAME_PASSWORD_LOGIN}`; + const body = JSON.stringify({ + username, + password + }); + + // Expiration is in seconds + const { access_token, expires_in } = await httpClient + .request("POST", url, { + body, + retry: false, + noAuth: true + }) + .ready(); + + return { access_token, expires_in }; + } + + private async authenticateServiceAccount( + auth: ServiceAccountAuth + ): Promise { + const { httpClient, apiEndpoint } = this.context; + const { client_id, client_secret } = auth; + + const authEndpoint = Authenticator.getAuthEndpoint(apiEndpoint); + const params = new URLSearchParams({ + client_id, + client_secret, + grant_type: AUTH_GRANT_TYPE, + audience: AUTH_AUDIENCE + }); + const url = `${authEndpoint}${SERVICE_ACCOUNT_LOGIN}`; + + // Expiration is in seconds + const { access_token, expires_in } = await httpClient + .request("POST", url, { + retry: false, + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: params, + noAuth: true + }) + .ready(); + + return { access_token, expires_in }; + } + + isUsernamePassword() { + const options = this.options.auth || this.options; + return !!( + (options as UsernamePasswordAuth).username && + (options as UsernamePasswordAuth).password + ); + } + + isServiceAccount() { + const options = this.options.auth || this.options; + return !!( + (options as ServiceAccountAuth).client_id && + (options as ServiceAccountAuth).client_secret + ); + } + + isFireboltCore(): boolean { + return false; + } + + async addAuthHeaders( + requestHeaders: Record + ): Promise> { + const token = await this.getToken(); + if (!token) { + throw new AuthenticationError({ + message: "Failed to get the access token when making a request." + }); + } + + return { + ...requestHeaders, + Authorization: `Bearer ${token}` + }; + } + + async authenticate(): Promise { + // Try to get token from cache using read lock + const cachedToken = await this.tryGetCachedTokenRecord(); + if (cachedToken) { + this.accessToken = cachedToken.token; + this.tokenExpiryTimestampMs = cachedToken.tokenExpiryTimestampMs; + return; + } + // No cached token, acquire write lock and authenticate + await this.acquireWriteLockAndAuthenticate(); + } + + async reAuthenticate(): Promise { + // Acquire write lock, clear cache and authenticate + return new Promise((resolve, reject) => { + rwLock.writeLock(async releaseWriteLock => { + try { + // Clear the cache under write lock + const key = this.getCacheKey(); + key && this.getCache().clear(key); + + // Perform authentication directly rather than calling acquireWriteLockAndAuthenticate + // since we already have the write lock + await this.performAuthentication(); + + resolve(); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } finally { + releaseWriteLock(); + } + }); + }); + } + + private async tryGetCachedTokenRecord(): Promise { + return new Promise((resolve, reject) => { + rwLock.readLock(releaseReadLock => { + try { + const cachedTokenRecord = this.getCachedTokenRecord(); + resolve(cachedTokenRecord); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } finally { + releaseReadLock(); + } + }); + }); + } + + private async acquireWriteLockAndAuthenticate(): Promise { + return new Promise((resolve, reject) => { + rwLock.writeLock(async releaseWriteLock => { + try { + // Double-check cache in case another thread authenticated while waiting + const cachedTokenInfo = this.getCachedTokenRecord(); + if (cachedTokenInfo) { + this.accessToken = cachedTokenInfo.token; + this.tokenExpiryTimestampMs = + cachedTokenInfo.tokenExpiryTimestampMs; + return resolve(); + } + await this.performAuthentication(); + + resolve(); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } finally { + releaseWriteLock(); + } + }); + }); + } + + private async performAuthentication(): Promise { + const options = this.options.auth || this.options; + + let auth: TokenInfo; + + if (this.isUsernamePassword()) { + auth = await this.authenticateUsernamePassword( + options as UsernamePasswordAuth + ); + } else if (this.isServiceAccount()) { + auth = await this.authenticateServiceAccount( + options as ServiceAccountAuth + ); + } else { + throw new Error("Please provide valid auth credentials"); + } + + this.setToken(auth.access_token, auth.expires_in); + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 00000000..8716f900 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,72 @@ +import { makeConnection } from "../connection"; +import { Authenticator } from "../auth/managed"; +import { CoreAuthenticator } from "../auth/core"; +import { + Context, + ConnectionOptions, + FireboltClientOptions, + FireboltCoreAuth +} from "../types"; +import { ResourceManager } from "../service"; +import { NodeHttpClient } from "../http/node"; + +export class FireboltClient { + private options: FireboltClientOptions; + private context: Context; + resourceManager?: ResourceManager; + + constructor(context: Context, options: FireboltClientOptions) { + this.context = context; + this.options = options; + } + + private async prepareConnection(connectionOptions: ConnectionOptions) { + // Create a new httpClient instance for each connection + const httpClient = + this.options.dependencies?.httpClient || new NodeHttpClient(); + + // Create a new context with the new httpClient + const connectionContext = { + ...this.context, + httpClient + }; + + // Use CoreAuthenticator for Core, regular Authenticator for managed Firebolt + const auth = + "type" in connectionOptions.auth && + (connectionOptions.auth as FireboltCoreAuth).type === "firebolt-core" + ? new CoreAuthenticator(connectionContext, connectionOptions) + : new Authenticator(connectionContext, connectionOptions); + + const connection = makeConnection(connectionContext, connectionOptions); + await auth.authenticate(); + await connection.resolveEngineEndpoint(); + + return { connection, connectionContext }; + } + + async connect(connectionOptions: ConnectionOptions) { + const { connection, connectionContext } = await this.prepareConnection( + connectionOptions + ); + + // Only create ResourceManager for managed Firebolt (not Core) + if ( + !("type" in connectionOptions.auth) || + (connectionOptions.auth as FireboltCoreAuth).type !== "firebolt-core" + ) { + const resourceContext = { + connection, + ...connectionContext + }; + this.resourceManager = new ResourceManager(resourceContext); + } + + return connection; + } + + async testConnection(connectionOptions: ConnectionOptions) { + const { connection } = await this.prepareConnection(connectionOptions); + await connection.testConnection(); + } +} diff --git a/src/connection/base.ts b/src/connection/base.ts index 3f5db279..af318e91 100644 --- a/src/connection/base.ts +++ b/src/connection/base.ts @@ -324,6 +324,18 @@ export abstract class Connection { return !!contentType?.includes("application/json"); } + async begin(): Promise { + await this.execute("BEGIN TRANSACTION"); + } + + async commit(): Promise { + await this.execute("COMMIT"); + } + + async rollback(): Promise { + await this.execute("ROLLBACK"); + } + async destroy() { for (const request of this.activeRequests) { request.abort(); diff --git a/src/connection/connection_core.ts b/src/connection/connection_core.ts new file mode 100644 index 00000000..54044f62 --- /dev/null +++ b/src/connection/connection_core.ts @@ -0,0 +1,61 @@ +import { Response } from "node-fetch"; +import { Connection as BaseConnection } from "./base"; +import { ExecuteQueryOptions, OutputFormat } from "../types"; +import { AsyncStatement } from "../statement/async"; +import { StreamStatement } from "../statement/stream"; + +export class ConnectionCore extends BaseConnection { + async resolveEngineEndpoint(): Promise { + if (!this.options.engineEndpoint) { + throw new Error("engineEndpoint is required for Firebolt Core connections"); + } + this.engineEndpoint = this.options.engineEndpoint; + return this.engineEndpoint; + } + + async testConnection(): Promise { + await this.execute("SELECT 1"); + } + + async executeAsync( + query: string, + executeQueryOptions: ExecuteQueryOptions = {} + ): Promise { + throw new Error("Async queries are not supported in Firebolt Core"); + } + + async executeStream( + query: string, + executeQueryOptions: ExecuteQueryOptions = {} + ): Promise { + const { response } = await this.prepareAndExecuteQuery( + query, + { + ...executeQueryOptions, + settings: { + ...executeQueryOptions?.settings, + output_format: OutputFormat.JSON_LINES + } + }, + true + ); + + return new StreamStatement({ + response, + executeQueryOptions + }); + } + + async isAsyncQueryRunning(token: string): Promise { + throw new Error("Async queries are not supported in Firebolt Core"); + } + + async isAsyncQuerySuccessful(token: string): Promise { + throw new Error("Async queries are not supported in Firebolt Core"); + } + + async cancelAsyncQuery(token: string): Promise { + throw new Error("Async queries are not supported in Firebolt Core"); + } +} + diff --git a/src/connection/connection_v2.ts b/src/connection/connection_v2.ts index e76bfa4a..0e848996 100644 --- a/src/connection/connection_v2.ts +++ b/src/connection/connection_v2.ts @@ -218,16 +218,4 @@ export class ConnectionV2 extends BaseConnection { const settings = { internal: [{ auto_start_stop_control: "ignore" }] }; await this.execute("select 1", { settings }); } - - async begin(): Promise { - await this.execute("BEGIN TRANSACTION"); - } - - async commit(): Promise { - await this.execute("COMMIT"); - } - - async rollback(): Promise { - await this.execute("ROLLBACK"); - } } diff --git a/src/connection/index.ts b/src/connection/index.ts index 85fdfe84..9c8289ea 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -2,17 +2,22 @@ import { Context, ConnectionOptions, ServiceAccountAuth, - UsernamePasswordAuth + UsernamePasswordAuth, + FireboltCoreAuth } from "../types"; import { ConnectionV1 } from "./connection_v1"; import { ConnectionV2 } from "./connection_v2"; +import { ConnectionCore } from "./connection_core"; import { QueryFormatterV1 } from "../formatter/formatter_v1"; import { QueryFormatterV2 } from "../formatter/formatter_v2"; export type { Connection } from "./base"; export function makeConnection(context: Context, options: ConnectionOptions) { - if ( + if ((options.auth as FireboltCoreAuth).type === "firebolt-core") { + const queryFormatter = new QueryFormatterV2(); + return new ConnectionCore(queryFormatter, context, options); + } else if ( (options.auth as ServiceAccountAuth).client_id && (options.auth as ServiceAccountAuth).client_secret ) { diff --git a/src/core/index.ts b/src/core/index.ts index 71546670..05931a77 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,13 +1,19 @@ import { makeConnection } from "../connection"; import { Authenticator } from "../auth"; -import { Context, ConnectionOptions, FireboltClientOptions } from "../types"; +import { CoreAuthenticator } from "../auth/core"; +import { + Context, + ConnectionOptions, + FireboltClientOptions, + FireboltCoreAuth +} from "../types"; import { ResourceManager } from "../service"; import { NodeHttpClient } from "../http/node"; -export class FireboltCore { +export class FireboltClient { private options: FireboltClientOptions; private context: Context; - resourceManager!: ResourceManager; + resourceManager?: ResourceManager; constructor(context: Context, options: FireboltClientOptions) { this.context = context; @@ -25,7 +31,13 @@ export class FireboltCore { httpClient }; - const auth = new Authenticator(connectionContext, connectionOptions); + // Use CoreAuthenticator for Core, regular Authenticator for managed Firebolt + const auth = + "type" in connectionOptions.auth && + (connectionOptions.auth as FireboltCoreAuth).type === "firebolt-core" + ? new CoreAuthenticator(connectionContext, connectionOptions) + : new Authenticator(connectionContext, connectionOptions); + const connection = makeConnection(connectionContext, connectionOptions); await auth.authenticate(); await connection.resolveEngineEndpoint(); @@ -37,11 +49,19 @@ export class FireboltCore { const { connection, connectionContext } = await this.prepareConnection( connectionOptions ); - const resourceContext = { - connection, - ...connectionContext - }; - this.resourceManager = new ResourceManager(resourceContext); + + // Only create ResourceManager for managed Firebolt (not Core) + if ( + !("type" in connectionOptions.auth) || + (connectionOptions.auth as FireboltCoreAuth).type !== "firebolt-core" + ) { + const resourceContext = { + connection, + ...connectionContext + }; + this.resourceManager = new ResourceManager(resourceContext); + } + return connection; } diff --git a/src/firebolt.ts b/src/firebolt.ts index c0db9697..461118c5 100644 --- a/src/firebolt.ts +++ b/src/firebolt.ts @@ -1,7 +1,7 @@ import { Logger } from "./logger"; import { HttpClient } from "./http"; import { ResourceManager } from "./service"; -import { FireboltCore } from "./core"; +import { FireboltClient } from "./client"; import { FireboltClientOptions, ResourceManagerOptions } from "./types"; type Dependencies = { @@ -45,7 +45,7 @@ export const FireboltClient = (dependencies: Dependencies) => { return (options: FireboltClientOptions = {}) => { const context = getContext(options, dependencies); - return new FireboltCore(context, options); + return new FireboltClient(context, options); }; }; diff --git a/src/http/index.ts b/src/http/index.ts index 735ebea8..a7dadbb8 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,9 +1,10 @@ -import { Authenticator } from "../auth"; +import { Authenticator } from "../auth/managed"; +import { CoreAuthenticator } from "../auth/core"; export type HttpClientOptions = Record; export interface HttpClientInterface { - authenticator: Authenticator; + authenticator: Authenticator | CoreAuthenticator; request( method: string, url: string, diff --git a/src/http/node.ts b/src/http/node.ts index 5897a002..47729aa3 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -5,7 +5,8 @@ import fetch, { Response } from "node-fetch"; import { AbortSignal } from "node-fetch/externals"; import { assignProtocol, systemInfoString } from "../common/util"; import { ApiError, AuthenticationError } from "../common/errors"; -import { Authenticator } from "../auth"; +import { Authenticator } from "../auth/managed"; +import { CoreAuthenticator } from "../auth/core"; const AbortController = globalThis.AbortController || Abort; @@ -50,7 +51,7 @@ HttpsAgent.prototype.createSocket = function (req, options, cb) { }; export class NodeHttpClient { - authenticator!: Authenticator; + authenticator!: Authenticator | CoreAuthenticator; agentCache!: Map; constructor() { @@ -91,19 +92,11 @@ export class NodeHttpClient { }; const addAuthHeaders = async (requestHeaders: Record) => { - if (options?.noAuth) return requestHeaders; - - const token = await this.authenticator.getToken(); - if (!token) { - throw new AuthenticationError({ - message: "Failed to get the access token when making a request." - }); + if (options?.noAuth) { + return requestHeaders; } - return { - ...requestHeaders, - Authorization: `Bearer ${token}` - }; + return this.authenticator.addAuthHeaders(requestHeaders); }; const handleErrorResponse = async (response: Response): Promise => { @@ -161,7 +154,8 @@ export class NodeHttpClient { if ( (response.status === 401 || response.status === 403) && retry && - !retriedErrors.has(response.status) + !retriedErrors.has(response.status) && + !this.authenticator.isFireboltCore() ) { try { console.warn( diff --git a/src/index.ts b/src/index.ts index 2b3ac721..44a160c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,9 +24,14 @@ export type { QueryResponse, QuerySettings, Context, - Row + Row, + FireboltCoreAuth } from "./types"; +export const FireboltCore = (): import("./types").FireboltCoreAuth => ({ + type: "firebolt-core" +}); + export { OutputFormat } from "./types"; export { EngineStatusSummary } from "./service/engine/types"; export { isDateType, isNumberType } from "./statement/dataTypes"; diff --git a/src/service/index.ts b/src/service/index.ts index 6b55e8ba..a26be058 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -14,6 +14,11 @@ export class ResourceManager { constructor(context: ResourceManagerContext) { this.context = context; const { httpClient } = this.context; + if (httpClient.authenticator.isFireboltCore()) { + throw new Error( + "ResourceManager is not supported for Firebolt Core connections" + ); + } if (httpClient.authenticator.isServiceAccount()) { this.engine = new EngineServiceV2(this.context); this.database = new DatabaseServiceV2(this.context); diff --git a/src/types.ts b/src/types.ts index c11e35b1..56f0eadd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,7 +82,14 @@ export type ServiceAccountAuth = { client_secret: string; }; -export type AuthOptions = UsernamePasswordAuth | ServiceAccountAuth; +export type FireboltCoreAuth = { + type: "firebolt-core"; +}; + +export type AuthOptions = + | UsernamePasswordAuth + | ServiceAccountAuth + | FireboltCoreAuth; export type PreparedStatementParamStyle = "native" | "fb_numeric"; From a4cb22f0a9c19c44573b5cc406cbb9bdd7a62b4f Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 14:34:55 -0800 Subject: [PATCH 02/18] Extensive testing --- .env.example | 8 +- src/client/index.ts | 19 +- src/common/util.ts | 5 +- src/connection/connection_core.ts | 12 + src/connection/index.ts | 5 +- src/firebolt.ts | 4 +- src/http/node.ts | 169 ++++++++++-- test/integration/core/async.test.ts | 52 ++++ test/integration/core/auth.test.ts | 23 ++ test/integration/core/boolean.test.ts | 25 ++ test/integration/core/connection.test.ts | 63 +++++ test/integration/core/dataTypes.test.ts | 134 ++++++++++ test/integration/core/error.test.ts | 55 ++++ test/integration/core/index.test.ts | 42 +++ test/integration/core/normalizeData.test.ts | 69 +++++ test/integration/core/outputFormat.test.ts | 71 +++++ .../core/preparedStatement.test.ts | 112 ++++++++ test/integration/core/query.test.ts | 56 ++++ test/integration/core/resourceManager.test.ts | 22 ++ test/integration/core/setStatement.test.ts | 49 ++++ test/integration/core/stream.test.ts | 63 +++++ test/integration/core/transaction.test.ts | 42 +++ test/integration/v2/engine.test.ts | 4 +- test/integration/v2/fetchTypes.test.ts | 5 - test/integration/v2/geography.test.ts | 3 - test/unit/core/async.test.ts | 43 +++ test/unit/core/connection.test.ts | 18 ++ test/unit/core/query.test.ts | 250 ++++++++++++++++++ test/unit/core/statement.test.ts | 42 +++ test/unit/core/transaction.test.ts | 34 +++ 30 files changed, 1454 insertions(+), 45 deletions(-) create mode 100644 test/integration/core/async.test.ts create mode 100644 test/integration/core/auth.test.ts create mode 100644 test/integration/core/boolean.test.ts create mode 100644 test/integration/core/connection.test.ts create mode 100644 test/integration/core/dataTypes.test.ts create mode 100644 test/integration/core/error.test.ts create mode 100644 test/integration/core/index.test.ts create mode 100644 test/integration/core/normalizeData.test.ts create mode 100644 test/integration/core/outputFormat.test.ts create mode 100644 test/integration/core/preparedStatement.test.ts create mode 100644 test/integration/core/query.test.ts create mode 100644 test/integration/core/resourceManager.test.ts create mode 100644 test/integration/core/setStatement.test.ts create mode 100644 test/integration/core/stream.test.ts create mode 100644 test/integration/core/transaction.test.ts create mode 100644 test/unit/core/async.test.ts create mode 100644 test/unit/core/connection.test.ts create mode 100644 test/unit/core/query.test.ts create mode 100644 test/unit/core/statement.test.ts create mode 100644 test/unit/core/transaction.test.ts diff --git a/.env.example b/.env.example index be1ff553..32821973 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -FIREBOLT_USERNAME= -FIREBOLT_PASSWORD= +FIREBOLT_CLIENT_ID= +FIREBOLT_CLIENT_SECRET= +FIREBOLT_ACCOUNT= FIREBOLT_DATABASE= FIREBOLT_ENGINE_NAME= -FIREBOLT_ENGINE_ENDPOINT= -FIREBOLT_API_ENDPOINT= \ No newline at end of file +FIREBOLT_CORE_ENDPOINT="http://localhost:3474" \ No newline at end of file diff --git a/src/client/index.ts b/src/client/index.ts index 8716f900..1d74005c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -13,13 +13,28 @@ import { NodeHttpClient } from "../http/node"; export class FireboltClient { private options: FireboltClientOptions; private context: Context; - resourceManager?: ResourceManager; + private _resourceManager?: ResourceManager; constructor(context: Context, options: FireboltClientOptions) { this.context = context; this.options = options; } + /** + * Getter for resourceManager that preserves backward compatibility. + * For managed Firebolt connections, this is always defined. + * For Firebolt Core connections, accessing this will throw an error. + */ + get resourceManager(): ResourceManager { + if (!this._resourceManager) { + throw new Error( + "ResourceManager is not available for Firebolt Core connections. " + + "Use managed Firebolt authentication (client_id/client_secret) to access ResourceManager." + ); + } + return this._resourceManager; + } + private async prepareConnection(connectionOptions: ConnectionOptions) { // Create a new httpClient instance for each connection const httpClient = @@ -59,7 +74,7 @@ export class FireboltClient { connection, ...connectionContext }; - this.resourceManager = new ResourceManager(resourceContext); + this._resourceManager = new ResourceManager(resourceContext); } return connection; diff --git a/src/common/util.ts b/src/common/util.ts index 48d8182b..a27aa83e 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -4,7 +4,10 @@ import os from "os"; import { ConnectorVersion } from "../types"; export const assignProtocol = (url: string) => { - return url.startsWith("http") ? url : `https://${url}`; + if (url.startsWith("http")) { + return url; + } + return `https://${url}`; }; export const isDataQuery = (query: string): boolean => { diff --git a/src/connection/connection_core.ts b/src/connection/connection_core.ts index 54044f62..8f126b1b 100644 --- a/src/connection/connection_core.ts +++ b/src/connection/connection_core.ts @@ -57,5 +57,17 @@ export class ConnectionCore extends BaseConnection { async cancelAsyncQuery(token: string): Promise { throw new Error("Async queries are not supported in Firebolt Core"); } + + async begin(): Promise { + throw new Error("Transactions are not supported in Firebolt Core"); + } + + async commit(): Promise { + throw new Error("Transactions are not supported in Firebolt Core"); + } + + async rollback(): Promise { + throw new Error("Transactions are not supported in Firebolt Core"); + } } diff --git a/src/connection/index.ts b/src/connection/index.ts index 9c8289ea..e3ebcee7 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -14,7 +14,10 @@ import { QueryFormatterV2 } from "../formatter/formatter_v2"; export type { Connection } from "./base"; export function makeConnection(context: Context, options: ConnectionOptions) { - if ((options.auth as FireboltCoreAuth).type === "firebolt-core") { + if ( + "type" in options.auth && + (options.auth as FireboltCoreAuth).type === "firebolt-core" + ) { const queryFormatter = new QueryFormatterV2(); return new ConnectionCore(queryFormatter, context, options); } else if ( diff --git a/src/firebolt.ts b/src/firebolt.ts index 461118c5..a7e135ac 100644 --- a/src/firebolt.ts +++ b/src/firebolt.ts @@ -1,7 +1,7 @@ import { Logger } from "./logger"; import { HttpClient } from "./http"; import { ResourceManager } from "./service"; -import { FireboltClient } from "./client"; +import { FireboltClient as FireboltClientClass } from "./client"; import { FireboltClientOptions, ResourceManagerOptions } from "./types"; type Dependencies = { @@ -45,7 +45,7 @@ export const FireboltClient = (dependencies: Dependencies) => { return (options: FireboltClientOptions = {}) => { const context = getContext(options, dependencies); - return new FireboltClient(context, options); + return new FireboltClientClass(context, options); }; }; diff --git a/src/http/node.ts b/src/http/node.ts index 47729aa3..d1cef25a 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -1,18 +1,25 @@ -import { HttpsAgent } from "agentkeepalive"; +import AgentKeepAlive from "agentkeepalive"; +import http from "http"; +import https from "https"; import Abort from "abort-controller"; -import fetch, { Response } from "node-fetch"; -import { AbortSignal } from "node-fetch/externals"; +import { Response } from "node-fetch"; import { assignProtocol, systemInfoString } from "../common/util"; import { ApiError, AuthenticationError } from "../common/errors"; import { Authenticator } from "../auth/managed"; import { CoreAuthenticator } from "../auth/core"; +const { HttpsAgent } = AgentKeepAlive; +type HttpAgent = typeof AgentKeepAlive; + const AbortController = globalThis.AbortController || Abort; +// Use public types to avoid exposing private agent types +type AgentType = http.Agent | https.Agent; + type RequestOptions = { headers: Record; - body?: string; + body?: string | URLSearchParams; raw?: boolean; retry?: boolean; noAuth?: boolean; @@ -52,20 +59,27 @@ HttpsAgent.prototype.createSocket = function (req, options, cb) { export class NodeHttpClient { authenticator!: Authenticator | CoreAuthenticator; - agentCache!: Map; + private agentCache!: Map; constructor() { this.agentCache = new Map(); } - getAgent = (url: string): HttpsAgent => { - const { hostname } = new URL(`https://${url}`); - if (this.agentCache.has(hostname)) { - return this.agentCache.get(hostname) as HttpsAgent; + getAgent = (url: string): AgentType => { + const withProtocol = assignProtocol(url); + const urlObj = new URL(withProtocol); + const isHttp = urlObj.protocol === "http:"; + const hostname = urlObj.hostname; + + const cacheKey = `${isHttp ? "http" : "https"}:${hostname}`; + if (this.agentCache.has(cacheKey)) { + return this.agentCache.get(cacheKey) as AgentType; } - const agent = new HttpsAgent(agentOptions); - this.agentCache.set(hostname, agent); + const agent = isHttp + ? new AgentKeepAlive(agentOptions) + : new HttpsAgent(agentOptions); + this.agentCache.set(cacheKey, agent); return agent; }; @@ -136,19 +150,126 @@ export class NodeHttpClient { const makeRequest = async () => { const headersWithAuth = await addAuthHeaders(headers); const withProtocol = assignProtocol(url); + const urlObj = new URL(withProtocol); + const isHttp = urlObj.protocol === "http:"; + const requestModule = isHttp ? http : https; const userAgent = headersWithAuth["user-agent"] || DEFAULT_USER_AGENT; - const response = await fetch(withProtocol, { - agent, - signal: controller.signal as AbortSignal, - method, - headers: { - "user-agent": userAgent, - "Content-Type": "application/json", - [PROTOCOL_VERSION_HEADER]: PROTOCOL_VERSION, - ...headersWithAuth - }, - body + const response = await new Promise((resolve, reject) => { + const req = requestModule.request( + { + hostname: urlObj.hostname, + port: urlObj.port || (isHttp ? 80 : 443), + path: urlObj.pathname + urlObj.search, + method, + headers: { + "user-agent": userAgent, + "Content-Type": "application/json", + [PROTOCOL_VERSION_HEADER]: PROTOCOL_VERSION, + ...headersWithAuth + }, + agent + }, + res => { + // For raw/streaming responses, don't consume the stream - pass it through + if (options?.raw) { + const response = { + status: res.statusCode || 200, + statusText: res.statusMessage || "OK", + headers: new Headers( + Object.entries(res.headers).reduce((acc, [key, value]) => { + if (value) { + acc[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + return acc; + }, {} as Record) + ), + ok: (res.statusCode || 200) >= 200 && (res.statusCode || 200) < 300, + body: res, + text: async () => { + const chunks: Buffer[] = []; + for await (const chunk of res) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks as Uint8Array[]).toString(); + }, + json: async () => { + const chunks: Buffer[] = []; + for await (const chunk of res) { + chunks.push(Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks as Uint8Array[]).toString() || "{}"); + }, + arrayBuffer: async () => { + const chunks: Buffer[] = []; + for await (const chunk of res) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks as Uint8Array[]).buffer; + } + } as unknown as Response; + + resolve(response); + return; + } + + // For non-raw responses, consume the stream and parse + (async () => { + const chunks: Buffer[] = []; + for await (const chunk of res) { + chunks.push(Buffer.from(chunk)); + } + const data = Buffer.concat(chunks as Uint8Array[]).toString(); + const buffer = Buffer.concat(chunks as Uint8Array[]); + + const response = { + status: res.statusCode || 200, + statusText: res.statusMessage || "OK", + headers: new Headers( + Object.entries(res.headers).reduce((acc, [key, value]) => { + if (value) { + acc[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + return acc; + }, {} as Record) + ), + ok: (res.statusCode || 200) >= 200 && (res.statusCode || 200) < 300, + body: undefined, + text: async () => data, + json: async () => JSON.parse(data || "{}"), + arrayBuffer: async () => buffer.buffer + } as unknown as Response; + + resolve(response); + })(); + } + ); + + req.on("error", error => { + const nodeError = error as NodeJS.ErrnoException; + const errorMessage = error.message || `Connection failed: ${nodeError.code || "unknown error"}`; + reject( + new ApiError({ + message: errorMessage, + code: nodeError.code || "", + status: 0, + url: urlObj.toString() + }) + ); + }); + + if (controller.signal.aborted) { + req.destroy(); + return; + } + controller.signal.addEventListener("abort", () => req.destroy()); + + if (body) { + // Convert URLSearchParams to string if needed + const bodyString = body instanceof URLSearchParams ? body.toString() : (body as string); + req.write(bodyString); + } + req.end(); }); if ( @@ -168,11 +289,9 @@ export class NodeHttpClient { }); } - // Track this error status as retried const updatedRetriedErrors = new Set(retriedErrors); updatedRetriedErrors.add(response.status); - // Retry with updated tracking but keep retry=true to allow retrying different errors const request = this.request(method, url, { headers: options?.headers ?? {}, body: options?.body, @@ -188,7 +307,7 @@ export class NodeHttpClient { } if (options?.raw) { - return response; + return response as unknown as T; } const parsed = await response.json(); diff --git a/test/integration/core/async.test.ts b/test/integration/core/async.test.ts new file mode 100644 index 00000000..0dc5f695 --- /dev/null +++ b/test/integration/core/async.test.ts @@ -0,0 +1,52 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("async queries", () => { + it("throws error when attempting async query execution", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.executeAsync("SELECT 1", { settings: { async: true } }) + ).rejects.toThrow("Async queries are not supported in Firebolt Core"); + }); + + it("throws error when checking async query status", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.isAsyncQueryRunning("dummy-token") + ).rejects.toThrow("Async queries are not supported in Firebolt Core"); + }); + + it("throws error when checking async query success", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.isAsyncQuerySuccessful("dummy-token") + ).rejects.toThrow("Async queries are not supported in Firebolt Core"); + }); + + it("throws error when canceling async query", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.cancelAsyncQuery("dummy-token") + ).rejects.toThrow("Async queries are not supported in Firebolt Core"); + }); +}); + diff --git a/test/integration/core/auth.test.ts b/test/integration/core/auth.test.ts new file mode 100644 index 00000000..7ae8b882 --- /dev/null +++ b/test/integration/core/auth.test.ts @@ -0,0 +1,23 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionOptions = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("auth", () => { + it("connects to Firebolt Core without authentication", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionOptions); + + // Test that we can execute a simple query + const statement = await connection.execute("SELECT 1"); + const { data } = await statement.fetchResult(); + expect(data[0][0]).toEqual(1); + }); +}); + diff --git a/test/integration/core/boolean.test.ts b/test/integration/core/boolean.test.ts new file mode 100644 index 00000000..db647c2b --- /dev/null +++ b/test/integration/core/boolean.test.ts @@ -0,0 +1,25 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(100000); + +describe("boolean", () => { + it("handles select boolean", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("select true::boolean"); + + const { data, meta } = await statement.fetchResult(); + expect(meta[0].type).toEqual("boolean"); + const row = data[0]; + expect((row as unknown[])[0]).toEqual(true); + }); +}); + diff --git a/test/integration/core/connection.test.ts b/test/integration/core/connection.test.ts new file mode 100644 index 00000000..199376ed --- /dev/null +++ b/test/integration/core/connection.test.ts @@ -0,0 +1,63 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("connection management", () => { + it("creates multiple connections independently", async () => { + const firebolt = Firebolt(); + + const connection1 = await firebolt.connect(connectionParams); + const connection2 = await firebolt.connect(connectionParams); + + const statement1 = await connection1.execute("SELECT 1 as conn1"); + const statement2 = await connection2.execute("SELECT 2 as conn2"); + + const { data: data1 } = await statement1.fetchResult(); + const { data: data2 } = await statement2.fetchResult(); + + expect(data1[0][0]).toEqual(1); + expect(data2[0][0]).toEqual(2); + }); + + it("maintains session state per connection", async () => { + const firebolt = Firebolt(); + + const connection1 = await firebolt.connect(connectionParams); + const connection2 = await firebolt.connect(connectionParams); + + // Set different settings on each connection + await connection1.execute("SET statement_timeout=5000"); + await connection2.execute("SET statement_timeout=10000"); + + // Verify each connection maintains its own session state + const statement1 = await connection1.execute("SELECT 1"); + const statement2 = await connection2.execute("SELECT 1"); + + const { data: data1 } = await statement1.fetchResult(); + const { data: data2 } = await statement2.fetchResult(); + + expect(data1[0][0]).toEqual(1); + expect(data2[0][0]).toEqual(1); + }); + + it("can destroy connection", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + // Start a query + const statementPromise = connection.execute("SELECT 1"); + + // Destroy the connection (should abort active requests) + await connection.destroy(); + + // The active request should be aborted + await expect(statementPromise).rejects.toThrow(); + }); +}); + diff --git a/test/integration/core/dataTypes.test.ts b/test/integration/core/dataTypes.test.ts new file mode 100644 index 00000000..51e53d83 --- /dev/null +++ b/test/integration/core/dataTypes.test.ts @@ -0,0 +1,134 @@ +import BigNumber from "bignumber.js"; +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("data types", () => { + it("handles integer types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 42::int, -100::int"); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("int"); + expect(meta[1].type).toEqual("int"); + expect(data[0][0]).toEqual(42); + expect(data[0][1]).toEqual(-100); + }); + + it("handles long types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT 9223372036854775807::long, -9223372036854775808::long" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("long"); + expect(meta[1].type).toEqual("long"); + expect(data[0][0]).toEqual(new BigNumber("9223372036854775807")); + expect(data[0][1]).toEqual(new BigNumber("-9223372036854775808")); + }); + + it("handles float and double types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT 3.14::float, 2.718281828459045::double" + ); + const { data, meta } = await statement.fetchResult(); + + // Core may return float as double + expect(["float", "double"]).toContain(meta[0].type); + expect(meta[1].type).toEqual("double"); + expect(data[0][0]).toBeCloseTo(3.14, 2); + expect(data[0][1]).toEqual(new BigNumber("2.718281828459045")); + }); + + it("handles text types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT 'hello'::text, 'world'::text, ''::text" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("text"); + expect(meta[1].type).toEqual("text"); + expect(meta[2].type).toEqual("text"); + expect(data[0][0]).toEqual("hello"); + expect(data[0][1]).toEqual("world"); + expect(data[0][2]).toEqual(""); + }); + + it("handles boolean types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT true::boolean, false::boolean" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("boolean"); + expect(meta[1].type).toEqual("boolean"); + expect(data[0][0]).toEqual(true); + expect(data[0][1]).toEqual(false); + }); + + it("handles array types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT [1, 2, 3]::array(int), ['a', 'b', 'c']::array(text)" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("array(int)"); + expect(meta[1].type).toEqual("array(text)"); + expect(data[0][0]).toEqual([1, 2, 3]); + expect(data[0][1]).toEqual(["a", "b", "c"]); + }); + + it("handles nullable types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT NULL::int, NULL::text, NULL::boolean" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("int null"); + expect(meta[1].type).toEqual("text null"); + expect(meta[2].type).toEqual("boolean null"); + expect(data[0][0]).toBeNull(); + expect(data[0][1]).toBeNull(); + expect(data[0][2]).toBeNull(); + }); + + it("handles decimal types", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT 12345.6789::decimal(10,4)" + ); + const { data, meta } = await statement.fetchResult(); + + expect(meta[0].type).toEqual("decimal"); + expect(data[0][0]).toEqual("12345.6789"); + }); +}); + diff --git a/test/integration/core/error.test.ts b/test/integration/core/error.test.ts new file mode 100644 index 00000000..f466a18e --- /dev/null +++ b/test/integration/core/error.test.ts @@ -0,0 +1,55 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("error handling", () => { + it("throws error for invalid SQL syntax", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await expect(connection.execute("INVALID SQL SYNTAX")).rejects.toThrow(); + }); + + it("throws error for missing table", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.execute("SELECT * FROM nonexistent_table") + ).rejects.toThrow(); + }); + + it("throws error for invalid parameter count", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.execute("SELECT ?, ?", { + parameters: [1] + }) + ).rejects.toThrow(); + }); + + it("throws error for division by zero", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await expect(connection.execute("SELECT 1 / 0")).rejects.toThrow(); + }); + + it("throws error for invalid type cast", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await expect( + connection.execute("SELECT 'not a number'::int") + ).rejects.toThrow(); + }); +}); + diff --git a/test/integration/core/index.test.ts b/test/integration/core/index.test.ts new file mode 100644 index 00000000..77751f06 --- /dev/null +++ b/test/integration/core/index.test.ts @@ -0,0 +1,42 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("integration test", () => { + it("works", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 1"); + const { data, meta } = await statement.fetchResult(); + expect(data.length).toEqual(1); + expect(meta.length).toEqual(1); + }); + + it("test connection", async () => { + const firebolt = Firebolt(); + + await firebolt.testConnection(connectionParams); + expect(true).toBeTruthy(); + }); + + it("requires engineEndpoint for Core connections", async () => { + const firebolt = Firebolt(); + + await expect( + firebolt.connect({ + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string + // Missing engineEndpoint + }) + ).rejects.toThrow("engineEndpoint is required for Firebolt Core connections"); + }); +}); + diff --git a/test/integration/core/normalizeData.test.ts b/test/integration/core/normalizeData.test.ts new file mode 100644 index 00000000..672faa02 --- /dev/null +++ b/test/integration/core/normalizeData.test.ts @@ -0,0 +1,69 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("normalized data", () => { + it("returns array format by default", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 1 as id, 'test' as name"); + const { data } = await statement.fetchResult(); + + expect(Array.isArray(data[0])).toBe(true); + expect(data[0]).toEqual([1, "test"]); + }); + + it("returns normalized object format when enabled", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 1 as id, 'test' as name", { + response: { normalizeData: true } + }); + const { data } = await statement.fetchResult(); + + expect(typeof data[0]).toBe("object"); + expect(data[0]).toEqual({ id: 1, name: "test" }); + }); + + it("handles normalized data with multiple rows", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t(id, name)", + { + response: { normalizeData: true } + } + ); + const { data } = await statement.fetchResult(); + + expect(data.length).toEqual(3); + expect(data[0]).toEqual({ id: 1, name: "a" }); + expect(data[1]).toEqual({ id: 2, name: "b" }); + expect(data[2]).toEqual({ id: 3, name: "c" }); + }); + + it("handles normalized data with NULL values", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT 1 as id, NULL::text as name", + { + response: { normalizeData: true } + } + ); + const { data } = await statement.fetchResult(); + + expect(data[0]).toEqual({ id: 1, name: null }); + }); +}); + diff --git a/test/integration/core/outputFormat.test.ts b/test/integration/core/outputFormat.test.ts new file mode 100644 index 00000000..900131b4 --- /dev/null +++ b/test/integration/core/outputFormat.test.ts @@ -0,0 +1,71 @@ +import { Firebolt, FireboltCore, OutputFormat } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("output formats", () => { + it("uses JSON_COMPACT format by default", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 1, 'a', 123.4"); + const { data, meta } = await statement.fetchResult(); + + expect(data.length).toEqual(1); + expect(meta.length).toEqual(3); + expect(meta[0].type).toEqual("int"); + expect(meta[1].type).toEqual("text"); + expect(meta[2].type).toEqual("double"); + }); + + it("supports JSON_LINES format via streaming", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.executeStream("SELECT 1, 'a', 123.4"); + const { data } = await statement.streamResult(); + const rows: unknown[][] = []; + + await new Promise(resolve => { + data.on("data", row => { + rows.push(row as unknown[]); + }); + data.on("end", () => { + expect(rows.length).toBeGreaterThan(0); + expect(rows[0][0]).toEqual(1); + expect(rows[0][1]).toEqual("a"); + expect(rows[0][2]).toEqual(123.4); + resolve(); + }); + }); + }); + + it("handles arrays with streaming format", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.executeStream( + "SELECT [1, 2, 3]::array(int), ['a', 'b']::array(text)" + ); + const { data } = await statement.streamResult(); + const rows: unknown[][] = []; + + await new Promise(resolve => { + data.on("data", row => { + rows.push(row as unknown[]); + }); + data.on("end", () => { + expect(rows.length).toBeGreaterThan(0); + expect(rows[0][0]).toEqual([1, 2, 3]); + expect(rows[0][1]).toEqual(["a", "b"]); + resolve(); + }); + }); + }); +}); + diff --git a/test/integration/core/preparedStatement.test.ts b/test/integration/core/preparedStatement.test.ts new file mode 100644 index 00000000..3974d5bc --- /dev/null +++ b/test/integration/core/preparedStatement.test.ts @@ -0,0 +1,112 @@ +import BigNumber from "bignumber.js"; +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("prepared statements", () => { + it("executes query with positional parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ?, ?", { + parameters: [1, 2] + }); + const { data, meta } = await statement.fetchResult(); + + expect(data[0]).toEqual([1, 2]); + expect(meta.length).toEqual(2); + }); + + it("executes query with named parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT :foo, :bar", { + namedParameters: { foo: 1, bar: 2 } + }); + const { data, meta } = await statement.fetchResult(); + + expect(data[0]).toEqual([1, 2]); + expect(meta.length).toEqual(2); + }); + + it("handles various data types with parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const now = new Date(); + + const statement = await connection.execute( + "SELECT ?::int, ?::text, ?::double, ?::boolean, ?::datetime", + { + parameters: [42, "hello", 3.14, true, now] + } + ); + const { data, meta } = await statement.fetchResult(); + + expect(data[0][0]).toEqual(42); + expect(data[0][1]).toEqual("hello"); + expect(data[0][2]).toEqual(3.14); + expect(data[0][3]).toEqual(true); + expect(new Date(data[0][4])).toEqual(now); + expect(meta.length).toEqual(5); + }); + + it("handles array parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ?::array(int)", { + parameters: [[1, 2, 3]] + }); + const { data, meta } = await statement.fetchResult(); + + expect(data[0][0]).toEqual([1, 2, 3]); + expect(meta[0].type).toEqual("array(int)"); + }); + + it("handles long values with parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ?::long", { + parameters: ["9007199254740991"] + }); + const { data, meta } = await statement.fetchResult(); + + expect(data[0][0]).toEqual(new BigNumber("9007199254740991")); + expect(meta[0].type).toEqual("long"); + }); + + it("handles NULL parameters", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ?::int", { + parameters: [null] + }); + const { data, meta } = await statement.fetchResult(); + + expect(data[0][0]).toBeNull(); + expect(meta[0].type).toEqual("int null"); + }); + + it("handles multiple parameters in different positions", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ?, ?, ?", { + parameters: ["first", "second", "third"] + }); + const { data } = await statement.fetchResult(); + + expect(data[0]).toEqual(["first", "second", "third"]); + }); +}); + diff --git a/test/integration/core/query.test.ts b/test/integration/core/query.test.ts new file mode 100644 index 00000000..025ec972 --- /dev/null +++ b/test/integration/core/query.test.ts @@ -0,0 +1,56 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("query execution", () => { + it("executes simple SELECT query", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT 1 as value"); + const { data, meta } = await statement.fetchResult(); + + expect(meta.length).toEqual(1); + expect(meta[0].name).toEqual("value"); + expect(data.length).toEqual(1); + expect(data[0][0]).toEqual(1); + }); + + it("executes query with multiple rows", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute( + "SELECT * FROM (VALUES (1), (2), (3)) AS t(value)" + ); + const { data } = await statement.fetchResult(); + + expect(data.length).toEqual(3); + expect(data[0][0]).toEqual(1); + expect(data[1][0]).toEqual(2); + expect(data[2][0]).toEqual(3); + }); + + it("executes query with parameters", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.execute("SELECT ? as value", { + parameters: [42] + }); + const { data } = await statement.fetchResult(); + + expect(data.length).toEqual(1); + expect(data[0][0]).toEqual(42); + }); +}); + diff --git a/test/integration/core/resourceManager.test.ts b/test/integration/core/resourceManager.test.ts new file mode 100644 index 00000000..92238eaf --- /dev/null +++ b/test/integration/core/resourceManager.test.ts @@ -0,0 +1,22 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("resource manager", () => { + it("does not create resourceManager for Core connections", async () => { + const firebolt = Firebolt(); + + await firebolt.connect(connectionParams); + + expect(() => firebolt.resourceManager).toThrow( + "ResourceManager is not available for Firebolt Core connections. Use managed Firebolt authentication (client_id/client_secret) to access ResourceManager." + ); + }); +}); + diff --git a/test/integration/core/setStatement.test.ts b/test/integration/core/setStatement.test.ts new file mode 100644 index 00000000..eb4dc723 --- /dev/null +++ b/test/integration/core/setStatement.test.ts @@ -0,0 +1,49 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("SET statements", () => { + it("executes SET statement", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + // SET statements don't return data, just verify they execute without error + // Use a setting that Core supports (statement_timeout is commonly supported) + await connection.execute("SET statement_timeout=10000"); + const statement = await connection.execute("SELECT 1"); + const { data } = await statement.fetchResult(); + + expect(data[0][0]).toEqual(1); + }); + + it("SET statement affects subsequent queries", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + // Use a setting that Core supports + await connection.execute("SET statement_timeout=5000"); + const statement = await connection.execute("SELECT 1"); + const { data } = await statement.fetchResult(); + + expect(data[0][0]).toEqual(1); + }); + + it("handles multiple SET statements", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.execute("SET statement_timeout=10000"); + await connection.execute("SET max_threads=1"); + const statement = await connection.execute("SELECT 1"); + const { data } = await statement.fetchResult(); + + expect(data[0][0]).toEqual(1); + }); +}); + diff --git a/test/integration/core/stream.test.ts b/test/integration/core/stream.test.ts new file mode 100644 index 00000000..874d0b1c --- /dev/null +++ b/test/integration/core/stream.test.ts @@ -0,0 +1,63 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(10000); + +describe("streams", () => { + it("executes streaming query", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.executeStream( + "SELECT * FROM (VALUES (1), (2), (3)) AS t(value)" + ); + + const { data } = await statement.streamResult(); + const rows: number[] = []; + + data.on("data", row => { + rows.push(row[0] as number); + }); + + await new Promise(resolve => { + data.on("end", () => { + expect(rows.length).toEqual(3); + expect(rows).toContain(1); + expect(rows).toContain(2); + expect(rows).toContain(3); + resolve(); + }); + }); + }); + + it("handles larger streaming result", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + const statement = await connection.executeStream( + "SELECT * FROM generate_series(1, 1000) AS t(value)" + ); + + const { data } = await statement.streamResult(); + let count = 0; + + data.on("data", () => { + count++; + }); + + await new Promise(resolve => { + data.on("end", () => { + expect(count).toEqual(1000); + resolve(); + }); + }); + }); +}); + diff --git a/test/integration/core/transaction.test.ts b/test/integration/core/transaction.test.ts new file mode 100644 index 00000000..ab5a089e --- /dev/null +++ b/test/integration/core/transaction.test.ts @@ -0,0 +1,42 @@ +import { Firebolt, FireboltCore } from "../../../src/index"; + +const connectionParams = { + auth: FireboltCore(), + database: process.env.FIREBOLT_DATABASE as string, + engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string +}; + +jest.setTimeout(20000); + +describe("transactions", () => { + it("begin throws error", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect(connection.begin()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); + + it("commit throws error", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect(connection.commit()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); + + it("rollback throws error", async () => { + const firebolt = Firebolt(); + + const connection = await firebolt.connect(connectionParams); + + await expect(connection.rollback()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); +}); + diff --git a/test/integration/v2/engine.test.ts b/test/integration/v2/engine.test.ts index 9b6cee69..6f5fc6d6 100644 --- a/test/integration/v2/engine.test.ts +++ b/test/integration/v2/engine.test.ts @@ -44,7 +44,7 @@ describe.each([ await firebolt.connect(connectionOptions); - const engine = await firebolt.resourceManager.engine.getByName( + const engine = await firebolt.resourceManager.engine.getByName( process.env.FIREBOLT_ENGINE_NAME as string ); @@ -69,7 +69,7 @@ describe.each([ await firebolt.connect(connectionOptions); - const engine = await firebolt.resourceManager.engine.getByName( + const engine = await firebolt.resourceManager.engine.getByName( process.env.FIREBOLT_ENGINE_NAME as string ); diff --git a/test/integration/v2/fetchTypes.test.ts b/test/integration/v2/fetchTypes.test.ts index b165ce4e..3a03ef13 100644 --- a/test/integration/v2/fetchTypes.test.ts +++ b/test/integration/v2/fetchTypes.test.ts @@ -107,11 +107,6 @@ describe("test type casting on fetch", () => { ...connectionParams, engineName: process.env.FIREBOLT_ENGINE_NAME as string }); - await connection.execute("SET advanced_mode=1"); - await connection.execute("SET enable_create_table_v2=true"); - await connection.execute("SET enable_struct_syntax=true"); - await connection.execute("SET prevent_create_on_information_schema=true"); - await connection.execute("SET enable_create_table_with_struct_type=true"); await connection.execute("DROP TABLE IF EXISTS test_struct"); await connection.execute("DROP TABLE IF EXISTS test_struct_helper"); try { diff --git a/test/integration/v2/geography.test.ts b/test/integration/v2/geography.test.ts index 0ecfbce8..b5a04adf 100644 --- a/test/integration/v2/geography.test.ts +++ b/test/integration/v2/geography.test.ts @@ -20,9 +20,6 @@ describe("geography", () => { const connection = await firebolt.connect(connectionParams); - await connection.execute("SET advanced_mode=1"); - await connection.execute("SET enable_geography=true"); - const statement = await connection.execute( "select 'POINT(1 1)'::geography;" ); diff --git a/test/unit/core/async.test.ts b/test/unit/core/async.test.ts new file mode 100644 index 00000000..743a1c8a --- /dev/null +++ b/test/unit/core/async.test.ts @@ -0,0 +1,43 @@ +import { Firebolt, FireboltCore } from "../../../src"; +import { ConnectionOptions } from "../../../src/types"; + +describe("Async Queries Core", () => { + const connectionParams: ConnectionOptions = { + auth: FireboltCore(), + database: "test_db", + engineEndpoint: "http://fake" // Not used in these tests, but required by ConnectionOptions + }; + + it("executeAsync throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.executeAsync("SELECT 1")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("isAsyncQueryRunning throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.isAsyncQueryRunning("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("isAsyncQuerySuccessful throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.isAsyncQuerySuccessful("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("cancelAsyncQuery throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.cancelAsyncQuery("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); +}); + diff --git a/test/unit/core/connection.test.ts b/test/unit/core/connection.test.ts new file mode 100644 index 00000000..1ed6e919 --- /dev/null +++ b/test/unit/core/connection.test.ts @@ -0,0 +1,18 @@ +import { Firebolt, FireboltCore } from "../../../src"; +import { ConnectionOptions } from "../../../src/types"; + +describe("Connection Core", () => { + it("requires engineEndpoint", async () => { + const firebolt = Firebolt(); + const connectionParams: ConnectionOptions = { + auth: FireboltCore(), + database: "test_db" + // Missing engineEndpoint + }; + + await expect(firebolt.connect(connectionParams)).rejects.toThrow( + "engineEndpoint is required for Firebolt Core connections" + ); + }); + +}); diff --git a/test/unit/core/query.test.ts b/test/unit/core/query.test.ts new file mode 100644 index 00000000..f2590bee --- /dev/null +++ b/test/unit/core/query.test.ts @@ -0,0 +1,250 @@ +import { setupServer } from "msw/node"; +import { rest } from "msw"; +import { Firebolt, FireboltCore, OutputFormat } from "../../../src"; +import { ConnectionOptions } from "../../../src/types"; + +const engineEndpoint = "http://localhost:3473"; + +const queryResponse = { + meta: [ + { + name: "result", + type: "Int64" + } + ], + data: [[42]], + rows: 1 +}; + +const multiRowResponse = { + meta: [ + { + name: "value", + type: "Text" + } + ], + data: [["a"], ["b"], ["c"]], + rows: 3 +}; + +describe("Connection Core HTTP", () => { + const server = setupServer(); + const connectionParams: ConnectionOptions = { + auth: FireboltCore(), + database: "test_db", + engineEndpoint + }; + + beforeAll(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it("executes a simple query", async () => { + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + const statement = await connection.execute("SELECT 42"); + const result = await statement.fetchResult(); + + expect(result.data).toEqual([[42]]); + expect(result.data.length).toBe(1); + }); + + it("executes a query with parameters", async () => { + let requestBody = ""; + server.use( + rest.post(`http://localhost:3473`, async (req, res, ctx) => { + requestBody = await req.text(); + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + const statement =await connection.execute("SELECT ?", { parameters: [42] }); + const result = await statement.fetchResult(); + + expect(result.data).toEqual([[42]]); + expect(result.data.length).toBe(1); + // Verify the query body contains the parameter value + expect(requestBody).toContain("42"); + }); + + it("handles multiple rows", async () => { + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + return res(ctx.json(multiRowResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + const statement = await connection.execute("SELECT 'a' UNION ALL SELECT 'b' UNION ALL SELECT 'c'"); + const result = await statement.fetchResult(); + + expect(result.data).toEqual([["a"], ["b"], ["c"]]); + expect(result.data.length).toBe(3); + }); + + it("handles query errors", async () => { + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + return res( + ctx.status(400), + ctx.json({ + error: { + message: "Syntax error", + code: "SYNTAX_ERROR" + } + }) + ); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.execute("INVALID SQL")).rejects.toThrow(); + }); + + it("sends database parameter in query string", async () => { + let requestUrl = ""; + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + requestUrl = req.url.toString(); + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const customConnectionParams: ConnectionOptions = { + ...connectionParams, + database: "my_database" + }; + const connection = await firebolt.connect(customConnectionParams); + await connection.execute("SELECT 1"); + + // Parse URL and verify database parameter is set correctly + const url = new URL(requestUrl); + expect(url.searchParams.get("database")).toBe("my_database"); + }); + + it("sends output_format parameter", async () => { + let requestUrl = ""; + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + requestUrl = req.url.toString(); + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + // Use a format that Core supports + await connection.execute("SELECT 1", { + settings: { output_format: OutputFormat.JSON_LINES } + }); + + // Parse URL and verify output_format parameter is set correctly + const url = new URL(requestUrl); + expect(url.searchParams.get("output_format")).toBe("JSONLines_Compact"); + }); + + it("does not send authorization header", async () => { + let receivedHeaders: Record = {}; + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + receivedHeaders = Object.fromEntries( + Object.entries(req.headers.all()).map(([key, value]) => [ + key.toLowerCase(), + Array.isArray(value) ? value[0] : value + ]) + ); + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await connection.execute("SELECT 1"); + + expect(receivedHeaders["authorization"]).toBeUndefined(); + }); + + it("handles streaming queries", async () => { + const jsonLines = [ + JSON.stringify({ + message_type: "START", + result_columns: [ + { + name: "value", + type: "text" + } + ] + }), + JSON.stringify({ + message_type: "DATA", + data: [["a"]] + }), + JSON.stringify({ + message_type: "DATA", + data: [["b"]] + }), + JSON.stringify({ + message_type: "DATA", + data: [["c"]] + }), + JSON.stringify({ + message_type: "FINISH_SUCCESSFULLY" + }) + ].join("\n"); + + server.use( + rest.post(`http://localhost:3473`, (req, res, ctx) => { + const urlParams = Object.fromEntries(req.url.searchParams.entries()); + if (urlParams["output_format"] === "JSONLines_Compact") { + return res( + ctx.set("Content-Type", "application/x-ndjson"), + ctx.body(jsonLines) + ); + } + return res(ctx.json(queryResponse)); + }) + ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + const streamStatement = await connection.executeStream("SELECT 'a' UNION ALL SELECT 'b' UNION ALL SELECT 'c'"); + + const rows: string[] = []; + const { data } = await streamStatement.streamResult(); + + await new Promise(resolve => { + data + .on("data", row => { + rows.push(row[0] as string); + }) + .on("end", () => { + expect(rows.length).toBe(3); + expect(rows).toContain("a"); + expect(rows).toContain("b"); + expect(rows).toContain("c"); + resolve(); + }); + }); + }); + +}); + diff --git a/test/unit/core/statement.test.ts b/test/unit/core/statement.test.ts new file mode 100644 index 00000000..4fd3dae9 --- /dev/null +++ b/test/unit/core/statement.test.ts @@ -0,0 +1,42 @@ +import { Firebolt, FireboltCore } from "../../../src"; +import { ConnectionOptions } from "../../../src/types"; + +describe("Statement Core", () => { + const connectionParams: ConnectionOptions = { + auth: FireboltCore(), + database: "test_db", + engineEndpoint: "http://fake" // Not used in these tests, but required by ConnectionOptions + }; + + it("executeAsync throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.executeAsync("SELECT 1")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("isAsyncQueryRunning throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.isAsyncQueryRunning("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("isAsyncQuerySuccessful throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.isAsyncQuerySuccessful("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); + + it("cancelAsyncQuery throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.cancelAsyncQuery("token")).rejects.toThrow( + "Async queries are not supported in Firebolt Core" + ); + }); +}); diff --git a/test/unit/core/transaction.test.ts b/test/unit/core/transaction.test.ts new file mode 100644 index 00000000..5747a2df --- /dev/null +++ b/test/unit/core/transaction.test.ts @@ -0,0 +1,34 @@ +import { Firebolt, FireboltCore } from "../../../src"; +import { ConnectionOptions } from "../../../src/types"; + +describe("Transaction Core", () => { + const connectionParams: ConnectionOptions = { + auth: FireboltCore(), + database: "test_db", + engineEndpoint: "http://fake" // Not used in these tests, but required by ConnectionOptions + }; + + it("begin throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.begin()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); + + it("commit throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.commit()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); + + it("rollback throws error", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await expect(connection.rollback()).rejects.toThrow( + "Transactions are not supported in Firebolt Core" + ); + }); +}); From 65851235556b4dd7b5e48458836fbfa1f2f5fab7 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 14:43:27 -0800 Subject: [PATCH 03/18] Update CI --- .../actions/setup-firebolt-core/action.yml | 60 ++++++++++++++++++ .../actions/teardown-firebolt-core/action.yml | 9 +++ .github/workflows/integration-tests-core.yaml | 62 +++++++++++++++++++ .github/workflows/integration-tests.yaml | 2 + .github/workflows/nightly.yaml | 20 ++++++ 5 files changed, 153 insertions(+) create mode 100644 .github/actions/setup-firebolt-core/action.yml create mode 100644 .github/actions/teardown-firebolt-core/action.yml create mode 100644 .github/workflows/integration-tests-core.yaml diff --git a/.github/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml new file mode 100644 index 00000000..eb03164a --- /dev/null +++ b/.github/actions/setup-firebolt-core/action.yml @@ -0,0 +1,60 @@ +name: 'Setup Firebolt Core' +description: 'Start Firebolt Core in Docker and wait for it to be ready' +inputs: + database: + description: 'Database name for Firebolt Core' + required: false + default: 'firebolt' + endpoint: + description: 'Firebolt Core endpoint' + required: false + default: 'http://localhost:3473' +outputs: + database: + description: 'Database name' + value: ${{ inputs.database }} + endpoint: + description: 'Firebolt Core endpoint' + value: ${{ inputs.endpoint }} +runs: + using: 'composite' + steps: + - name: Create firebolt-core-data directory + shell: bash + run: mkdir -p ./firebolt-core-data + + - name: Start Firebolt Core + shell: bash + run: | + docker run -d --rm --name firebolt-core \ + --ulimit memlock=8589934592:8589934592 \ + --security-opt seccomp=unconfined \ + -p 127.0.0.1:3473:3473 \ + -v $(pwd)/firebolt-core-data:/firebolt-core/volume \ + ghcr.io/firebolt-db/firebolt-core:preview-rc + + - name: Wait for Firebolt Core to be ready + shell: bash + run: | + echo "Waiting for Firebolt Core to be ready..." + timeout=60 + elapsed=0 + while [ $elapsed -lt $timeout ]; do + if curl -s ${{ inputs.endpoint }} > /dev/null 2>&1; then + echo "Firebolt Core is ready!" + exit 0 + fi + echo "Waiting... ($elapsed/$timeout seconds)" + sleep 2 + elapsed=$((elapsed + 2)) + done + echo "Firebolt Core failed to start within $timeout seconds" + docker logs firebolt-core + exit 1 + + - name: Set outputs + shell: bash + run: | + echo "database=${{ inputs.database }}" >> $GITHUB_OUTPUT + echo "endpoint=${{ inputs.endpoint }}" >> $GITHUB_OUTPUT + diff --git a/.github/actions/teardown-firebolt-core/action.yml b/.github/actions/teardown-firebolt-core/action.yml new file mode 100644 index 00000000..dcf1587a --- /dev/null +++ b/.github/actions/teardown-firebolt-core/action.yml @@ -0,0 +1,9 @@ +name: 'Teardown Firebolt Core' +description: 'Stop Firebolt Core Docker container' +runs: + using: 'composite' + steps: + - name: Stop Firebolt Core + shell: bash + run: docker stop firebolt-core || true + diff --git a/.github/workflows/integration-tests-core.yaml b/.github/workflows/integration-tests-core.yaml new file mode 100644 index 00000000..0c987fb2 --- /dev/null +++ b/.github/workflows/integration-tests-core.yaml @@ -0,0 +1,62 @@ +name: Run integration tests for Core +on: + workflow_dispatch: + workflow_call: + inputs: + token: + description: 'GitHub token if called from another workflow' + required: false + type: string + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: npm install + + - name: Setup Firebolt Core + id: setup-core + uses: ./.github/actions/setup-firebolt-core + with: + database: "firebolt" + endpoint: "http://localhost:3473" + + - name: Run Core integration tests + env: + FIREBOLT_DATABASE: ${{ steps.setup-core.outputs.database }} + FIREBOLT_CORE_ENDPOINT: ${{ steps.setup-core.outputs.endpoint }} + run: | + npm run test:ci integration/core + + - name: Teardown Firebolt Core + if: always() + uses: ./.github/actions/teardown-firebolt-core + + # Need to pull the pages branch in order to fetch the previous runs + - name: Get Allure history + uses: actions/checkout@v4 + if: always() + continue-on-error: true + with: + ref: gh-pages + path: gh-pages + + - name: Allure Report + uses: firebolt-db/action-allure-report@v1 + if: always() + with: + github-key: ${{ inputs.token || secrets.GITHUB_TOKEN }} + test-type: integration-core + allure-dir: allure-results + pages-branch: gh-pages + repository-name: firebolt-node-sdk + diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index ed3a4868..850cc0f1 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -29,4 +29,6 @@ jobs: secrets: FIREBOLT_CLIENT_ID_STG_NEW_IDN: ${{ secrets.FIREBOLT_CLIENT_ID_STG_NEW_IDN }} FIREBOLT_CLIENT_SECRET_STG_NEW_IDN: ${{ secrets.FIREBOLT_CLIENT_SECRET_STG_NEW_IDN }} + integration-test-core: + uses: ./.github/workflows/integration-tests-core.yaml diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 6f15380f..a7b352bb 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -45,6 +45,26 @@ jobs: run: | npm run test:ci integration/v2 + - name: Setup Firebolt Core + if: matrix.os == 'ubuntu-latest' + id: setup-core + uses: ./.github/actions/setup-firebolt-core + with: + database: "firebolt" + endpoint: "http://localhost:3473" + + - name: Run Core integration tests + if: matrix.os == 'ubuntu-latest' + env: + FIREBOLT_DATABASE: ${{ steps.setup-core.outputs.database }} + FIREBOLT_CORE_ENDPOINT: ${{ steps.setup-core.outputs.endpoint }} + run: | + npm run test:ci integration/core + + - name: Teardown Firebolt Core + if: matrix.os == 'ubuntu-latest' + uses: ./.github/actions/teardown-firebolt-core + - name: Slack Notify of failure if: failure() id: slack From a5192e32bd81b2a13124bd05329fcfc896818099 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 14:52:46 -0800 Subject: [PATCH 04/18] Fix CI --- .../actions/setup-firebolt-core/action.yml | 52 +++++++++++++++---- .github/workflows/integration-tests-core.yaml | 2 +- .github/workflows/nightly.yaml | 2 +- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml index eb03164a..d35e98f3 100644 --- a/.github/actions/setup-firebolt-core/action.yml +++ b/.github/actions/setup-firebolt-core/action.yml @@ -8,7 +8,7 @@ inputs: endpoint: description: 'Firebolt Core endpoint' required: false - default: 'http://localhost:3473' + default: 'http://127.0.0.1:3473' outputs: database: description: 'Database name' @@ -37,20 +37,52 @@ runs: shell: bash run: | echo "Waiting for Firebolt Core to be ready..." - timeout=60 + timeout=120 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${{ inputs.endpoint }} > /dev/null 2>&1; then + # Try to connect and execute a simple query + response=$(curl -s -w "\n%{http_code}" -X POST "${{ inputs.endpoint }}/?database=default" \ + --data-binary "SELECT 1" 2>&1) || response="" + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "200" ]; then echo "Firebolt Core is ready!" - exit 0 + break fi - echo "Waiting... ($elapsed/$timeout seconds)" - sleep 2 - elapsed=$((elapsed + 2)) + echo "Waiting... ($elapsed/$timeout seconds) - HTTP $http_code" + sleep 3 + elapsed=$((elapsed + 3)) done - echo "Firebolt Core failed to start within $timeout seconds" - docker logs firebolt-core - exit 1 + + if [ $elapsed -ge $timeout ]; then + echo "Firebolt Core failed to start within $timeout seconds" + echo "Container logs:" + docker logs firebolt-core || true + echo "Container status:" + docker ps -a | grep firebolt-core || true + exit 1 + fi + + # Give Core a bit more time to fully initialize + echo "Core is responding, waiting additional 5 seconds for full initialization..." + sleep 5 + + - name: Create database if it doesn't exist + shell: bash + run: | + echo "Creating database '${{ inputs.database }}' ..." + # Create the database using a SQL command + response=$(curl -s -w "\n%{http_code}" -X POST "${{ inputs.endpoint }}/?database=default" \ + --data-binary "CREATE DATABASE \"${{ inputs.database }}\"" 2>&1) + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "200" ]; then + echo "Database '${{ inputs.database }}' created" + else + echo "Warning: Database creation returned HTTP $http_code" + echo "Response: $response" + exit 1 + fi - name: Set outputs shell: bash diff --git a/.github/workflows/integration-tests-core.yaml b/.github/workflows/integration-tests-core.yaml index 0c987fb2..cf6c401b 100644 --- a/.github/workflows/integration-tests-core.yaml +++ b/.github/workflows/integration-tests-core.yaml @@ -28,7 +28,7 @@ jobs: uses: ./.github/actions/setup-firebolt-core with: database: "firebolt" - endpoint: "http://localhost:3473" + endpoint: "http://127.0.0.1:3473" - name: Run Core integration tests env: diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index a7b352bb..38d8fd5e 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -51,7 +51,7 @@ jobs: uses: ./.github/actions/setup-firebolt-core with: database: "firebolt" - endpoint: "http://localhost:3473" + endpoint: "http://127.0.0.1:3473" - name: Run Core integration tests if: matrix.os == 'ubuntu-latest' From 1d35fea90dbeefafe0d56791226f6cbab2446045 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 14:55:35 -0800 Subject: [PATCH 05/18] Don't attach to random default database --- .github/actions/setup-firebolt-core/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml index d35e98f3..de2d63f0 100644 --- a/.github/actions/setup-firebolt-core/action.yml +++ b/.github/actions/setup-firebolt-core/action.yml @@ -41,7 +41,7 @@ runs: elapsed=0 while [ $elapsed -lt $timeout ]; do # Try to connect and execute a simple query - response=$(curl -s -w "\n%{http_code}" -X POST "${{ inputs.endpoint }}/?database=default" \ + response=$(curl -s -w "\n%{http_code}" -X POST "${{ inputs.endpoint }}/" \ --data-binary "SELECT 1" 2>&1) || response="" http_code=$(echo "$response" | tail -n1) From 00a7b5f8808a3adf1516f32852b4270edf03e5f3 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 15:04:24 -0800 Subject: [PATCH 06/18] README and CI --- .cursor/rules/architecture/RULE.md | 21 +++++--- .../actions/setup-firebolt-core/action.yml | 8 ++- README.md | 52 +++++++++++++++++-- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/.cursor/rules/architecture/RULE.md b/.cursor/rules/architecture/RULE.md index ae47c203..f59147e2 100644 --- a/.cursor/rules/architecture/RULE.md +++ b/.cursor/rules/architecture/RULE.md @@ -7,8 +7,9 @@ alwaysApply: false **V1 (Legacy)**: Username/password auth → `ConnectionV1`, `QueryFormatterV1`, `DatabaseServiceV1`, `EngineServiceV1` **V2 (Current)**: Service account auth (client_id/secret) → `ConnectionV2`, `QueryFormatterV2`, `DatabaseServiceV2`, `EngineServiceV2` +**Core (Self-Hosted)**: `FireboltCore()` auth → `ConnectionCore`, `QueryFormatterV2`, no ResourceManager, no async queries, no transactions -Version selected in `makeConnection()` based on auth type. **Always support both versions** unless explicitly V2-only. +Version selected in `makeConnection()` based on auth type. **Always support all versions** unless explicitly version-specific feature. ## Core Patterns @@ -39,12 +40,15 @@ Connection maintains session parameters: ## Authentication & Caching -`Authenticator` handles OAuth tokens with thread-safe caching (read/write locks via `rwlock`). Cache key: `{clientId, secret, apiEndpoint}`. Tokens expire at 50% of actual expiry for safety. Disable with `useCache: false`. +**Managed Firebolt**: `Authenticator` handles OAuth tokens with thread-safe caching (read/write locks via `rwlock`). Cache key: `{clientId, secret, apiEndpoint}`. Tokens expire at 50% of actual expiry for safety. Disable with `useCache: false`. + +**Firebolt Core**: `CoreAuthenticator` provides no-op authentication (no tokens, no caching). Core connections don't require authentication. ## Engine Endpoint Resolution **V2**: Get system engine URL → connect → `USE DATABASE` → `USE ENGINE` (if specified) **V1**: Resolve account ID → get engine URL by database/engine → direct connection +**Core**: `engineEndpoint` must be provided explicitly in connection options (no resolution needed) ## Error Handling @@ -53,18 +57,21 @@ Use `CompositeError` for multiple errors. Custom errors: `AccountNotFoundError`, ## Important Details - **Prepared statements**: `native` (default, client-side `?`/`:name`) vs `fb_numeric` (server-side `$1`/`$2`) -- **Transactions**: `begin()`, `commit()`, `rollback()` on Connection (state per connection) -- **Async queries**: `async: true` setting → token for `isAsyncQueryRunning()`, `isAsyncQuerySuccessful()`, `cancelAsyncQuery()` +- **Transactions**: `begin()`, `commit()`, `rollback()` on Connection (state per connection). **Not supported in Core** - methods throw errors. +- **Async queries**: `async: true` setting → token for `isAsyncQueryRunning()`, `isAsyncQuerySuccessful()`, `cancelAsyncQuery()`. **Not supported in Core** - methods throw errors. +- **ResourceManager**: Available for V1/V2 managed connections. **Not available in Core** - accessing `resourceManager` throws error. - **Result hydration**: SQL types → JS types (dates, BigNumber for large ints, normalization) - **Connection cleanup**: `destroy()` aborts all active requests - **Response formats**: `JSON_COMPACT` (default), `JSON`, `JSON_LINES` ## Development Rules -1. Support both V1 and V2 unless V2-only feature -2. Use abstract base classes for shared functionality +1. Support V1, V2, and Core unless version-specific feature +2. Use abstract base classes for shared functionality (`Connection`, `QueryFormatter`, `Authenticator`) 3. Keep DI pattern for testability 4. Use custom error types from `src/common/errors.ts` -5. Maintain separate V1/V2 test suites +5. Maintain separate V1/V2/Core test suites 6. V1 is legacy; prioritize V2 for new features +7. **Core limitations**: No ResourceManager, no async queries, no transactions. Use `CoreAuthenticator` for no-op auth. `engineEndpoint` required. +8. **Type guards**: Use `"type" in auth && auth.type === "firebolt-core"` to detect Core connections diff --git a/.github/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml index de2d63f0..bbbbdd82 100644 --- a/.github/actions/setup-firebolt-core/action.yml +++ b/.github/actions/setup-firebolt-core/action.yml @@ -21,7 +21,11 @@ runs: steps: - name: Create firebolt-core-data directory shell: bash - run: mkdir -p ./firebolt-core-data + run: | + mkdir -p ./firebolt-core-data + # Firebolt Core runs as user 1111:1111, so we need to set ownership + sudo chown -R 1111:1111 ./firebolt-core-data + sudo chmod 755 ./firebolt-core-data - name: Start Firebolt Core shell: bash @@ -37,7 +41,7 @@ runs: shell: bash run: | echo "Waiting for Firebolt Core to be ready..." - timeout=120 + timeout=30 elapsed=0 while [ $elapsed -lt $timeout ]; do # Try to connect and execute a simple query diff --git a/README.md b/README.md index 24b3ac68..733c15de 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ yarn add firebolt-sdk ### Authentication -After installation, you must authenticate before you can use the SDK to establish connections, run queries, and manage database resources. The following code example sets up a connection using your Firebolt service account credentials: +After installation, you must authenticate before you can use the SDK to establish connections, run queries, and manage database resources. The SDK supports two authentication methods: + +#### Managed Firebolt (Service Account) + +The following code example sets up a connection using your Firebolt service account credentials: ```typescript const connection = await firebolt.connect({ @@ -41,6 +45,22 @@ In the previous code example, the following apply: * `database` is the target databaset to store your tables. * `account` is the [account](https://docs.firebolt.io/Overview/organizations-accounts.html#accounts) within your organisation. Your account is not the same as your user name. +#### Firebolt Core (Self-Hosted) + +For self-hosted Firebolt Core instances, no authentication is required. Simply use `FireboltCore()` as the auth method: + +```typescript +import { Firebolt, FireboltCore } from 'firebolt-sdk' + +const connection = await firebolt.connect({ + auth: FireboltCore(), + database: 'database', + engineEndpoint: 'http://localhost:3473', // Required for Core +}); +``` + +**Note:** Firebolt Core connections require `engineEndpoint` to be specified. Resource management (engines, databases) and async queries are not available for Core connections. + ### Example In the following code example, credentials are stored in environment variables. For bash and similar shells you can set them by running `export FIREBOLT_CLIENT_ID=` where is the id you want to set. This method prevents hardcoding sensitive information in your code so it can be safely commited to a version control system such as Git. Many IDEs, including IntelliJ IDEA, allow the configuration of environment variables in their run configurations. @@ -203,11 +223,15 @@ type ClientCredentialsAuth = { type PreparedStatementParamStyle = "native" | "fb_numeric"; +type FireboltCoreAuth = { + type: "firebolt-core"; +}; + type ConnectionOptions = { - auth: AccessTokenAuth | ServiceAccountAuth; + auth: AccessTokenAuth | ServiceAccountAuth | FireboltCoreAuth; database: string; engineName?: string; - engineEndpoint?: string; + engineEndpoint?: string; // Required for Firebolt Core connections account?: string; preparedStatementParamStyle?: PreparedStatementParamStyle; }; @@ -250,6 +274,28 @@ const connection = await firebolt.connect({ #### engineName You can omit `engineName` and execute AQL queries on such connection. + +#### Firebolt Core +For self-hosted Firebolt Core instances, use `FireboltCore()` authentication and provide `engineEndpoint`: + +```typescript +import { Firebolt, FireboltCore } from 'firebolt-sdk' + +const connection = await firebolt.connect({ + auth: FireboltCore(), + database: 'database', + engineEndpoint: 'http://localhost:3473', // Required for Core +}); +``` + +**Important notes for Firebolt Core:** +- No authentication credentials are required +- `engineEndpoint` is required (must be specified) +- Resource management (`resourceManager`) is not available +- Async queries (`executeAsync`) are not supported +- Transactions (`begin`, `commit`, `rollback`) are not supported +- Streaming queries (`executeStream`) are supported + #### Token caching Driver implements a caching mechanism for access tokens. If you are using the same client id or secret for multiple connections, the driver will cache the access token and reuse it for subsequent connections. From 3b233abef5c9de94268104c088e36260ad7e81bf Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 15:06:15 -0800 Subject: [PATCH 07/18] create if not exists --- .github/actions/setup-firebolt-core/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml index bbbbdd82..279c6942 100644 --- a/.github/actions/setup-firebolt-core/action.yml +++ b/.github/actions/setup-firebolt-core/action.yml @@ -77,11 +77,11 @@ runs: echo "Creating database '${{ inputs.database }}' ..." # Create the database using a SQL command response=$(curl -s -w "\n%{http_code}" -X POST "${{ inputs.endpoint }}/?database=default" \ - --data-binary "CREATE DATABASE \"${{ inputs.database }}\"" 2>&1) + --data-binary "CREATE DATABASE IF NOT EXISTS \"${{ inputs.database }}\"" 2>&1) http_code=$(echo "$response" | tail -n1) if [ "$http_code" = "200" ]; then - echo "Database '${{ inputs.database }}' created" + echo "Database '${{ inputs.database }}' created or already exists" else echo "Warning: Database creation returned HTTP $http_code" echo "Response: $response" From ffe73935eec7f6d6c92c90202e27002cc170aa9c Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 15:09:54 -0800 Subject: [PATCH 08/18] No allure reporting for forks --- .github/workflows/integration-tests-core.yaml | 5 +++-- .github/workflows/integration-tests-v2.yaml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-tests-core.yaml b/.github/workflows/integration-tests-core.yaml index cf6c401b..26c7f1ec 100644 --- a/.github/workflows/integration-tests-core.yaml +++ b/.github/workflows/integration-tests-core.yaml @@ -42,9 +42,10 @@ jobs: uses: ./.github/actions/teardown-firebolt-core # Need to pull the pages branch in order to fetch the previous runs + # Only run Allure reporting on the main repository, not forks - name: Get Allure history uses: actions/checkout@v4 - if: always() + if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' continue-on-error: true with: ref: gh-pages @@ -52,7 +53,7 @@ jobs: - name: Allure Report uses: firebolt-db/action-allure-report@v1 - if: always() + if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' with: github-key: ${{ inputs.token || secrets.GITHUB_TOKEN }} test-type: integration-core diff --git a/.github/workflows/integration-tests-v2.yaml b/.github/workflows/integration-tests-v2.yaml index b1176ad6..6349d9a9 100644 --- a/.github/workflows/integration-tests-v2.yaml +++ b/.github/workflows/integration-tests-v2.yaml @@ -57,9 +57,10 @@ jobs: npm run test:ci integration/v2 # Need to pull the pages branch in order to fetch the previous runs + # Only run Allure reporting on the main repository, not forks - name: Get Allure history uses: actions/checkout@v6 - if: always() + if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' continue-on-error: true with: ref: gh-pages @@ -67,7 +68,7 @@ jobs: - name: Allure Report uses: firebolt-db/action-allure-report@v1 - if: always() + if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' with: github-key: ${{ inputs.token || secrets.GITHUB_TOKEN }} test-type: integration From c03c5d4f159b521e0543c7207d39db20d81337b4 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 15:14:57 -0800 Subject: [PATCH 09/18] Fix unit tests --- src/core/index.ts | 10 +++++----- src/http/node.ts | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 05931a77..7517f6e6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -55,11 +55,11 @@ export class FireboltClient { !("type" in connectionOptions.auth) || (connectionOptions.auth as FireboltCoreAuth).type !== "firebolt-core" ) { - const resourceContext = { - connection, - ...connectionContext - }; - this.resourceManager = new ResourceManager(resourceContext); + const resourceContext = { + connection, + ...connectionContext + }; + this.resourceManager = new ResourceManager(resourceContext); } return connection; diff --git a/src/http/node.ts b/src/http/node.ts index d1cef25a..77b843f6 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -3,7 +3,7 @@ import http from "http"; import https from "https"; import Abort from "abort-controller"; -import { Response } from "node-fetch"; +import { Response, Headers } from "node-fetch"; import { assignProtocol, systemInfoString } from "../common/util"; import { ApiError, AuthenticationError } from "../common/errors"; import { Authenticator } from "../auth/managed"; @@ -161,13 +161,13 @@ export class NodeHttpClient { hostname: urlObj.hostname, port: urlObj.port || (isHttp ? 80 : 443), path: urlObj.pathname + urlObj.search, - method, - headers: { - "user-agent": userAgent, - "Content-Type": "application/json", - [PROTOCOL_VERSION_HEADER]: PROTOCOL_VERSION, - ...headersWithAuth - }, + method, + headers: { + "user-agent": userAgent, + "Content-Type": "application/json", + [PROTOCOL_VERSION_HEADER]: PROTOCOL_VERSION, + ...headersWithAuth + }, agent }, res => { From 92ed869d691a9786dce4642b830d30737648796e Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 14 Dec 2025 15:55:10 -0800 Subject: [PATCH 10/18] Fix header propagation --- src/http/node.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/http/node.ts b/src/http/node.ts index 77b843f6..b59b65a4 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -178,7 +178,7 @@ export class NodeHttpClient { statusText: res.statusMessage || "OK", headers: new Headers( Object.entries(res.headers).reduce((acc, [key, value]) => { - if (value) { + if (value !== undefined && value !== null) { acc[key] = Array.isArray(value) ? value.join(", ") : String(value); } return acc; @@ -227,7 +227,7 @@ export class NodeHttpClient { statusText: res.statusMessage || "OK", headers: new Headers( Object.entries(res.headers).reduce((acc, [key, value]) => { - if (value) { + if (value !== undefined && value !== null) { acc[key] = Array.isArray(value) ? value.join(", ") : String(value); } return acc; From d4ac87ec692a3ba35da8dda0fd362f7f18f94c2a Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sat, 20 Dec 2025 23:15:40 +0100 Subject: [PATCH 11/18] Multi Statement Tx Handling --- .env.example | 2 +- .github/workflows/integration-tests-core.yaml | 4 +- src/common/util.ts | 5 +- src/connection/connection_core.ts | 12 -- test/integration/core/transaction.test.ts | 154 ++++++++++++++++-- test/unit/core/transaction.test.ts | 121 ++++++++++++-- 6 files changed, 257 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 32821973..c591f095 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ FIREBOLT_CLIENT_SECRET= FIREBOLT_ACCOUNT= FIREBOLT_DATABASE= FIREBOLT_ENGINE_NAME= -FIREBOLT_CORE_ENDPOINT="http://localhost:3474" \ No newline at end of file +FIREBOLT_CORE_ENDPOINT="http://localhost:3473" \ No newline at end of file diff --git a/.github/workflows/integration-tests-core.yaml b/.github/workflows/integration-tests-core.yaml index 26c7f1ec..3bcd8fd3 100644 --- a/.github/workflows/integration-tests-core.yaml +++ b/.github/workflows/integration-tests-core.yaml @@ -16,9 +16,9 @@ jobs: uses: actions/checkout@v4 - name: Set up node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '18' + node-version: '24' - name: Install dependencies run: npm install diff --git a/src/common/util.ts b/src/common/util.ts index a27aa83e..48d8182b 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -4,10 +4,7 @@ import os from "os"; import { ConnectorVersion } from "../types"; export const assignProtocol = (url: string) => { - if (url.startsWith("http")) { - return url; - } - return `https://${url}`; + return url.startsWith("http") ? url : `https://${url}`; }; export const isDataQuery = (query: string): boolean => { diff --git a/src/connection/connection_core.ts b/src/connection/connection_core.ts index 8f126b1b..54044f62 100644 --- a/src/connection/connection_core.ts +++ b/src/connection/connection_core.ts @@ -57,17 +57,5 @@ export class ConnectionCore extends BaseConnection { async cancelAsyncQuery(token: string): Promise { throw new Error("Async queries are not supported in Firebolt Core"); } - - async begin(): Promise { - throw new Error("Transactions are not supported in Firebolt Core"); - } - - async commit(): Promise { - throw new Error("Transactions are not supported in Firebolt Core"); - } - - async rollback(): Promise { - throw new Error("Transactions are not supported in Firebolt Core"); - } } diff --git a/test/integration/core/transaction.test.ts b/test/integration/core/transaction.test.ts index ab5a089e..3094a164 100644 --- a/test/integration/core/transaction.test.ts +++ b/test/integration/core/transaction.test.ts @@ -6,37 +6,169 @@ const connectionParams = { engineEndpoint: process.env.FIREBOLT_CORE_ENDPOINT as string }; -jest.setTimeout(20000); +jest.setTimeout(10000); describe("transactions", () => { - it("begin throws error", async () => { + beforeAll(async () => { const firebolt = Firebolt(); + // Setup test table const connection = await firebolt.connect(connectionParams); + await connection.execute("DROP TABLE IF EXISTS transaction_test"); + await connection.execute(` + CREATE TABLE transaction_test ( + id INT, + name TEXT + ) + `); + }); - await expect(connection.begin()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" + afterAll(async () => { + const firebolt = Firebolt(); + // Cleanup test table + const connection = await firebolt.connect(connectionParams); + try { + await connection.execute("DROP TABLE IF EXISTS transaction_test"); + } catch (error) { + // Ignore cleanup errors + } + }); + + const checkRecordCountByIdInAnotherTransaction = async ( + id: number, + expected: number + ): Promise => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + const statement = await connection.execute( + `SELECT COUNT(*) FROM transaction_test WHERE id = ${id}` ); + const { data } = await statement.fetchResult(); + const count = parseInt(data[0][0]); + expect(count).toBe(expected); + }; + + it("should commit transaction", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.execute("BEGIN TRANSACTION"); + await connection.execute("INSERT INTO transaction_test VALUES (1, 'test')"); + + await checkRecordCountByIdInAnotherTransaction(1, 0); + + await connection.execute("COMMIT"); + + await checkRecordCountByIdInAnotherTransaction(1, 1); }); - it("commit throws error", async () => { + it("should rollback transaction", async () => { const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.execute("BEGIN TRANSACTION"); + await connection.execute("INSERT INTO transaction_test VALUES (2, 'test')"); + + await checkRecordCountByIdInAnotherTransaction(2, 0); + + await connection.execute("ROLLBACK"); + await checkRecordCountByIdInAnotherTransaction(2, 0); + }); + + it("should commit transaction using transaction control methods", async () => { + const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await expect(connection.commit()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" - ); + await connection.begin(); + + await connection.execute("INSERT INTO transaction_test VALUES (3, 'test')"); + + await checkRecordCountByIdInAnotherTransaction(3, 0); + + await connection.commit(); + + await checkRecordCountByIdInAnotherTransaction(3, 1); + }); + + it("should rollback transaction using transaction control methods", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.begin(); + + await connection.execute("INSERT INTO transaction_test VALUES (4, 'test')"); + + await checkRecordCountByIdInAnotherTransaction(4, 0); + + await connection.rollback(); + + await checkRecordCountByIdInAnotherTransaction(4, 0); }); - it("rollback throws error", async () => { + it("should handle sequential transactions", async () => { const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + // First transaction + await connection.begin(); + await connection.execute("INSERT INTO transaction_test VALUES (5, 'test')"); + await checkRecordCountByIdInAnotherTransaction(5, 0); + await connection.commit(); + + await checkRecordCountByIdInAnotherTransaction(5, 1); + + // Second transaction + await connection.begin(); + await connection.execute("INSERT INTO transaction_test VALUES (6, 'test')"); + await checkRecordCountByIdInAnotherTransaction(6, 0); + await connection.commit(); + + await checkRecordCountByIdInAnotherTransaction(5, 1); + await checkRecordCountByIdInAnotherTransaction(6, 1); + }); + it("should work with prepared statements", async () => { + const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await expect(connection.rollback()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" + await connection.begin(); + + await connection.execute( + "INSERT INTO transaction_test VALUES (?, 'test')", + { + parameters: [7] + } ); + + await checkRecordCountByIdInAnotherTransaction(7, 0); + + await connection.commit(); + await checkRecordCountByIdInAnotherTransaction(7, 1); + }); + + it("should handle multi-statement transaction", async () => { + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.begin(); + + // Multiple statements within the same transaction + await connection.execute("INSERT INTO transaction_test VALUES (8, 'first')"); + await connection.execute("INSERT INTO transaction_test VALUES (9, 'second')"); + await connection.execute("INSERT INTO transaction_test VALUES (10, 'third')"); + + // Verify none are visible yet + await checkRecordCountByIdInAnotherTransaction(8, 0); + await checkRecordCountByIdInAnotherTransaction(9, 0); + await checkRecordCountByIdInAnotherTransaction(10, 0); + + await connection.commit(); + + // Verify all are visible after commit + await checkRecordCountByIdInAnotherTransaction(8, 1); + await checkRecordCountByIdInAnotherTransaction(9, 1); + await checkRecordCountByIdInAnotherTransaction(10, 1); }); }); diff --git a/test/unit/core/transaction.test.ts b/test/unit/core/transaction.test.ts index 5747a2df..a207f92f 100644 --- a/test/unit/core/transaction.test.ts +++ b/test/unit/core/transaction.test.ts @@ -1,34 +1,133 @@ +import { setupServer } from "msw/node"; +import { rest } from "msw"; import { Firebolt, FireboltCore } from "../../../src"; import { ConnectionOptions } from "../../../src/types"; +const engineEndpoint = "http://localhost:3473"; + +const queryResponse = { + meta: [ + { + name: "result", + type: "Int64" + } + ], + data: [[1]], + rows: 1 +}; + describe("Transaction Core", () => { + const server = setupServer(); const connectionParams: ConnectionOptions = { auth: FireboltCore(), database: "test_db", - engineEndpoint: "http://fake" // Not used in these tests, but required by ConnectionOptions + engineEndpoint }; - it("begin throws error", async () => { + beforeAll(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it("executes BEGIN TRANSACTION query", async () => { + let executedQueries: string[] = []; + server.use( + rest.post(engineEndpoint, async (req, res, ctx) => { + const body = await req.text(); + executedQueries.push(body); + return res(ctx.json(queryResponse)); + }) + ); + const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await expect(connection.begin()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" - ); + await connection.begin(); + + expect(executedQueries).toContain("BEGIN TRANSACTION"); }); - it("commit throws error", async () => { + it("executes COMMIT query", async () => { + let executedQueries: string[] = []; + server.use( + rest.post(engineEndpoint, async (req, res, ctx) => { + const body = await req.text(); + executedQueries.push(body); + return res(ctx.json(queryResponse)); + }) + ); + const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await expect(connection.commit()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" + await connection.commit(); + + expect(executedQueries).toContain("COMMIT"); + }); + + it("executes ROLLBACK query", async () => { + let executedQueries: string[] = []; + server.use( + rest.post(engineEndpoint, async (req, res, ctx) => { + const body = await req.text(); + executedQueries.push(body); + return res(ctx.json(queryResponse)); + }) ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + await connection.rollback(); + + expect(executedQueries).toContain("ROLLBACK"); }); - it("rollback throws error", async () => { + it("handles full transaction lifecycle", async () => { + let executedQueries: string[] = []; + server.use( + rest.post(engineEndpoint, async (req, res, ctx) => { + const body = await req.text(); + executedQueries.push(body); + return res(ctx.json(queryResponse)); + }) + ); + const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await expect(connection.rollback()).rejects.toThrow( - "Transactions are not supported in Firebolt Core" + + await connection.begin(); + await connection.execute("INSERT INTO test_table VALUES (1)"); + await connection.commit(); + + expect(executedQueries).toContain("BEGIN TRANSACTION"); + expect(executedQueries).toContain("INSERT INTO test_table VALUES (1)"); + expect(executedQueries).toContain("COMMIT"); + }); + + it("handles rollback in transaction lifecycle", async () => { + let executedQueries: string[] = []; + server.use( + rest.post(engineEndpoint, async (req, res, ctx) => { + const body = await req.text(); + executedQueries.push(body); + return res(ctx.json(queryResponse)); + }) ); + + const firebolt = Firebolt(); + const connection = await firebolt.connect(connectionParams); + + await connection.begin(); + await connection.execute("INSERT INTO test_table VALUES (1)"); + await connection.rollback(); + + expect(executedQueries).toContain("BEGIN TRANSACTION"); + expect(executedQueries).toContain("INSERT INTO test_table VALUES (1)"); + expect(executedQueries).toContain("ROLLBACK"); }); }); From 5490600792eb4bb75d37ff87155e97216c4745a8 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sat, 20 Dec 2025 23:45:20 +0100 Subject: [PATCH 12/18] Address further review comments --- .cursor/rules/architecture/RULE.md | 2 +- README.md | 1 - test/integration/core/connection.test.ts | 28 +++++++++++++------- test/integration/core/transaction.test.ts | 31 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.cursor/rules/architecture/RULE.md b/.cursor/rules/architecture/RULE.md index f59147e2..4999c204 100644 --- a/.cursor/rules/architecture/RULE.md +++ b/.cursor/rules/architecture/RULE.md @@ -57,7 +57,7 @@ Use `CompositeError` for multiple errors. Custom errors: `AccountNotFoundError`, ## Important Details - **Prepared statements**: `native` (default, client-side `?`/`:name`) vs `fb_numeric` (server-side `$1`/`$2`) -- **Transactions**: `begin()`, `commit()`, `rollback()` on Connection (state per connection). **Not supported in Core** - methods throw errors. +- **Transactions**: `begin()`, `commit()`, `rollback()` on Connection (state per connection). - **Async queries**: `async: true` setting → token for `isAsyncQueryRunning()`, `isAsyncQuerySuccessful()`, `cancelAsyncQuery()`. **Not supported in Core** - methods throw errors. - **ResourceManager**: Available for V1/V2 managed connections. **Not available in Core** - accessing `resourceManager` throws error. - **Result hydration**: SQL types → JS types (dates, BigNumber for large ints, normalization) diff --git a/README.md b/README.md index 733c15de..3d0aa0b3 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,6 @@ const connection = await firebolt.connect({ - `engineEndpoint` is required (must be specified) - Resource management (`resourceManager`) is not available - Async queries (`executeAsync`) are not supported -- Transactions (`begin`, `commit`, `rollback`) are not supported - Streaming queries (`executeStream`) are supported diff --git a/test/integration/core/connection.test.ts b/test/integration/core/connection.test.ts index 199376ed..51a8c125 100644 --- a/test/integration/core/connection.test.ts +++ b/test/integration/core/connection.test.ts @@ -31,19 +31,29 @@ describe("connection management", () => { const connection1 = await firebolt.connect(connectionParams); const connection2 = await firebolt.connect(connectionParams); - // Set different settings on each connection - await connection1.execute("SET statement_timeout=5000"); - await connection2.execute("SET statement_timeout=10000"); - - // Verify each connection maintains its own session state - const statement1 = await connection1.execute("SELECT 1"); - const statement2 = await connection2.execute("SELECT 1"); + // Set different timezone settings on each connection + await connection1.execute("SET timezone = 'Europe/Berlin'"); + await connection2.execute("SET timezone = 'Europe/Bucharest'"); + + // Verify each connection maintains its own session state by checking + // that the same UTC timestamp is displayed differently based on timezone. + // Cast back to TEXT to see the time zone setting in effect server side. + // Otherwise the SDK properly coorects back to a normalized timestamp. + const statement1 = await connection1.execute( + "SELECT '2025-12-15 16:00:00+00'::timestamptz::TEXT" + ); + const statement2 = await connection2.execute( + "SELECT '2025-12-15 16:00:00+00'::timestamptz::TEXT" + ); const { data: data1 } = await statement1.fetchResult(); const { data: data2 } = await statement2.fetchResult(); - expect(data1[0][0]).toEqual(1); - expect(data2[0][0]).toEqual(1); + // Europe/Berlin is UTC+1, so 16:00:00 UTC becomes 17:00:00+01 + expect(data1[0][0]).toContain("17:00:00+01"); + + // Europe/Bucharest is UTC+2, so 16:00:00 UTC becomes 18:00:00+02 + expect(data2[0][0]).toContain("18:00:00+02"); }); it("can destroy connection", async () => { diff --git a/test/integration/core/transaction.test.ts b/test/integration/core/transaction.test.ts index 3094a164..5dd1b3a5 100644 --- a/test/integration/core/transaction.test.ts +++ b/test/integration/core/transaction.test.ts @@ -170,5 +170,36 @@ describe("transactions", () => { await checkRecordCountByIdInAnotherTransaction(9, 1); await checkRecordCountByIdInAnotherTransaction(10, 1); }); + + it("should not allow concurrent write transactions", async () => { + const firebolt = Firebolt(); + const connection1 = await firebolt.connect(connectionParams); + const connection2 = await firebolt.connect(connectionParams); + + // Start first transaction with a write operation + await connection1.begin(); + await connection1.execute("INSERT INTO transaction_test VALUES (11, 'first')"); + + // Attempt to start a second concurrent write transaction + // Core does not support multiple concurrent write transactions, so this should fail + // The error may occur when trying to begin() or when trying to execute the write operation + let secondTransactionFailed = false; + try { + await connection2.begin(); + await connection2.execute("INSERT INTO transaction_test VALUES (12, 'second')"); + } catch (error) { + secondTransactionFailed = true; + // Verify that the second transaction did not succeed + await checkRecordCountByIdInAnotherTransaction(12, 0); + } + + expect(secondTransactionFailed).toBe(true); + + // Clean up: rollback the first transaction + await connection1.rollback(); + + // Verify the first transaction's data was rolled back + await checkRecordCountByIdInAnotherTransaction(11, 0); + }); }); From 980fe22a019439e82946de8a78ad7d41ab68e356 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 00:04:54 +0100 Subject: [PATCH 13/18] Clean up code --- src/client/index.ts | 17 ++++++---------- src/common/auth.ts | 45 +++++++++++++++++++++++++++++++++++++++++ src/connection/index.ts | 28 +++++++++---------------- src/core/index.ts | 27 ++++++++++--------------- src/index.ts | 3 ++- 5 files changed, 73 insertions(+), 47 deletions(-) create mode 100644 src/common/auth.ts diff --git a/src/client/index.ts b/src/client/index.ts index 1d74005c..8fc4bca7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -4,11 +4,11 @@ import { CoreAuthenticator } from "../auth/core"; import { Context, ConnectionOptions, - FireboltClientOptions, - FireboltCoreAuth + FireboltClientOptions } from "../types"; import { ResourceManager } from "../service"; import { NodeHttpClient } from "../http/node"; +import { isFireboltCoreAuth } from "../common/auth"; export class FireboltClient { private options: FireboltClientOptions; @@ -47,11 +47,9 @@ export class FireboltClient { }; // Use CoreAuthenticator for Core, regular Authenticator for managed Firebolt - const auth = - "type" in connectionOptions.auth && - (connectionOptions.auth as FireboltCoreAuth).type === "firebolt-core" - ? new CoreAuthenticator(connectionContext, connectionOptions) - : new Authenticator(connectionContext, connectionOptions); + const auth = isFireboltCoreAuth(connectionOptions.auth) + ? new CoreAuthenticator(connectionContext, connectionOptions) + : new Authenticator(connectionContext, connectionOptions); const connection = makeConnection(connectionContext, connectionOptions); await auth.authenticate(); @@ -66,10 +64,7 @@ export class FireboltClient { ); // Only create ResourceManager for managed Firebolt (not Core) - if ( - !("type" in connectionOptions.auth) || - (connectionOptions.auth as FireboltCoreAuth).type !== "firebolt-core" - ) { + if (!isFireboltCoreAuth(connectionOptions.auth)) { const resourceContext = { connection, ...connectionContext diff --git a/src/common/auth.ts b/src/common/auth.ts new file mode 100644 index 00000000..e0412c27 --- /dev/null +++ b/src/common/auth.ts @@ -0,0 +1,45 @@ +import { + AuthOptions, + FireboltCoreAuth, + ServiceAccountAuth, + UsernamePasswordAuth +} from "../types"; + +/** + * Type guard to check if auth is FireboltCoreAuth + */ +export function isFireboltCoreAuth( + auth: AuthOptions +): auth is FireboltCoreAuth { + return ( + "type" in auth && + (auth as FireboltCoreAuth).type === "firebolt-core" + ); +} + +/** + * Type guard to check if auth is ServiceAccountAuth + */ +export function isServiceAccountAuth( + auth: AuthOptions +): auth is ServiceAccountAuth { + const serviceAuth = auth as ServiceAccountAuth; + return !!( + serviceAuth.client_id && + serviceAuth.client_secret + ); +} + +/** + * Type guard to check if auth is UsernamePasswordAuth + */ +export function isUsernamePasswordAuth( + auth: AuthOptions +): auth is UsernamePasswordAuth { + const usernameAuth = auth as UsernamePasswordAuth; + return !!( + usernameAuth.username && + usernameAuth.password + ); +} + diff --git a/src/connection/index.ts b/src/connection/index.ts index e3ebcee7..4895fcd2 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -1,35 +1,25 @@ -import { - Context, - ConnectionOptions, - ServiceAccountAuth, - UsernamePasswordAuth, - FireboltCoreAuth -} from "../types"; +import { Context, ConnectionOptions } from "../types"; import { ConnectionV1 } from "./connection_v1"; import { ConnectionV2 } from "./connection_v2"; import { ConnectionCore } from "./connection_core"; import { QueryFormatterV1 } from "../formatter/formatter_v1"; import { QueryFormatterV2 } from "../formatter/formatter_v2"; +import { + isFireboltCoreAuth, + isServiceAccountAuth, + isUsernamePasswordAuth +} from "../common/auth"; export type { Connection } from "./base"; export function makeConnection(context: Context, options: ConnectionOptions) { - if ( - "type" in options.auth && - (options.auth as FireboltCoreAuth).type === "firebolt-core" - ) { + if (isFireboltCoreAuth(options.auth)) { const queryFormatter = new QueryFormatterV2(); return new ConnectionCore(queryFormatter, context, options); - } else if ( - (options.auth as ServiceAccountAuth).client_id && - (options.auth as ServiceAccountAuth).client_secret - ) { + } else if (isServiceAccountAuth(options.auth)) { const queryFormatter = new QueryFormatterV2(); return new ConnectionV2(queryFormatter, context, options); - } else if ( - (options.auth as UsernamePasswordAuth).username && - (options.auth as UsernamePasswordAuth).password - ) { + } else if (isUsernamePasswordAuth(options.auth)) { const queryFormatter = new QueryFormatterV1(); return new ConnectionV1(queryFormatter, context, options); } diff --git a/src/core/index.ts b/src/core/index.ts index 7517f6e6..b007d3e3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,11 +4,11 @@ import { CoreAuthenticator } from "../auth/core"; import { Context, ConnectionOptions, - FireboltClientOptions, - FireboltCoreAuth + FireboltClientOptions } from "../types"; import { ResourceManager } from "../service"; import { NodeHttpClient } from "../http/node"; +import { isFireboltCoreAuth } from "../common/auth"; export class FireboltClient { private options: FireboltClientOptions; @@ -32,11 +32,9 @@ export class FireboltClient { }; // Use CoreAuthenticator for Core, regular Authenticator for managed Firebolt - const auth = - "type" in connectionOptions.auth && - (connectionOptions.auth as FireboltCoreAuth).type === "firebolt-core" - ? new CoreAuthenticator(connectionContext, connectionOptions) - : new Authenticator(connectionContext, connectionOptions); + const auth = isFireboltCoreAuth(connectionOptions.auth) + ? new CoreAuthenticator(connectionContext, connectionOptions) + : new Authenticator(connectionContext, connectionOptions); const connection = makeConnection(connectionContext, connectionOptions); await auth.authenticate(); @@ -51,15 +49,12 @@ export class FireboltClient { ); // Only create ResourceManager for managed Firebolt (not Core) - if ( - !("type" in connectionOptions.auth) || - (connectionOptions.auth as FireboltCoreAuth).type !== "firebolt-core" - ) { - const resourceContext = { - connection, - ...connectionContext - }; - this.resourceManager = new ResourceManager(resourceContext); + if (!isFireboltCoreAuth(connectionOptions.auth)) { + const resourceContext = { + connection, + ...connectionContext + }; + this.resourceManager = new ResourceManager(resourceContext); } return connection; diff --git a/src/index.ts b/src/index.ts index 44a160c9..0d1a89b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { FireboltClient, ResourceClient } from "./firebolt"; import { NodeHttpClient } from "./http/node"; import { Logger } from "./logger/node"; +import type { FireboltCoreAuth } from "./types"; export const Firebolt = FireboltClient({ logger: Logger, @@ -28,7 +29,7 @@ export type { FireboltCoreAuth } from "./types"; -export const FireboltCore = (): import("./types").FireboltCoreAuth => ({ +export const FireboltCore = (): FireboltCoreAuth => ({ type: "firebolt-core" }); From 54fd398929dea749ed4d8eb085ce57c091a300c6 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 00:08:23 +0100 Subject: [PATCH 14/18] Clean up settings test --- test/integration/core/setStatement.test.ts | 43 +++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/test/integration/core/setStatement.test.ts b/test/integration/core/setStatement.test.ts index eb4dc723..f3366631 100644 --- a/test/integration/core/setStatement.test.ts +++ b/test/integration/core/setStatement.test.ts @@ -13,37 +13,54 @@ describe("SET statements", () => { const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - // SET statements don't return data, just verify they execute without error - // Use a setting that Core supports (statement_timeout is commonly supported) - await connection.execute("SET statement_timeout=10000"); - const statement = await connection.execute("SELECT 1"); + // SET timezone and verify it affects subsequent queries + await connection.execute("SET timezone = 'Europe/Berlin'"); + + // Verify the timezone setting is applied by checking timestamp conversion + const statement = await connection.execute( + "SELECT '2025-12-15 16:00:00+00'::timestamptz::TEXT" + ); const { data } = await statement.fetchResult(); - expect(data[0][0]).toEqual(1); + // Europe/Berlin is UTC+1, so 16:00:00 UTC becomes 17:00:00+01 + expect(data[0][0]).toContain("17:00:00+01"); }); it("SET statement affects subsequent queries", async () => { const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - // Use a setting that Core supports - await connection.execute("SET statement_timeout=5000"); - const statement = await connection.execute("SELECT 1"); + // Set timezone to Europe/Bucharest + await connection.execute("SET timezone = 'Europe/Bucharest'"); + + // Verify the timezone setting affects the query result + const statement = await connection.execute( + "SELECT '2025-12-15 16:00:00+00'::timestamptz::TEXT" + ); const { data } = await statement.fetchResult(); - expect(data[0][0]).toEqual(1); + // Europe/Bucharest is UTC+2, so 16:00:00 UTC becomes 18:00:00+02 + expect(data[0][0]).toContain("18:00:00+02"); }); it("handles multiple SET statements", async () => { const firebolt = Firebolt(); const connection = await firebolt.connect(connectionParams); - await connection.execute("SET statement_timeout=10000"); - await connection.execute("SET max_threads=1"); - const statement = await connection.execute("SELECT 1"); + // Set multiple settings + await connection.execute("SET timezone = 'Europe/Berlin'"); + await connection.execute("SET max_result_rows=5"); + + // Verify the timezone setting is still applied after multiple SET statements + const statement = await connection.execute( + "SELECT '2025-12-15 16:00:00+00'::timestamptz::TEXT FROM generate_series(1, 10)" + ); const { data } = await statement.fetchResult(); - expect(data[0][0]).toEqual(1); + // Europe/Berlin is UTC+1, so 16:00:00 UTC becomes 17:00:00+01 + expect(data[0][0]).toContain("17:00:00+01"); + // Ensure that there are only five result rows + expect(data.length).toBe(5); }); }); From 8f55328710e02e46ab72a34205fa00adddc92ca8 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 00:35:28 +0100 Subject: [PATCH 15/18] Make github actions more composable --- .../run-core-integration-tests/action.yml | 33 +++++++++++++++++++ .github/workflows/integration-tests-core.yaml | 26 ++++----------- .github/workflows/nightly.yaml | 19 +---------- 3 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 .github/actions/run-core-integration-tests/action.yml diff --git a/.github/actions/run-core-integration-tests/action.yml b/.github/actions/run-core-integration-tests/action.yml new file mode 100644 index 00000000..626690ff --- /dev/null +++ b/.github/actions/run-core-integration-tests/action.yml @@ -0,0 +1,33 @@ +name: 'Run Core Integration Tests' +description: 'Setup, run, and teardown Firebolt Core integration tests' +inputs: + database: + description: 'Database name for Firebolt Core' + required: false + default: 'firebolt' + endpoint: + description: 'Firebolt Core endpoint' + required: false + default: 'http://127.0.0.1:3473' +runs: + using: 'composite' + steps: + - name: Setup Firebolt Core + id: setup-core + uses: ./.github/actions/setup-firebolt-core + with: + database: ${{ inputs.database }} + endpoint: ${{ inputs.endpoint }} + + - name: Run Core integration tests + shell: bash + env: + FIREBOLT_DATABASE: ${{ steps.setup-core.outputs.database }} + FIREBOLT_CORE_ENDPOINT: ${{ steps.setup-core.outputs.endpoint }} + run: | + npm run test:ci integration/core + + - name: Teardown Firebolt Core + if: always() + uses: ./.github/actions/teardown-firebolt-core + diff --git a/.github/workflows/integration-tests-core.yaml b/.github/workflows/integration-tests-core.yaml index 3bcd8fd3..c31bbf30 100644 --- a/.github/workflows/integration-tests-core.yaml +++ b/.github/workflows/integration-tests-core.yaml @@ -9,7 +9,7 @@ on: type: string jobs: - tests: + core-tests: runs-on: ubuntu-latest steps: - name: Check out code @@ -23,29 +23,18 @@ jobs: - name: Install dependencies run: npm install - - name: Setup Firebolt Core - id: setup-core - uses: ./.github/actions/setup-firebolt-core - with: - database: "firebolt" - endpoint: "http://127.0.0.1:3473" - - name: Run Core integration tests - env: - FIREBOLT_DATABASE: ${{ steps.setup-core.outputs.database }} - FIREBOLT_CORE_ENDPOINT: ${{ steps.setup-core.outputs.endpoint }} - run: | - npm run test:ci integration/core - - - name: Teardown Firebolt Core - if: always() - uses: ./.github/actions/teardown-firebolt-core + uses: ./.github/actions/run-core-integration-tests + allure-report: + needs: core-tests + runs-on: ubuntu-latest + if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' + steps: # Need to pull the pages branch in order to fetch the previous runs # Only run Allure reporting on the main repository, not forks - name: Get Allure history uses: actions/checkout@v4 - if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' continue-on-error: true with: ref: gh-pages @@ -53,7 +42,6 @@ jobs: - name: Allure Report uses: firebolt-db/action-allure-report@v1 - if: always() && github.repository == 'firebolt-db/firebolt-node-sdk' with: github-key: ${{ inputs.token || secrets.GITHUB_TOKEN }} test-type: integration-core diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 38d8fd5e..6a7093dc 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -45,29 +45,12 @@ jobs: run: | npm run test:ci integration/v2 - - name: Setup Firebolt Core - if: matrix.os == 'ubuntu-latest' - id: setup-core - uses: ./.github/actions/setup-firebolt-core - with: - database: "firebolt" - endpoint: "http://127.0.0.1:3473" - - name: Run Core integration tests if: matrix.os == 'ubuntu-latest' - env: - FIREBOLT_DATABASE: ${{ steps.setup-core.outputs.database }} - FIREBOLT_CORE_ENDPOINT: ${{ steps.setup-core.outputs.endpoint }} - run: | - npm run test:ci integration/core - - - name: Teardown Firebolt Core - if: matrix.os == 'ubuntu-latest' - uses: ./.github/actions/teardown-firebolt-core + uses: ./.github/actions/run-core-integration-tests - name: Slack Notify of failure if: failure() - id: slack uses: firebolt-db/action-slack-nightly-notify@v1 with: os: ${{ matrix.os }} From 5aa54cba8ea9d89baf9ae86288c9bfa4d3cbc7e2 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 01:00:08 +0100 Subject: [PATCH 16/18] Fix abort handling --- src/http/node.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/http/node.ts b/src/http/node.ts index b59b65a4..eb96f9cf 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -247,7 +247,11 @@ export class NodeHttpClient { req.on("error", error => { const nodeError = error as NodeJS.ErrnoException; - const errorMessage = error.message || `Connection failed: ${nodeError.code || "unknown error"}`; + // Check if this error was caused by a user-initiated abort + const isAborted = controller.signal.aborted; + const errorMessage = isAborted + ? "The user aborted a request." + : error.message || `Connection failed: ${nodeError.code || "unknown error"}`; reject( new ApiError({ message: errorMessage, From d41335d76242400285c58780d37c59d3f3ffadbd Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 12:57:17 +0100 Subject: [PATCH 17/18] Get back to original HTTP complexity --- src/http/node.ts | 121 ++--------------------------------------------- 1 file changed, 5 insertions(+), 116 deletions(-) diff --git a/src/http/node.ts b/src/http/node.ts index eb96f9cf..88656b67 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -3,7 +3,7 @@ import http from "http"; import https from "https"; import Abort from "abort-controller"; -import { Response, Headers } from "node-fetch"; +import fetch, { Response } from "node-fetch"; import { assignProtocol, systemInfoString } from "../common/util"; import { ApiError, AuthenticationError } from "../common/errors"; import { Authenticator } from "../auth/managed"; @@ -150,17 +150,11 @@ export class NodeHttpClient { const makeRequest = async () => { const headersWithAuth = await addAuthHeaders(headers); const withProtocol = assignProtocol(url); - const urlObj = new URL(withProtocol); - const isHttp = urlObj.protocol === "http:"; - const requestModule = isHttp ? http : https; const userAgent = headersWithAuth["user-agent"] || DEFAULT_USER_AGENT; - const response = await new Promise((resolve, reject) => { - const req = requestModule.request( - { - hostname: urlObj.hostname, - port: urlObj.port || (isHttp ? 80 : 443), - path: urlObj.pathname + urlObj.search, + const response = await fetch(withProtocol, { + agent, + signal: controller.signal as any, method, headers: { "user-agent": userAgent, @@ -168,112 +162,7 @@ export class NodeHttpClient { [PROTOCOL_VERSION_HEADER]: PROTOCOL_VERSION, ...headersWithAuth }, - agent - }, - res => { - // For raw/streaming responses, don't consume the stream - pass it through - if (options?.raw) { - const response = { - status: res.statusCode || 200, - statusText: res.statusMessage || "OK", - headers: new Headers( - Object.entries(res.headers).reduce((acc, [key, value]) => { - if (value !== undefined && value !== null) { - acc[key] = Array.isArray(value) ? value.join(", ") : String(value); - } - return acc; - }, {} as Record) - ), - ok: (res.statusCode || 200) >= 200 && (res.statusCode || 200) < 300, - body: res, - text: async () => { - const chunks: Buffer[] = []; - for await (const chunk of res) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks as Uint8Array[]).toString(); - }, - json: async () => { - const chunks: Buffer[] = []; - for await (const chunk of res) { - chunks.push(Buffer.from(chunk)); - } - return JSON.parse(Buffer.concat(chunks as Uint8Array[]).toString() || "{}"); - }, - arrayBuffer: async () => { - const chunks: Buffer[] = []; - for await (const chunk of res) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks as Uint8Array[]).buffer; - } - } as unknown as Response; - - resolve(response); - return; - } - - // For non-raw responses, consume the stream and parse - (async () => { - const chunks: Buffer[] = []; - for await (const chunk of res) { - chunks.push(Buffer.from(chunk)); - } - const data = Buffer.concat(chunks as Uint8Array[]).toString(); - const buffer = Buffer.concat(chunks as Uint8Array[]); - - const response = { - status: res.statusCode || 200, - statusText: res.statusMessage || "OK", - headers: new Headers( - Object.entries(res.headers).reduce((acc, [key, value]) => { - if (value !== undefined && value !== null) { - acc[key] = Array.isArray(value) ? value.join(", ") : String(value); - } - return acc; - }, {} as Record) - ), - ok: (res.statusCode || 200) >= 200 && (res.statusCode || 200) < 300, - body: undefined, - text: async () => data, - json: async () => JSON.parse(data || "{}"), - arrayBuffer: async () => buffer.buffer - } as unknown as Response; - - resolve(response); - })(); - } - ); - - req.on("error", error => { - const nodeError = error as NodeJS.ErrnoException; - // Check if this error was caused by a user-initiated abort - const isAborted = controller.signal.aborted; - const errorMessage = isAborted - ? "The user aborted a request." - : error.message || `Connection failed: ${nodeError.code || "unknown error"}`; - reject( - new ApiError({ - message: errorMessage, - code: nodeError.code || "", - status: 0, - url: urlObj.toString() - }) - ); - }); - - if (controller.signal.aborted) { - req.destroy(); - return; - } - controller.signal.addEventListener("abort", () => req.destroy()); - - if (body) { - // Convert URLSearchParams to string if needed - const bodyString = body instanceof URLSearchParams ? body.toString() : (body as string); - req.write(bodyString); - } - req.end(); + body }); if ( From 306463db686082127799a9f7c19bc4bfd38911f1 Mon Sep 17 00:00:00 2001 From: Benjamin Wagner Date: Sun, 21 Dec 2025 13:29:27 +0100 Subject: [PATCH 18/18] Improve NodeHttpClient interfaces --- src/http/node.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/http/node.ts b/src/http/node.ts index 88656b67..70d35936 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -1,6 +1,4 @@ import AgentKeepAlive from "agentkeepalive"; -import http from "http"; -import https from "https"; import Abort from "abort-controller"; import fetch, { Response } from "node-fetch"; @@ -10,12 +8,11 @@ import { Authenticator } from "../auth/managed"; import { CoreAuthenticator } from "../auth/core"; const { HttpsAgent } = AgentKeepAlive; -type HttpAgent = typeof AgentKeepAlive; +const HttpAgent = AgentKeepAlive; const AbortController = globalThis.AbortController || Abort; -// Use public types to avoid exposing private agent types -type AgentType = http.Agent | https.Agent; +type AgentType = InstanceType | InstanceType; type RequestOptions = { headers: Record; @@ -65,7 +62,7 @@ export class NodeHttpClient { this.agentCache = new Map(); } - getAgent = (url: string): AgentType => { + private getAgent = (url: string): AgentType => { const withProtocol = assignProtocol(url); const urlObj = new URL(withProtocol); const isHttp = urlObj.protocol === "http:"; @@ -77,7 +74,7 @@ export class NodeHttpClient { } const agent = isHttp - ? new AgentKeepAlive(agentOptions) + ? new HttpAgent(agentOptions) : new HttpsAgent(agentOptions); this.agentCache.set(cacheKey, agent); return agent;