Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions lib/sea/SeaErrorMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import HiveDriverError from '../errors/HiveDriverError';
import AuthenticationError from '../errors/AuthenticationError';
import OperationStateError, { OperationStateErrorCode } from '../errors/OperationStateError';
import ParameterError from '../errors/ParameterError';

/**
* Shape of the kernel error surfaced by the napi-binding's `napi_err_from_kernel`.
*
* The Rust kernel's `kernel_error::Error` is exposed as a `JsError` whose
* properties mirror the Rust struct: the `ErrorCode` variant name (as a string),
* the message, and an optional SQLSTATE (either taken from the structured
* server response or recovered via `extract_sqlstate_from_message`).
*/
export interface KernelErrorShape {
/** Kernel `ErrorCode` variant name, e.g. `"Unauthenticated"`, `"SqlError"`. */
code: string;
/** Human-readable error message. */
message: string;
/** Optional SQLSTATE — five-char alphanumeric, when the kernel was able to surface it. */
sqlstate?: string;
}

/**
* Kernel `ErrorCode` variants — the 13 variants of the `#[non_exhaustive]` enum
* defined in `src/kernel_error.rs:66-134`.
*
* Kept here as a literal type rather than an `enum` so test exhaustiveness checks
* and runtime `code` strings are guaranteed to stay in lockstep with the kernel.
*/
export type KernelErrorCode =
| 'InvalidArgument'
| 'Unauthenticated'
| 'PermissionDenied'
| 'NotFound'
| 'ResourceExhausted'
| 'Unavailable'
| 'Timeout'
| 'Cancelled'
| 'DataLoss'
| 'Internal'
| 'InvalidStatementHandle'
| 'NetworkError'
| 'SqlError';

/**
* An `Error` with a preserved SQLSTATE on the `sqlState` property. Used as the
* narrowed return type of {@link mapKernelErrorToJsError} so callers that need
* the SQLSTATE can `error.sqlState` without an `any` cast.
*/
export interface ErrorWithSqlState extends Error {
sqlState?: string;
}

/**
* Attach the kernel's SQLSTATE to the JS error object via the `sqlState` property.
* The driver has no pre-existing `sqlState` convention (no other error class
* sets it today) so this single helper defines it for the SEA path.
*/
function attachSqlState(error: ErrorWithSqlState, sqlstate?: string): ErrorWithSqlState {
if (sqlstate !== undefined) {
// Using Object.defineProperty so the property is non-enumerable but still
// visible via direct access — matches the way Node attaches `.code` to system errors.
Object.defineProperty(error, 'sqlState', {
value: sqlstate,
writable: true,
enumerable: false,
configurable: true,
});
}
return error;
}

/**
* Map a kernel error (as surfaced by the napi-binding) to the appropriate JS
* driver error class.
*
* M0 mapping table:
* Unauthenticated, PermissionDenied → AuthenticationError
* Cancelled → OperationStateError(Canceled)
* Timeout → OperationStateError(Timeout)
* InvalidArgument → ParameterError
* NetworkError, Unavailable,
* NotFound, ResourceExhausted,
* DataLoss, Internal,
* InvalidStatementHandle, SqlError → HiveDriverError
*
* Unknown `code` values (e.g. if the kernel adds a new variant) fall through
* to HiveDriverError so the driver never silently drops an error. The kernel's
* `ErrorCode` is `#[non_exhaustive]` so this can legitimately happen.
*
* SQLSTATE, when present, is attached on `error.sqlState` regardless of which
* class is returned.
*/
export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlState {
const { code, message, sqlstate } = kErr;

let error: ErrorWithSqlState;

switch (code as KernelErrorCode) {
case 'Unauthenticated':
case 'PermissionDenied':
error = new AuthenticationError(message);
break;

case 'Cancelled':
// OperationStateError with the Canceled code carries the kernel message
// through the response.displayMessage fallback path.
error = new OperationStateError(OperationStateErrorCode.Canceled);
error.message = message;
break;

case 'Timeout':
error = new OperationStateError(OperationStateErrorCode.Timeout);
error.message = message;
break;

case 'InvalidArgument':
error = new ParameterError(message);
break;

// All remaining kernel ErrorCode variants map to the base driver error class.
// M0 intentionally does not introduce new error classes; M1 may add nuance.
case 'NotFound':
case 'ResourceExhausted':
case 'Unavailable':
case 'DataLoss':
case 'Internal':
case 'InvalidStatementHandle':
case 'NetworkError':
case 'SqlError':
error = new HiveDriverError(message);
break;

default:
// Unknown/future kernel variant — never drop the error, surface as base class.
error = new HiveDriverError(message);
break;
}

return attachSqlState(error, sqlstate);
}
227 changes: 227 additions & 0 deletions tests/unit/sea/error-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { expect } from 'chai';
import {
mapKernelErrorToJsError,
KernelErrorCode,
KernelErrorShape,
} from '../../../lib/sea/SeaErrorMapping';
import HiveDriverError from '../../../lib/errors/HiveDriverError';
import AuthenticationError from '../../../lib/errors/AuthenticationError';
import OperationStateError, {
OperationStateErrorCode,
} from '../../../lib/errors/OperationStateError';
import ParameterError from '../../../lib/errors/ParameterError';

describe('SeaErrorMapping.mapKernelErrorToJsError', () => {
// The 13 kernel ErrorCode variants — kept in sync with src/kernel_error.rs:66-134.
// Tabular driver: each row is (kernel code, expected class, optional extra assertion).
type Case = {
code: KernelErrorCode;
expectedClass: Function;
extra?: (err: Error) => void;
};

const cases: Array<Case> = [
{
code: 'InvalidArgument',
expectedClass: ParameterError,
},
{
code: 'Unauthenticated',
expectedClass: AuthenticationError,
},
{
code: 'PermissionDenied',
expectedClass: AuthenticationError,
},
{
code: 'NotFound',
expectedClass: HiveDriverError,
},
{
code: 'ResourceExhausted',
expectedClass: HiveDriverError,
},
{
code: 'Unavailable',
expectedClass: HiveDriverError,
},
{
code: 'Timeout',
expectedClass: OperationStateError,
extra: (err) => {
expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Timeout);
},
},
{
code: 'Cancelled',
expectedClass: OperationStateError,
extra: (err) => {
expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Canceled);
},
},
{
code: 'DataLoss',
expectedClass: HiveDriverError,
},
{
code: 'Internal',
expectedClass: HiveDriverError,
},
{
code: 'InvalidStatementHandle',
expectedClass: HiveDriverError,
},
{
code: 'NetworkError',
expectedClass: HiveDriverError,
},
{
code: 'SqlError',
expectedClass: HiveDriverError,
},
];

it('covers all 13 kernel ErrorCode variants', () => {
// Guardrail: if the kernel adds a variant, KernelErrorCode in TS will gain
// a literal — this test then fails because the new variant has no case row.
// (Drift is caught at the test level since the union itself is an inline literal.)
expect(cases).to.have.lengthOf(13);
});

cases.forEach(({ code, expectedClass, extra }) => {
it(`maps ${code} to ${expectedClass.name}`, () => {
const kErr: KernelErrorShape = {
code,
message: `kernel ${code} message`,
};

const err = mapKernelErrorToJsError(kErr);

expect(err).to.be.instanceOf(expectedClass);
expect(err.message).to.equal(`kernel ${code} message`);
if (extra) {
extra(err);
}
});
});

describe('SQLSTATE preservation', () => {
it('attaches sqlState when present on the kernel error', () => {
const err = mapKernelErrorToJsError({
code: 'SqlError',
message: 'syntax error',
sqlstate: '42000',
});

expect(err).to.be.instanceOf(HiveDriverError);
expect(err.sqlState).to.equal('42000');
});

it('does not set sqlState when absent', () => {
const err = mapKernelErrorToJsError({
code: 'Internal',
message: 'boom',
});

expect(err.sqlState).to.be.undefined;
});

it('preserves sqlState on AuthenticationError', () => {
const err = mapKernelErrorToJsError({
code: 'Unauthenticated',
message: 'invalid token',
sqlstate: '28000',
});

expect(err).to.be.instanceOf(AuthenticationError);
expect(err.sqlState).to.equal('28000');
});

it('preserves sqlState on OperationStateError', () => {
const err = mapKernelErrorToJsError({
code: 'Timeout',
message: 'deadline exceeded',
sqlstate: 'HYT01',
});

expect(err).to.be.instanceOf(OperationStateError);
expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Timeout);
expect(err.sqlState).to.equal('HYT01');
});

it('preserves sqlState on ParameterError', () => {
const err = mapKernelErrorToJsError({
code: 'InvalidArgument',
message: 'bad param',
sqlstate: 'HY009',
});

expect(err).to.be.instanceOf(ParameterError);
expect(err.sqlState).to.equal('HY009');
});

it('attaches sqlState as a non-enumerable property', () => {
const err = mapKernelErrorToJsError({
code: 'SqlError',
message: 'oops',
sqlstate: '42000',
});

const descriptor = Object.getOwnPropertyDescriptor(err, 'sqlState');
expect(descriptor).to.exist;
expect(descriptor!.enumerable).to.equal(false);
expect(descriptor!.writable).to.equal(true);
expect(descriptor!.configurable).to.equal(true);
});
});

describe('unknown / future kernel codes', () => {
it('falls back to HiveDriverError for an unrecognised code', () => {
const err = mapKernelErrorToJsError({
code: 'SomeFutureVariantThatDoesNotExist',
message: 'forward-compat message',
});

// Never silently drop — must surface as the base driver class.
expect(err).to.be.instanceOf(HiveDriverError);
expect(err.message).to.equal('forward-compat message');
});

it('still preserves sqlState on a fallback HiveDriverError', () => {
const err = mapKernelErrorToJsError({
code: 'BrandNewVariant',
message: 'with sqlstate',
sqlstate: '01004',
});

expect(err).to.be.instanceOf(HiveDriverError);
expect(err.sqlState).to.equal('01004');
});
});

describe('returned errors compose with try/catch', () => {
it('thrown errors are catchable as Error', () => {
function thrower() {
throw mapKernelErrorToJsError({ code: 'Internal', message: 'kaboom' });
}

expect(thrower).to.throw(Error, 'kaboom');
expect(thrower).to.throw(HiveDriverError, 'kaboom');
});

it('AuthenticationError thrown is also instanceOf HiveDriverError', () => {
// AuthenticationError extends HiveDriverError — preserve that hierarchy.
const err = mapKernelErrorToJsError({ code: 'Unauthenticated', message: 'nope' });
expect(err).to.be.instanceOf(AuthenticationError);
expect(err).to.be.instanceOf(HiveDriverError);
expect(err).to.be.instanceOf(Error);
});

it('ParameterError does NOT extend HiveDriverError (matches existing class hierarchy)', () => {
const err = mapKernelErrorToJsError({ code: 'InvalidArgument', message: 'bad' });
expect(err).to.be.instanceOf(ParameterError);
expect(err).to.not.be.instanceOf(HiveDriverError);
expect(err).to.be.instanceOf(Error);
});
});
});
Loading