LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper - hyperdb_http_client.ts Coverage Total Hit
Test: DashQL 915828b Lines: 95.7 % 257 246
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import { HttpClient, HttpFetchResult } from '../../platform/http/http_client.js';
       2              : import { Logger, LoggableException } from '../../platform/logger/logger.js';
       3              : 
       4            1 : const LOG_CTX = "hyperdb_http_client";
       5              : 
       6              : // ---------------------------------------------------------------------------
       7              : // Shared V3 API types
       8              : // ---------------------------------------------------------------------------
       9              : 
      10              : export type CompletionStatus =
      11              :     | "RUNNING_OR_UNSPECIFIED"
      12              :     | "FINISHED"
      13              :     | "RESULTS_PRODUCED";
      14              : 
      15              : export interface ColumnDefinition {
      16              :     name?: string;
      17              :     type?: string;
      18              :     nullable?: boolean;
      19              : }
      20              : 
      21              : export interface ExecutionStats {
      22              :     wallClockTime: number;
      23              :     rowsProcessed: number;
      24              : }
      25              : 
      26              : export interface QueryStatus {
      27              :     queryId: string;
      28              :     completionStatus: CompletionStatus;
      29              :     chunkCount: number | null;
      30              :     rowCount: number | null;
      31              :     progress: number | null;
      32              :     expirationTime: string | null;
      33              :     executionStats: ExecutionStats | null;
      34              : }
      35              : 
      36              : /// The order in which a query advances through the completion states
      37            1 : const COMPLETION_STATUS_RANK: Record<CompletionStatus, number> = {
      38            1 :     RUNNING_OR_UNSPECIFIED: 0,
      39            1 :     RESULTS_PRODUCED: 1,
      40            1 :     FINISHED: 2,
      41            1 : };
      42              : 
      43              : /// Read a number that the server may serialize as a JSON string
      44            1 : function readNumber(value: unknown): number | null {
      45            1 :     const n = typeof value === "string" ? Number(value) : value;
      46            1 :     return typeof n === "number" && Number.isFinite(n) ? n : null;
      47            1 : }
      48              : 
      49              : /// Read a query status from a status header or a status response.
      50              : /// The status response serializes chunkCount and rowCount as strings.
      51            1 : export function readQueryStatus(raw: any): QueryStatus {
      52            1 :     const stats = raw.executionStats;
      53            1 :     return {
      54            1 :         queryId: raw.queryId,
      55            1 :         completionStatus: raw.completionStatus ?? "RUNNING_OR_UNSPECIFIED",
      56            1 :         chunkCount: readNumber(raw.chunkCount),
      57            1 :         rowCount: readNumber(raw.rowCount),
      58            1 :         progress: readNumber(raw.progress),
      59            1 :         expirationTime: raw.expirationTime ?? null,
      60            1 :         executionStats: stats ? {
      61            1 :             wallClockTime: readNumber(stats.wallClockTime) ?? 0,
      62            1 :             rowsProcessed: readNumber(stats.rowsProcessed) ?? 0,
      63            1 :         } : null,
      64            1 :     };
      65            1 : }
      66              : 
      67            1 : function maxOrNull(a: number | null, b: number | null): number | null {
      68            1 :     return a == null ? b : b == null ? a : Math.max(a, b);
      69            1 : }
      70              : 
      71              : /// Merge a status into the current one.
      72              : /// Responses of parallel requests arrive in any order, an older status must not roll back a newer one.
      73            1 : export function mergeQueryStatus(current: QueryStatus, update: QueryStatus): QueryStatus {
      74            1 :     const currentRank = COMPLETION_STATUS_RANK[current.completionStatus] ?? 0;
      75            1 :     const updateRank = COMPLETION_STATUS_RANK[update.completionStatus] ?? 0;
      76            1 :     const a = current.executionStats;
      77            1 :     const b = update.executionStats;
      78            1 :     return {
      79            1 :         queryId: current.queryId,
      80            1 :         completionStatus: updateRank >= currentRank ? update.completionStatus : current.completionStatus,
      81            1 :         chunkCount: maxOrNull(current.chunkCount, update.chunkCount),
      82            1 :         rowCount: maxOrNull(current.rowCount, update.rowCount),
      83            1 :         progress: maxOrNull(current.progress, update.progress),
      84            1 :         expirationTime: update.expirationTime ?? current.expirationTime,
      85            1 :         executionStats: a && b ? {
      86            1 :             wallClockTime: Math.max(a.wallClockTime, b.wallClockTime),
      87            1 :             rowsProcessed: Math.max(a.rowsProcessed, b.rowsProcessed),
      88            1 :         } : (b ?? a),
      89            1 :     };
      90            1 : }
      91              : 
      92              : // ---------------------------------------------------------------------------
      93              : // Request types
      94              : // ---------------------------------------------------------------------------
      95              : 
      96              : export interface ExecuteQueryRequest {
      97              :     sql: string;
      98              :     transferMode?: "ADAPTIVE" | string;
      99              :     settings?: Record<string, unknown>;
     100              :     resultRange?: {
     101              :         rowLimit?: number;
     102              :         byteLimit: number;
     103              :     };
     104              :     queryRowLimit?: number;
     105              :     paramStyle?: "NAMED" | string;
     106              :     databases?: {
     107              :         path: string;
     108              :         alias?: string;
     109              :     }[];
     110              : }
     111              : 
     112              : export interface GetQueryStatusParams {
     113              :     queryId: string;
     114              :     waitTimeMs?: number;
     115              : }
     116              : 
     117              : export interface GetQueryChunkParams {
     118              :     queryId: string;
     119              :     chunkId: number;
     120              :     omitSchema?: boolean;
     121              : }
     122              : 
     123              : export interface GetQueryRowsParams {
     124              :     queryId: string;
     125              :     offset: number;
     126              :     limit?: number;
     127              :     byteLimit?: number;
     128              :     omitSchema?: boolean;
     129              : }
     130              : 
     131              : // ---------------------------------------------------------------------------
     132              : // Optional request headers
     133              : // ---------------------------------------------------------------------------
     134              : 
     135              : export interface HyperHttpRequestHeaders {
     136              :     workload?: string;
     137              :     externalClientContext?: string;
     138              :     adaptiveTimeout?: string;
     139              : }
     140              : 
     141              : // ---------------------------------------------------------------------------
     142              : // Auth provider
     143              : // ---------------------------------------------------------------------------
     144              : 
     145              : export interface HyperHttpAuthProvider {
     146              :     getAuthHeaders(): Promise<Record<string, string>>;
     147              : }
     148              : 
     149              : // ---------------------------------------------------------------------------
     150              : // Error
     151              : // ---------------------------------------------------------------------------
     152              : 
     153              : export interface QueryErrorResponse {
     154              :     error: string;
     155              :     message: string;
     156              :     details?: {
     157              :         customerHint?: string;
     158              :         customerDetail?: string;
     159              :         systemDetail?: string;
     160              :         errorSource?: string;
     161              :         position?: {
     162              :             errorBeginCharacterOffset: string;
     163              :             errorEndCharacterOffset: string;
     164              :         };
     165              :     };
     166              : }
     167              : 
     168            1 : export class HyperHttpError extends LoggableException {
     169            1 :     public readonly httpStatus: number;
     170            1 :     public readonly errorResponse: QueryErrorResponse | null;
     171              : 
     172            1 :     constructor(httpStatus: number, errorResponse: QueryErrorResponse | null, diagnostics: Record<string, string | null> = {}) {
     173            1 :         super(errorResponse?.message || errorResponse?.error || `HTTP ${httpStatus}`, diagnostics, LOG_CTX);
     174            1 :         this.httpStatus = httpStatus;
     175            1 :         this.errorResponse = errorResponse;
     176            1 :     }
     177            1 : }
     178              : 
     179              : /// The maximum length of a raw header value in the error diagnostics
     180            1 : const MAX_DIAGNOSTIC_LENGTH = 2000;
     181              : /// How deep to search nested objects for an error
     182            1 : const MAX_ERROR_SEARCH_DEPTH = 3;
     183              : 
     184            1 : function truncate(text: string, maxLength: number): string {
     185            1 :     return text.length <= maxLength ? text : `${text.substring(0, maxLength)}...`;
     186            1 : }
     187              : 
     188            1 : function nonEmptyString(value: unknown): string | undefined {
     189            1 :     return typeof value === 'string' && value.trim() ? value : undefined;
     190            1 : }
     191              : 
     192              : /// Read a Hyper ErrorInfo serialized as JSON, in camelCase or snake_case
     193            1 : function readErrorInfo(raw: any, httpStatus: number): QueryErrorResponse | null {
     194            1 :     const primaryMessage = nonEmptyString(raw.primaryMessage ?? raw.primary_message);
     195            1 :     if (!primaryMessage) return null;
     196            1 :     const position = raw.position;
     197            1 :     const begin = position?.errorBeginCharacterOffset ?? position?.error_begin_character_offset;
     198            1 :     const end = position?.errorEndCharacterOffset ?? position?.error_end_character_offset;
     199            1 :     return {
     200            1 :         error: nonEmptyString(raw.sqlstate) ?? `HTTP ${httpStatus}`,
     201            1 :         message: primaryMessage,
     202            1 :         details: {
     203            1 :             customerHint: nonEmptyString(raw.customerHint ?? raw.customer_hint),
     204            1 :             customerDetail: nonEmptyString(raw.customerDetail ?? raw.customer_detail),
     205            1 :             systemDetail: nonEmptyString(raw.systemDetail ?? raw.system_detail),
     206            1 :             errorSource: nonEmptyString(raw.errorSource ?? raw.error_source),
     207            1 :             position: begin != null && end != null ? {
     208            1 :                 errorBeginCharacterOffset: String(begin),
     209            1 :                 errorEndCharacterOffset: String(end),
     210            1 :             } : undefined,
     211            1 :         },
     212            1 :     };
     213            1 : }
     214              : 
     215              : /// Read an error from a JSON error body or the JSON status header.
     216              : /// Recognizes the V3 error envelope, Salesforce error arrays, Hyper ErrorInfo objects,
     217              : /// google.rpc.Status objects with packed details, and any of these nested in other objects.
     218            1 : export function readErrorResponse(raw: unknown, httpStatus: number, depth: number = 0): QueryErrorResponse | null {
     219            1 :     if (raw == null || typeof raw !== 'object' || depth > MAX_ERROR_SEARCH_DEPTH) return null;
     220            1 :     if (Array.isArray(raw)) {
     221            1 :         for (const entry of raw) {
     222            1 :             const error = readErrorResponse(entry, httpStatus, depth + 1);
     223            1 :             if (error) return error;
     224            1 :         }
     225            0 :         return null;
     226            0 :     }
     227            1 :     const obj = raw as Record<string, any>;
     228              : 
     229              :     // Hyper ErrorInfo
     230            1 :     const errorInfo = readErrorInfo(obj, httpStatus);
     231            1 :     if (errorInfo) return errorInfo;
     232              : 
     233              :     // google.rpc.Status, prefer a packed ErrorInfo over the terse message
     234            1 :     if (Array.isArray(obj.details)) {
     235            1 :         for (const detail of obj.details) {
     236            1 :             if (detail && typeof detail === 'object') {
     237            1 :                 const info = readErrorInfo(detail, httpStatus);
     238            1 :                 if (info) return info;
     239            1 :             }
     240              :         }
     241              :     }
     242              : 
     243              :     // V3 error envelope or Salesforce error
     244            1 :     const message = nonEmptyString(obj.message);
     245            1 :     const error = nonEmptyString(obj.error);
     246            1 :     const errorCode = nonEmptyString(obj.errorCode);
     247            1 :     if (message || error || errorCode) {
     248            1 :         return {
     249            1 :             ...obj,
     250            1 :             message: message ?? '',
     251            1 :             error: error ?? errorCode ?? `HTTP ${httpStatus}`,
     252            1 :             details: obj.details != null && typeof obj.details === 'object' && !Array.isArray(obj.details) ? obj.details : undefined,
     253            1 :         } as QueryErrorResponse;
     254              :     }
     255              : 
     256              :     // An error nested in another object, e.g. the error of a query status
     257            1 :     for (const value of Object.values(obj)) {
     258            1 :         const nested = readErrorResponse(value, httpStatus, depth + 1);
     259            1 :         if (nested) return nested;
     260            1 :     }
     261            1 :     return null;
     262            1 : }
     263              : 
     264              : // ---------------------------------------------------------------------------
     265              : // Client
     266              : // ---------------------------------------------------------------------------
     267              : 
     268            1 : export class HyperDatabaseHttpClient {
     269            1 :     httpClient: HttpClient;
     270              :     /// The Hyper endpoint URL (the Salesforce Data Cloud instance URL).
     271            1 :     endpointUrl: URL;
     272            1 :     auth: HyperHttpAuthProvider;
     273            1 :     logger: Logger;
     274              : 
     275            1 :     constructor(httpClient: HttpClient, endpointUrl: URL, auth: HyperHttpAuthProvider, logger: Logger) {
     276            1 :         this.httpClient = httpClient;
     277            1 :         this.endpointUrl = endpointUrl;
     278            1 :         this.auth = auth;
     279            1 :         this.logger = logger;
     280            1 :     }
     281              : 
     282            1 :     private async buildHeaders(extra?: HyperHttpRequestHeaders): Promise<Headers> {
     283            1 :         const authHeaders = await this.auth.getAuthHeaders();
     284            1 :         const headers = new Headers({
     285            1 :             "Accept": "application/vnd.apache.arrow.stream",
     286            1 :             "Content-Type": "application/json",
     287            1 :             ...authHeaders,
     288            1 :         });
     289            1 :         if (extra?.workload) {
     290            0 :             headers.set("x-hyperdb-workload", extra.workload);
     291              :         }
     292            1 :         if (extra?.externalClientContext) {
     293            1 :             headers.set("x-hyperdb-external-client-context", extra.externalClientContext);
     294              :         }
     295            1 :         if (extra?.adaptiveTimeout) {
     296            0 :             headers.set("X-hyperdb-adaptive-timeout", extra.adaptiveTimeout);
     297              :         }
     298            1 :         return headers;
     299            1 :     }
     300              : 
     301            1 :     private parseStatusHeader(response: HttpFetchResult): QueryStatus | null {
     302            1 :         const raw = response.headers.get("x-hyperdb-status");
     303            1 :         if (!raw) return null;
     304            1 :         return readQueryStatus(JSON.parse(raw));
     305            1 :     }
     306              : 
     307            1 :     private async throwIfError(response: HttpFetchResult): Promise<void> {
     308            1 :         if (response.status >= 400) {
     309            1 :             let errorBody: QueryErrorResponse | null = null;
     310            1 :             let errorOrigin = 'none';
     311            1 :             let bodyType = 'unreadable';
     312            1 :             let bodyKeys = '';
     313            1 :             try {
     314            1 :                 const body = await response.json();
     315            1 :                 bodyType = body == null ? 'null' : Array.isArray(body) ? 'array' : typeof body;
     316            1 :                 bodyKeys = body != null && typeof body === 'object'
     317            1 :                     ? Object.keys(Array.isArray(body) ? (body[0] ?? {}) : body)
     318            1 :                         .filter(key => ['message', 'error', 'errorCode', 'details', 'detail'].includes(key)).join(',')
     319            0 :                     : '';
     320            1 :                 errorBody = readErrorResponse(body, response.status);
     321            1 :                 if (errorBody) errorOrigin = 'body';
     322            1 :             } catch {
     323              :                 // Response body may not be valid JSON
     324            0 :             }
     325              :             // The body may be empty and the error is only in the status header
     326            1 :             const rawStatus = response.headers.get('x-hyperdb-status');
     327            1 :             if (errorBody == null && rawStatus) {
     328            1 :                 try {
     329            1 :                     errorBody = readErrorResponse(JSON.parse(rawStatus), response.status);
     330            1 :                     if (errorBody) errorOrigin = 'x-hyperdb-status';
     331            1 :                 } catch {
     332              :                     // Status header may not be valid JSON
     333            0 :                 }
     334              :             }
     335            1 :             const diagnostics = {
     336            1 :                 status: response.status.toString(),
     337            1 :                 statusText: response.statusText,
     338            1 :                 contentType: response.headers.get('content-type'),
     339            1 :                 hyperdbStatusPresent: (rawStatus != null).toString(),
     340            1 :                 hyperdbStatus: rawStatus != null ? truncate(rawStatus, MAX_DIAGNOSTIC_LENGTH) : null,
     341            1 :                 bodyType,
     342            1 :                 bodyKeys,
     343            1 :                 hyperErrorRecognized: (errorBody != null).toString(),
     344            1 :                 hyperErrorOrigin: errorOrigin,
     345            1 :             };
     346            1 :             this.logger.warn('Hyper HTTP error response', diagnostics, LOG_CTX);
     347            1 :             throw new HyperHttpError(response.status, errorBody, diagnostics);
     348            1 :         }
     349            1 :     }
     350              : 
     351              :     /// POST /api/v3/query
     352            1 :     async executeQuery(request: ExecuteQueryRequest, extra?: HyperHttpRequestHeaders, abort?: AbortSignal): Promise<{ status: QueryStatus | null; response: HttpFetchResult }> {
     353            1 :         const url = new URL("/api/v3/query", this.endpointUrl);
     354            1 :         const headers = await this.buildHeaders(extra);
     355            1 :         const response = await this.httpClient.fetch(url, {
     356            1 :             method: "POST",
     357            1 :             headers,
     358            1 :             body: JSON.stringify(request),
     359            1 :             signal: abort,
     360            1 :         });
     361            1 :         await this.throwIfError(response);
     362            1 :         const status = this.parseStatusHeader(response);
     363            1 :         return { status, response };
     364            1 :     }
     365              : 
     366              :     /// GET /api/v3/query/{queryId}
     367            1 :     async getQueryStatus(params: GetQueryStatusParams, abort?: AbortSignal): Promise<QueryStatus> {
     368            1 :         const url = new URL(`/api/v3/query/${encodeURIComponent(params.queryId)}`, this.endpointUrl);
     369            1 :         if (params.waitTimeMs !== undefined) {
     370            0 :             url.searchParams.set("waitTimeMs", params.waitTimeMs.toString());
     371              :         }
     372            1 :         const headers = await this.buildHeaders();
     373            1 :         const response = await this.httpClient.fetch(url, {
     374            1 :             method: "GET",
     375            1 :             headers,
     376            1 :             signal: abort,
     377            1 :         });
     378            1 :         await this.throwIfError(response);
     379            1 :         return readQueryStatus(await response.json());
     380            1 :     }
     381              : 
     382              :     /// DELETE /api/v3/query/{queryId}
     383            1 :     async cancelQuery(queryId: string, abort?: AbortSignal): Promise<void> {
     384            1 :         const url = new URL(`/api/v3/query/${encodeURIComponent(queryId)}`, this.endpointUrl);
     385            1 :         const headers = await this.buildHeaders();
     386            1 :         const response = await this.httpClient.fetch(url, {
     387            1 :             method: "DELETE",
     388            1 :             headers,
     389            1 :             signal: abort,
     390            1 :         });
     391            1 :         await this.throwIfError(response);
     392            1 :     }
     393              : 
     394              :     /// GET /api/v3/query/{queryId}/chunk/{chunkId}
     395            1 :     async getQueryChunk(params: GetQueryChunkParams, extra?: HyperHttpRequestHeaders, abort?: AbortSignal): Promise<{ status: QueryStatus | null; response: HttpFetchResult }> {
     396            1 :         const url = new URL(`/api/v3/query/${encodeURIComponent(params.queryId)}/chunk/${params.chunkId}`, this.endpointUrl);
     397            1 :         if (params.omitSchema) {
     398            1 :             url.searchParams.set("omitSchema", "true");
     399              :         }
     400            1 :         const headers = await this.buildHeaders(extra);
     401            1 :         const response = await this.httpClient.fetch(url, {
     402            1 :             method: "GET",
     403            1 :             headers,
     404            1 :             signal: abort,
     405            1 :         });
     406            1 :         await this.throwIfError(response);
     407            1 :         const status = this.parseStatusHeader(response);
     408            1 :         return { status, response };
     409            1 :     }
     410              : 
     411              :     /// GET /api/v3/query/{queryId}/row
     412            1 :     async getQueryRows(params: GetQueryRowsParams, extra?: HyperHttpRequestHeaders, abort?: AbortSignal): Promise<HttpFetchResult> {
     413            1 :         const url = new URL(`/api/v3/query/${encodeURIComponent(params.queryId)}/row`, this.endpointUrl);
     414            1 :         url.searchParams.set("offset", params.offset.toString());
     415            1 :         if (params.limit !== undefined) {
     416            0 :             url.searchParams.set("limit", params.limit.toString());
     417              :         }
     418            1 :         if (params.byteLimit !== undefined) {
     419            0 :             url.searchParams.set("byteLimit", params.byteLimit.toString());
     420              :         }
     421            1 :         if (params.omitSchema) {
     422            0 :             url.searchParams.set("omitSchema", "true");
     423              :         }
     424            1 :         const headers = await this.buildHeaders(extra);
     425            1 :         const response = await this.httpClient.fetch(url, {
     426            1 :             method: "GET",
     427            1 :             headers,
     428            1 :             signal: abort,
     429            1 :         });
     430            1 :         await this.throwIfError(response);
     431            1 :         return response;
     432            1 :     }
     433            1 : }
        

Generated by: LCOV version 2.0-1