312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
|
* Utility functions for consistent error handling across the application.
|
|
*/
|
|
|
|
export type BackendErrorParam = string | number | boolean;
|
|
|
|
/**
|
|
* Driver-reported SQL error position, relative to the statement text that was
|
|
* actually sent to the database. `line`/`column` are 1-based and counted in
|
|
* Unicode scalar values; `offset` is the 0-based scalar-value index.
|
|
*
|
|
* Currently only populated by the native PostgreSQL driver.
|
|
*/
|
|
export interface SqlErrorPosition {
|
|
line: number;
|
|
column: number;
|
|
offset: number;
|
|
}
|
|
|
|
export interface BackendError {
|
|
version: 1;
|
|
code: string;
|
|
messageKey: string;
|
|
messageParams: Record<string, BackendErrorParam>;
|
|
/** Compatibility provenance. New callers should prefer origin metadata. */
|
|
source: string;
|
|
operationOutcome: "not_started" | "unknown";
|
|
origin?: {
|
|
subsystem: string;
|
|
adapter: string;
|
|
driver?: string;
|
|
};
|
|
detail?: string;
|
|
diagnostics?: Record<string, unknown>;
|
|
helpUrl?: string;
|
|
errorPosition?: SqlErrorPosition;
|
|
}
|
|
|
|
export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
|
|
|
|
const MAX_FALLBACK_CHARS = 64 * 1024;
|
|
const MAX_ERROR_PARSE_DEPTH = 16;
|
|
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
|
|
// Rust-side transport suffix carrying a driver cursor position. It is stripped
|
|
// before a structured envelope is built, but metadata/catalog errors can surface
|
|
// as raw strings, so strip it here so it never reaches the UI.
|
|
const SQL_ERROR_POSITION_MARKER_PATTERN = /\nDBX_SQL_ERROR_POSITION:\d+/g;
|
|
|
|
export function sanitizeBackendErrorMessage(message: string): string {
|
|
const withoutPositionMarker = message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, "");
|
|
const markerIndex = withoutPositionMarker.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
|
|
if (markerIndex < 0) return withoutPositionMarker;
|
|
|
|
const rawData = withoutPositionMarker.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
|
|
try {
|
|
const data: unknown = JSON.parse(rawData);
|
|
if (!data || typeof data !== "object" || Array.isArray(data)) return withoutPositionMarker;
|
|
} catch {
|
|
return withoutPositionMarker;
|
|
}
|
|
|
|
return withoutPositionMarker.slice(0, markerIndex).trimEnd();
|
|
}
|
|
|
|
function isBackendError(value: unknown): value is BackendError {
|
|
if (!value || typeof value !== "object") return false;
|
|
const candidate = value as Record<string, unknown>;
|
|
if (
|
|
candidate.version !== 1 ||
|
|
typeof candidate.code !== "string" ||
|
|
!/^DBX-[A-Z][A-Z0-9]*-\d{4}$/.test(candidate.code) ||
|
|
typeof candidate.messageKey !== "string" ||
|
|
!candidate.messageKey.startsWith("backendErrors.") ||
|
|
!candidate.messageParams ||
|
|
typeof candidate.messageParams !== "object" ||
|
|
Array.isArray(candidate.messageParams) ||
|
|
typeof candidate.source !== "string" ||
|
|
candidate.source.length === 0 ||
|
|
candidate.source.length > 64 ||
|
|
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
|
|
) {
|
|
return false;
|
|
}
|
|
if (candidate.origin !== undefined) {
|
|
const origin = candidate.origin;
|
|
const originRecord = origin as Record<string, unknown>;
|
|
if (
|
|
!origin ||
|
|
typeof origin !== "object" ||
|
|
Array.isArray(origin) ||
|
|
typeof originRecord.subsystem !== "string" ||
|
|
typeof originRecord.adapter !== "string" ||
|
|
originRecord.subsystem.length > 64 ||
|
|
originRecord.adapter.length > 64 ||
|
|
(originRecord.driver !== undefined && (typeof originRecord.driver !== "string" || originRecord.driver.length > 64))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
|
|
// Optional driver-reported position. Malformed values are rejected so a
|
|
// corrupted envelope never drives a wrong editor jump, while a missing field
|
|
// stays valid (all non-PostgreSQL errors and older backends).
|
|
if (candidate.errorPosition !== undefined) {
|
|
if (!isValidErrorPosition(candidate.errorPosition)) return false;
|
|
}
|
|
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
|
|
}
|
|
|
|
function isValidErrorPosition(value: unknown): value is SqlErrorPosition {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const { line, column, offset } = value as Record<string, unknown>;
|
|
return isPositiveInt(line) && isPositiveInt(column) && isNonNegativeInt(offset);
|
|
}
|
|
|
|
function isPositiveInt(value: unknown): boolean {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
}
|
|
|
|
function isNonNegativeInt(value: unknown): boolean {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
}
|
|
|
|
export function normalizeBackendError(error: unknown): BackendError | null {
|
|
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
|
|
}
|
|
|
|
export function isManualTransactionSessionExpired(error: unknown): boolean {
|
|
if (normalizeBackendError(error)?.code === MANUAL_TRANSACTION_SESSION_EXPIRED_CODE) return true;
|
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
return message?.startsWith("Transaction session not found or expired;") === true || message === "Transaction was auto-rolled back due to 5 minutes of inactivity";
|
|
}
|
|
|
|
export function isUnsupportedManualTransactionMethod(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
if (!message) return false;
|
|
const normalized = message.toLowerCase();
|
|
return normalized.includes("begin_manual_transaction") && (normalized.includes("unknown method") || normalized.includes("method not found"));
|
|
}
|
|
|
|
function normalizeBackendErrorAtDepth(error: unknown, seen: WeakSet<object>, depth: number): BackendError | null {
|
|
if (depth > MAX_ERROR_PARSE_DEPTH) return null;
|
|
|
|
if (error && typeof error === "object") {
|
|
if (seen.has(error)) return null;
|
|
seen.add(error);
|
|
|
|
if ("name" in error && error.name === "BackendErrorException" && "backendError" in error) {
|
|
const normalized = normalizeBackendErrorAtDepth((error as { backendError: unknown }).backendError, seen, depth + 1);
|
|
if (normalized) return normalized;
|
|
}
|
|
}
|
|
if (typeof error === "string") {
|
|
try {
|
|
return normalizeBackendErrorAtDepth(JSON.parse(error), seen, depth + 1);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (isBackendError(error)) return error;
|
|
if (error && typeof error === "object" && "backendError" in error) {
|
|
const backendError = (error as { backendError: unknown }).backendError;
|
|
const normalized = normalizeBackendErrorAtDepth(backendError, seen, depth + 1);
|
|
if (normalized) return normalized;
|
|
}
|
|
if (error && typeof error === "object" && "error" in error) {
|
|
const nested = (error as { error: unknown }).error;
|
|
const normalized = normalizeBackendErrorAtDepth(nested, seen, depth + 1);
|
|
if (normalized) return normalized;
|
|
}
|
|
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
|
|
try {
|
|
const parsed: unknown = JSON.parse(error.message);
|
|
const normalized = normalizeBackendErrorAtDepth(parsed, seen, depth + 1);
|
|
if (normalized) return normalized;
|
|
} catch {
|
|
// Keep checking compatibility wrappers before falling back to plain text.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const GENERIC_TRANSPORT_FAILURE_MESSAGE = "Backend request failed";
|
|
|
|
/**
|
|
* Marks a backend message the UI could not classify, so it is carried as raw text inside a
|
|
* structured envelope. Callers may still match that text against the known-message catalog.
|
|
*/
|
|
export const LEGACY_BACKEND_ERROR_CODE = "DBX-LEGACY-0001";
|
|
|
|
export class BackendErrorException extends Error {
|
|
readonly backendError: BackendError;
|
|
|
|
constructor(error: unknown) {
|
|
const backendError = normalizeRawBackendError(error);
|
|
const fallbackDetail = boundedFallbackText(error);
|
|
const fallbackMessage = sanitizeBackendErrorMessage(fallbackDetail ?? GENERIC_TRANSPORT_FAILURE_MESSAGE);
|
|
super(backendError?.detail ? sanitizeBackendErrorMessage(backendError.detail) : fallbackMessage);
|
|
this.name = "BackendErrorException";
|
|
this.backendError = backendError ?? {
|
|
version: 1,
|
|
code: LEGACY_BACKEND_ERROR_CODE,
|
|
messageKey: "backendErrors.legacy",
|
|
messageParams: {},
|
|
source: "legacyBackend",
|
|
operationOutcome: "unknown",
|
|
origin: { subsystem: "backend", adapter: "legacy" },
|
|
...(fallbackDetail ? { detail: sanitizeBackendErrorMessage(fallbackDetail) } : {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
function normalizeRawBackendError(error: unknown): BackendError | null {
|
|
return normalizeBackendError(error);
|
|
}
|
|
|
|
function boundedFallbackText(error: unknown): string | undefined {
|
|
return boundedFallbackTextAtDepth(error, new WeakSet<object>(), 0);
|
|
}
|
|
|
|
function boundedFallbackTextAtDepth(error: unknown, seen: WeakSet<object>, depth: number): string | undefined {
|
|
if (depth > MAX_ERROR_PARSE_DEPTH) return undefined;
|
|
|
|
let text: string | undefined;
|
|
if (typeof error === "string") {
|
|
text = error;
|
|
} else if (error instanceof Error) {
|
|
text = error.message;
|
|
} else if (error && typeof error === "object") {
|
|
if (seen.has(error)) return undefined;
|
|
seen.add(error);
|
|
const candidate = error as Record<string, unknown>;
|
|
for (const key of ["message", "reason", "detail"]) {
|
|
if (typeof candidate[key] === "string") {
|
|
text = candidate[key];
|
|
break;
|
|
}
|
|
}
|
|
if (!text && "error" in candidate) text = boundedFallbackTextAtDepth(candidate.error, seen, depth + 1);
|
|
if (!text && "backendError" in candidate) text = boundedFallbackTextAtDepth(candidate.backendError, seen, depth + 1);
|
|
}
|
|
const normalized = text?.trim();
|
|
if (!normalized) return undefined;
|
|
return Array.from(normalized).slice(0, MAX_FALLBACK_CHARS).join("");
|
|
}
|
|
|
|
/**
|
|
* Formats an unknown error value into a human-readable string.
|
|
* Handles Error objects, strings, null/undefined, and other types.
|
|
*
|
|
* @param e - The error value to format (from a catch block)
|
|
* @returns A human-readable error message string
|
|
*
|
|
* @example
|
|
* try {
|
|
* await someOperation();
|
|
* } catch (e: unknown) {
|
|
* errorMessage.value = formatError(e);
|
|
* }
|
|
*/
|
|
export function formatError(e: unknown): string {
|
|
const backendError = normalizeBackendError(e);
|
|
if (backendError?.detail) return sanitizeBackendErrorMessage(backendError.detail);
|
|
if (backendError) return backendError.code;
|
|
|
|
if (e instanceof Error) {
|
|
return sanitizeBackendErrorMessage(e.message);
|
|
}
|
|
|
|
if (typeof e === "string") {
|
|
return sanitizeBackendErrorMessage(e);
|
|
}
|
|
|
|
if (e === null || e === undefined) {
|
|
return "Unknown error occurred";
|
|
}
|
|
|
|
// Try to extract message property from object-like values
|
|
if (typeof e === "object" && "message" in e) {
|
|
const message = (e as { message: unknown }).message;
|
|
if (typeof message === "string") {
|
|
return sanitizeBackendErrorMessage(message);
|
|
}
|
|
}
|
|
|
|
// Fallback: prefer bounded nested text (message/reason/detail, .error/.backendError)
|
|
// over raw string coercion so object-shaped failures never degrade to "[object Object]".
|
|
const fallback = boundedFallbackText(e);
|
|
try {
|
|
return sanitizeBackendErrorMessage(fallback ?? String(e));
|
|
} catch {
|
|
return "Unknown error occurred";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats an error with a context prefix for better debugging.
|
|
*
|
|
* @param e - The error value to format
|
|
* @param context - The operation context (e.g., "loading topics", "creating tenant")
|
|
* @returns A formatted error message with context
|
|
*
|
|
* @example
|
|
* catch (e: unknown) {
|
|
* errorMessage.value = formatErrorWithContext(e, 'loading topics');
|
|
* }
|
|
*/
|
|
export function formatErrorWithContext(e: unknown, context: string): string {
|
|
const message = formatError(e);
|
|
return `Failed to ${context}: ${message}`;
|
|
}
|