diff --git a/.cursor/rules/architecture/RULE.md b/.cursor/rules/architecture/RULE.md new file mode 100644 index 00000000..4999c204 --- /dev/null +++ b/.cursor/rules/architecture/RULE.md @@ -0,0 +1,77 @@ +--- +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` +**Core (Self-Hosted)**: `FireboltCore()` auth → `ConnectionCore`, `QueryFormatterV2`, no ResourceManager, no async queries, no transactions + +Version selected in `makeConnection()` based on auth type. **Always support all versions** unless explicitly version-specific feature. + +## 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 + +**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 + +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()`. **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 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/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/.env.example b/.env.example index be1ff553..c591f095 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:3473" \ No newline at end of file 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/actions/setup-firebolt-core/action.yml b/.github/actions/setup-firebolt-core/action.yml new file mode 100644 index 00000000..279c6942 --- /dev/null +++ b/.github/actions/setup-firebolt-core/action.yml @@ -0,0 +1,96 @@ +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://127.0.0.1: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 + # 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 + 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=30 + 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 }}/" \ + --data-binary "SELECT 1" 2>&1) || response="" + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "200" ]; then + echo "Firebolt Core is ready!" + break + fi + echo "Waiting... ($elapsed/$timeout seconds) - HTTP $http_code" + sleep 3 + elapsed=$((elapsed + 3)) + done + + 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 IF NOT EXISTS \"${{ inputs.database }}\"" 2>&1) + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "200" ]; then + echo "Database '${{ inputs.database }}' created or already exists" + else + echo "Warning: Database creation returned HTTP $http_code" + echo "Response: $response" + exit 1 + fi + + - 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..c31bbf30 --- /dev/null +++ b/.github/workflows/integration-tests-core.yaml @@ -0,0 +1,51 @@ +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: + core-tests: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Install dependencies + run: npm install + + - name: Run Core integration tests + 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 + continue-on-error: true + with: + ref: gh-pages + path: gh-pages + + - name: Allure Report + uses: firebolt-db/action-allure-report@v1 + 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-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 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..6a7093dc 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -45,9 +45,12 @@ jobs: run: | npm run test:ci integration/v2 + - name: Run Core integration tests + if: matrix.os == 'ubuntu-latest' + 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 }} diff --git a/README.md b/README.md index 24b3ac68..3d0aa0b3 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,27 @@ 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 +- 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. 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..8fc4bca7 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,82 @@ +import { makeConnection } from "../connection"; +import { Authenticator } from "../auth/managed"; +import { CoreAuthenticator } from "../auth/core"; +import { + Context, + ConnectionOptions, + FireboltClientOptions +} from "../types"; +import { ResourceManager } from "../service"; +import { NodeHttpClient } from "../http/node"; +import { isFireboltCoreAuth } from "../common/auth"; + +export class FireboltClient { + private options: FireboltClientOptions; + private context: Context; + 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 = + 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 = isFireboltCoreAuth(connectionOptions.auth) + ? 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 (!isFireboltCoreAuth(connectionOptions.auth)) { + 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/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/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..4895fcd2 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -1,27 +1,25 @@ -import { - Context, - ConnectionOptions, - ServiceAccountAuth, - UsernamePasswordAuth -} 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 ( - (options.auth as ServiceAccountAuth).client_id && - (options.auth as ServiceAccountAuth).client_secret - ) { + if (isFireboltCoreAuth(options.auth)) { + const queryFormatter = new QueryFormatterV2(); + return new ConnectionCore(queryFormatter, context, options); + } 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 71546670..b007d3e3 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 +} from "../types"; import { ResourceManager } from "../service"; import { NodeHttpClient } from "../http/node"; +import { isFireboltCoreAuth } from "../common/auth"; -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,11 @@ export class FireboltCore { httpClient }; - const auth = new Authenticator(connectionContext, connectionOptions); + // Use CoreAuthenticator for Core, regular Authenticator for managed Firebolt + const auth = isFireboltCoreAuth(connectionOptions.auth) + ? new CoreAuthenticator(connectionContext, connectionOptions) + : new Authenticator(connectionContext, connectionOptions); + const connection = makeConnection(connectionContext, connectionOptions); await auth.authenticate(); await connection.resolveEngineEndpoint(); @@ -37,11 +47,16 @@ 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 (!isFireboltCoreAuth(connectionOptions.auth)) { + const resourceContext = { + connection, + ...connectionContext + }; + this.resourceManager = new ResourceManager(resourceContext); + } + return connection; } diff --git a/src/firebolt.ts b/src/firebolt.ts index c0db9697..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 { FireboltCore } from "./core"; +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 FireboltCore(context, options); + return new FireboltClientClass(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..70d35936 100644 --- a/src/http/node.ts +++ b/src/http/node.ts @@ -1,17 +1,22 @@ -import { HttpsAgent } from "agentkeepalive"; +import AgentKeepAlive from "agentkeepalive"; import Abort from "abort-controller"; 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 { HttpsAgent } = AgentKeepAlive; +const HttpAgent = AgentKeepAlive; const AbortController = globalThis.AbortController || Abort; +type AgentType = InstanceType | InstanceType; + type RequestOptions = { headers: Record; - body?: string; + body?: string | URLSearchParams; raw?: boolean; retry?: boolean; noAuth?: boolean; @@ -50,21 +55,28 @@ HttpsAgent.prototype.createSocket = function (req, options, cb) { }; export class NodeHttpClient { - authenticator!: Authenticator; - agentCache!: Map; + authenticator!: Authenticator | CoreAuthenticator; + 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; + private 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 HttpAgent(agentOptions) + : new HttpsAgent(agentOptions); + this.agentCache.set(cacheKey, agent); return agent; }; @@ -91,19 +103,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 => { @@ -147,7 +151,7 @@ export class NodeHttpClient { const response = await fetch(withProtocol, { agent, - signal: controller.signal as AbortSignal, + signal: controller.signal as any, method, headers: { "user-agent": userAgent, @@ -161,7 +165,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( @@ -174,11 +179,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, @@ -194,7 +197,7 @@ export class NodeHttpClient { } if (options?.raw) { - return response; + return response as unknown as T; } const parsed = await response.json(); diff --git a/src/index.ts b/src/index.ts index 2b3ac721..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, @@ -24,9 +25,14 @@ export type { QueryResponse, QuerySettings, Context, - Row + Row, + FireboltCoreAuth } from "./types"; +export const FireboltCore = (): 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"; 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..51a8c125 --- /dev/null +++ b/test/integration/core/connection.test.ts @@ -0,0 +1,73 @@ +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 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(); + + // 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 () => { + 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..f3366631 --- /dev/null +++ b/test/integration/core/setStatement.test.ts @@ -0,0 +1,66 @@ +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 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(); + + // 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); + + // 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(); + + // 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); + + // 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(); + + // 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); + }); +}); + 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..5dd1b3a5 --- /dev/null +++ b/test/integration/core/transaction.test.ts @@ -0,0 +1,205 @@ +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("transactions", () => { + 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 + ) + `); + }); + + 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("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 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("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 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); + }); + + 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); + }); +}); + 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..a207f92f --- /dev/null +++ b/test/unit/core/transaction.test.ts @@ -0,0 +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 + }; + + 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 connection.begin(); + + expect(executedQueries).toContain("BEGIN TRANSACTION"); + }); + + 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 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("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 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"); + }); +});