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

            Line data    Source code
       1              : import * as buf from "@bufbuild/protobuf";
       2              : import * as wkt from "@bufbuild/protobuf/wkt";
       3              : import * as pb from "../../proto.js";
       4              : 
       5              : import { LoggableException } from "../../platform/logger/logger.js";
       6              : import type { QueryErrorResponse } from "./hyperdb_http_client.js";
       7              : 
       8              : /// The gRPC "richer error model" trailer that carries the base64-encoded
       9              : /// google.rpc.Status. This is the same name the wire uses, mirrored by the
      10              : /// native proxy (see proxy_headers.rs HEADER_NAME_GRPC_STATUS_DETAILS).
      11            1 : export const HEADER_GRPC_STATUS_DETAILS = "dashql-grpc-status-details-bin";
      12              : /// The terse gRPC status message forwarded by the native proxy.
      13            1 : export const HEADER_GRPC_MESSAGE = "dashql-grpc-message";
      14              : /// The numeric gRPC status code forwarded by the native proxy.
      15            1 : export const HEADER_GRPC_STATUS = "dashql-grpc-status";
      16              : 
      17              : /// A text position into the user-provided SQL text.
      18              : /// Offsets are measured in unicode code points.
      19              : export interface HyperTextPosition {
      20              :     /// Start offset
      21              :     beginCharacterOffset: number;
      22              :     /// End offset
      23              :     endCharacterOffset: number;
      24              : }
      25              : 
      26              : /// The origin of an error, as classified by Hyper.
      27              : export type HyperErrorSource = "User" | "System" | "Unknown";
      28              : 
      29              : /// A structured, transport-independent view of Hyper's rich error model.
      30              : ///
      31              : /// Both the native gRPC path (via the `grpc-status-details-bin` trailer) and
      32              : /// the web HTTP v3 path (via the JSON error body) are decoded into this shape
      33              : /// so the rest of the app has a single error representation.
      34              : export interface HyperErrorInfo {
      35              :     /// The primary (terse) error message
      36              :     primaryMessage: string;
      37              :     /// The SQL state error code (empty means "unknown / internal error")
      38              :     sqlstate?: string;
      39              :     /// A suggestion on what to do about the problem
      40              :     customerHint?: string;
      41              :     /// Error detail with customer data classification
      42              :     customerDetail?: string;
      43              :     /// Error detail with system data classification
      44              :     systemDetail?: string;
      45              :     /// Position information pertaining to the error in the SQL text
      46              :     position?: HyperTextPosition;
      47              :     /// The cause of the error
      48              :     errorSource: HyperErrorSource;
      49              :     /// The numeric gRPC status code, if the error came from a gRPC call
      50              :     grpcStatusCode?: number;
      51              : }
      52              : 
      53            1 : function normalizeErrorSource(raw: string | undefined): HyperErrorSource {
      54            1 :     switch (raw) {
      55            1 :         case "User":
      56            1 :             return "User";
      57            0 :         case "System":
      58            0 :             return "System";
      59            1 :         default:
      60            1 :             return "Unknown";
      61            1 :     }
      62            1 : }
      63              : 
      64              : /// Convert a decoded protobuf ErrorInfo into our structured HyperErrorInfo.
      65            1 : function fromProtoErrorInfo(info: pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfo, grpcStatusCode?: number): HyperErrorInfo {
      66            1 :     const position = info.position
      67            1 :         ? {
      68            1 :             beginCharacterOffset: Number(info.position.errorBeginCharacterOffset),
      69            1 :             endCharacterOffset: Number(info.position.errorEndCharacterOffset),
      70            1 :         }
      71            1 :         : undefined;
      72            1 :     return {
      73            1 :         primaryMessage: info.primaryMessage,
      74            1 :         sqlstate: info.sqlstate || undefined,
      75            1 :         customerHint: info.customerHint || undefined,
      76            1 :         customerDetail: info.customerDetail || undefined,
      77            1 :         systemDetail: info.systemDetail || undefined,
      78            1 :         position,
      79            1 :         errorSource: normalizeErrorSource(info.errorSource),
      80            1 :         grpcStatusCode,
      81            1 :     };
      82            1 : }
      83              : 
      84              : /// Decode a base64-encoded google.rpc.Status (the `grpc-status-details-bin`
      85              : /// trailer) and unpack the first packed Hyper ErrorInfo, if any.
      86            1 : export function decodeGrpcStatusDetails(base64Details: string, grpcStatusCode?: number): HyperErrorInfo | null {
      87            1 :     let bytes: Uint8Array;
      88            1 :     try {
      89            1 :         const binary = atob(base64Details);
      90            1 :         bytes = new Uint8Array(binary.length);
      91            1 :         for (let i = 0; i < binary.length; ++i) {
      92            1 :             bytes[i] = binary.charCodeAt(i);
      93              :         }
      94            1 :     } catch {
      95            1 :         return null;
      96            1 :     }
      97              : 
      98            1 :     let status: pb.google_rpc.status.Status;
      99            1 :     try {
     100            1 :         status = buf.fromBinary(pb.google_rpc.status.StatusSchema, bytes);
     101            1 :     } catch {
     102            0 :         return null;
     103            0 :     }
     104              : 
     105              :     // Find the ErrorInfo packed into the repeated Any `details`.
     106            1 :     for (const detail of status.details) {
     107            1 :         if (wkt.anyIs(detail, pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfoSchema)) {
     108            1 :             const info = wkt.anyUnpack(detail, pb.salesforce_hyperdb_grpc_v1.error_details.ErrorInfoSchema);
     109            1 :             if (info) {
     110            1 :                 return fromProtoErrorInfo(info, grpcStatusCode);
     111              :             }
     112            1 :         }
     113              :     }
     114              : 
     115              :     // No ErrorInfo packed: fall back to the plain google.rpc.Status message.
     116            0 :     if (status.message) {
     117            0 :         return {
     118            0 :             primaryMessage: status.message,
     119            0 :             errorSource: "Unknown",
     120            0 :             grpcStatusCode: grpcStatusCode ?? status.code,
     121            0 :         };
     122              :     }
     123            0 :     return null;
     124            1 : }
     125              : 
     126              : /// Extract a HyperErrorInfo from response headers/trailers forwarded by the
     127              : /// native gRPC proxy. Returns null if no rich error data is present.
     128            1 : export function decodeGrpcErrorHeaders(headers: Headers | null | undefined): HyperErrorInfo | null {
     129            1 :     if (!headers) {
     130            1 :         return null;
     131              :     }
     132            1 :     const rawStatus = headers.get(HEADER_GRPC_STATUS);
     133            1 :     const grpcStatusCode = rawStatus != null ? Number.parseInt(rawStatus) : undefined;
     134              : 
     135            1 :     const details = headers.get(HEADER_GRPC_STATUS_DETAILS);
     136            1 :     if (details) {
     137            1 :         const decoded = decodeGrpcStatusDetails(details, grpcStatusCode);
     138            1 :         if (decoded) {
     139            1 :             return decoded;
     140              :         }
     141            1 :     }
     142              : 
     143              :     // No status-details-bin: fall back to the terse grpc-message.
     144            1 :     const message = headers.get(HEADER_GRPC_MESSAGE);
     145            1 :     if (message) {
     146            1 :         return {
     147            1 :             primaryMessage: decodeGrpcMessage(message),
     148            1 :             errorSource: "Unknown",
     149            1 :             grpcStatusCode,
     150            1 :         };
     151              :     }
     152            1 :     return null;
     153            1 : }
     154              : 
     155              : /// The proxy percent-encodes the message like the `grpc-message` trailer, a header value can't hold line breaks
     156            1 : function decodeGrpcMessage(message: string): string {
     157            1 :     try {
     158            1 :         return decodeURIComponent(message);
     159            1 :     } catch {
     160            1 :         return message;
     161            1 :     }
     162            1 : }
     163              : 
     164              : /// Flatten a HyperErrorInfo into a string key/value map for logging and for
     165              : /// the generic error-details viewer. Only non-empty fields are included, and
     166              : /// `system_detail` is included because these logs stay client-side.
     167            1 : export function hyperErrorInfoToKeyValues(info: HyperErrorInfo): Record<string, string> {
     168            1 :     const kv: Record<string, string> = {};
     169            1 :     if (info.sqlstate) kv["sqlstate"] = info.sqlstate;
     170            1 :     if (info.errorSource !== "Unknown") kv["source"] = info.errorSource;
     171            1 :     if (info.customerHint) kv["hint"] = info.customerHint;
     172            1 :     if (info.customerDetail) kv["detail"] = info.customerDetail;
     173            1 :     if (info.systemDetail) kv["systemDetail"] = info.systemDetail;
     174            1 :     if (info.grpcStatusCode != null) kv["grpcStatus"] = info.grpcStatusCode.toString();
     175            1 :     if (info.position) {
     176            1 :         kv["position"] = `${info.position.beginCharacterOffset}..${info.position.endCharacterOffset}`;
     177              :     }
     178            1 :     return kv;
     179            1 : }
     180              : 
     181              : /// An error raised for a failed Hyper query, carrying the decoded rich error
     182              : /// model. Both the native gRPC path and the web HTTP v3 path raise this so the
     183              : /// rest of the app has a single, structured error type.
     184            1 : export class HyperQueryError extends LoggableException {
     185              :     /// The decoded rich error info
     186            1 :     readonly info: HyperErrorInfo;
     187              : 
     188            1 :     constructor(info: HyperErrorInfo, target?: string) {
     189            1 :         super(info.primaryMessage, hyperErrorInfoToKeyValues(info), target);
     190            1 :         this.info = info;
     191            1 :     }
     192            1 : }
     193              : 
     194              : /// A five-character SQL state, the V3 error field holds one or a word like INTERNAL_ERROR
     195            1 : const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/;
     196              : 
     197              : /// Map the web HTTP v3 JSON error body into a HyperErrorInfo.
     198            1 : export function fromHttpErrorResponse(response: QueryErrorResponse): HyperErrorInfo {
     199            1 :     const details = response.details;
     200            1 :     const position = details?.position
     201            1 :         ? {
     202            1 :             beginCharacterOffset: Number.parseInt(details.position.errorBeginCharacterOffset),
     203            1 :             endCharacterOffset: Number.parseInt(details.position.errorEndCharacterOffset),
     204            1 :         }
     205            1 :         : undefined;
     206            1 :     return {
     207            1 :         primaryMessage: response.message || response.error,
     208            1 :         sqlstate: SQLSTATE_PATTERN.test(response.error) ? response.error : undefined,
     209            1 :         customerHint: details?.customerHint || undefined,
     210            1 :         customerDetail: details?.customerDetail || undefined,
     211            1 :         systemDetail: details?.systemDetail || undefined,
     212            1 :         position,
     213            1 :         errorSource: normalizeErrorSource(details?.errorSource),
     214            1 :     };
     215            1 : }
        

Generated by: LCOV version 2.0-1