diff --git a/.gitattributes b/.gitattributes index a748d2ce..0a49a4b9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,3 +31,10 @@ Dockerfile* text # .gitattributes export-ignore .gitignore export-ignore + +# napi-rs auto-generates these files from the kernel's `napi-binding/napi/` +# crate; regenerated by `npm run build:native`. Tell git/GitHub they're +# machine-generated so they collapse in diffs and are excluded from +# blame and language stats. +native/sea/index.d.ts linguist-generated=true +native/sea/index.js linguist-generated=true diff --git a/.gitignore b/.gitignore index 99381ce5..c3801f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,12 @@ coverage_unit dist *.DS_Store lib/version.ts + +# SEA native binding — copied/generated from kernel workspace by `npm run build:native`. +# The committed contract is `native/sea/index.d.ts` (TypeScript declarations) and +# `native/sea/index.js` (the napi-rs platform router — small, stable, and required in +# the publish tarball so a missing build step can't ship a tarball that can't load). +# The `.node` binaries are large per-platform artifacts and must NOT be committed; +# in production they arrive via the `@databricks/sql-kernel-` optional deps. +native/sea/index.node +native/sea/index.*.node diff --git a/.npmignore b/.npmignore index 2bfe597c..448289a7 100644 --- a/.npmignore +++ b/.npmignore @@ -3,6 +3,13 @@ !dist/**/* !thrift/**/* +# SEA napi-rs router shim + TypeScript declarations. The router (index.js) +# selects the per-platform `.node` artifact from `@databricks/sql-kernel-*` +# optionalDependencies (populated when the kernel CI publishes them); +# the .d.ts is the consumer-facing type contract. +!native/sea/index.js +!native/sea/index.d.ts + !LICENSE !NOTICE !package.json diff --git a/.prettierignore b/.prettierignore index 9a9ec6bc..4a764095 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,9 @@ coverage dist thrift package-lock.json + +# Generated by napi-rs from the kernel's `napi-binding/napi/` crate; +# regenerated by `npm run build:native`. Format follows napi-rs's +# defaults (no semicolons), not this repo's prettier config. +native/sea/index.d.ts +native/sea/index.js diff --git a/lib/sea/SeaNativeLoader.ts b/lib/sea/SeaNativeLoader.ts new file mode 100644 index 00000000..3cb089a3 --- /dev/null +++ b/lib/sea/SeaNativeLoader.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Lazy loader for the SEA (Statement Execution API) native binding. + * + * Mirrors the load-failure-tolerant pattern of `lib/utils/lz4.ts`: the + * `.node` artifact ships via per-platform optional dependencies + * (`@databricks/sql-kernel-`), so its absence must not crash + * a Thrift-only consumer of the driver. Callers that actually need + * SEA construct a {@link SeaNativeLoader} (or use the process-global + * {@link getSeaNative}) which throws a structured error if the binding + * could not be loaded. + * + * M0 publishes a single triple (`linux-x64-gnu`); see + * `native/sea/README.md` for the supported-platform policy. + */ + +import type { + Connection as NativeConnection, + Statement as NativeStatement, + ConnectionOptions as NativeConnectionOptions, + ArrowBatch as NativeArrowBatch, + ArrowSchema as NativeArrowSchema, +} from '../../native/sea'; + +// SEA-prefixed re-exports. The kernel-generated `.d.ts` keeps the +// napi-rs default names (`ConnectionOptions`, `ArrowBatch`, …); we +// disambiguate on the TS-wrapper side so these never collide with the +// Thrift-side `ConnectionOptions` (lib/contracts/IDBSQLClient.ts) or +// `ArrowBatch` (lib/result/utils.ts) when imported elsewhere. +export type SeaConnectionOptions = NativeConnectionOptions; +export type SeaArrowBatch = NativeArrowBatch; +export type SeaArrowSchema = NativeArrowSchema; +export type SeaConnection = NativeConnection; +export type SeaStatement = NativeStatement; + +/** + * The full native binding surface, derived from the generated module + * so it can never drift from the `.d.ts` contract: when the kernel + * adds or renames a free function / class, this type follows + * automatically and `defaultRequire`'s cast stays correct. + */ +export type SeaNativeBinding = typeof import('../../native/sea'); + +const MIN_NODE_MAJOR = 18; + +function detectNodeMajor(): number { + // `process.version` is `vX.Y.Z`; parseInt stops at the first non-digit. + return parseInt(process.version.slice(1), 10); +} + +function platformLabel(): string { + return `${process.platform}-${process.arch}`; +} + +function loadFailureHint(err: NodeJS.ErrnoException): string { + const platform = platformLabel(); + // Do not name a concrete package: the published name uses the napi-rs + // triple (e.g. `-linux-x64-gnu` / `-linux-x64-musl` / `-win32-x64-msvc`), + // not the bare `${platform}` shown here, so a literal example would + // 404. Point at the README's supported-triple list instead. + const installHint = + 'Install the matching @databricks/sql-kernel-* optional dependency for your platform ' + + '(see native/sea/README.md for the supported triples; M0 ships linux-x64-gnu only).'; + if (err.code === 'MODULE_NOT_FOUND') { + return `SEA native binding not installed for platform ${platform} on Node ${process.version}. ${installHint}`; + } + if (err.code === 'ERR_DLOPEN_FAILED') { + // Surface the underlying dlerror string (e.g. `GLIBC_2.32 not found`) + // plus concrete remediation — without it the cause is invisible. + return ( + `SEA native binding present but failed to dlopen on platform ${platform} / Node ${process.version}: ` + + `${err.message}. Common causes: glibc/musl mismatch (e.g. Alpine Linux — install the -musl variant), ` + + `Node ABI mismatch (try \`rm -rf node_modules && npm install\`), or CPU-architecture mismatch. ` + + `The binding requires Node >=${MIN_NODE_MAJOR}.` + ); + } + return `SEA native binding failed to load on platform ${platform} / Node ${process.version}: ${err.message}`; +} + +/** + * Default loader: resolves `native/sea/index.js` (the napi-rs router), + * which selects the per-platform `.node`. `.js` is omitted so eslint's + * `import/extensions` rule accepts the call. + */ +function defaultRequire(): SeaNativeBinding { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + return require('../../native/sea') as SeaNativeBinding; +} + +/** + * Verify the loaded module exposes the surface the driver depends on. + * Catches kernel-side renames at load time rather than letting them + * surface as `undefined is not a function` deep in a call path. + */ +function assertBindingShape(binding: SeaNativeBinding): void { + const missing: string[] = []; + if (typeof binding.version !== 'function') missing.push('version'); + if (typeof binding.openSession !== 'function') missing.push('openSession'); + if (typeof binding.Connection !== 'function') missing.push('Connection'); + if (typeof binding.Statement !== 'function') missing.push('Statement'); + if (missing.length > 0) { + throw new Error( + `SEA native binding loaded but is missing expected export(s): ${missing.join(', ')}. ` + + `The kernel-generated binding and the JS loader are out of sync.`, + ); + } +} + +/** + * Loads and caches the SEA native binding. Exposed as a class with an + * injectable `load` seam so consumers (e.g. `SeaBackend`) can be unit + * tested with a stub binding instead of requiring a real `.node` on the + * test machine. Most production code uses the process-global default + * via {@link getSeaNative} / {@link tryGetSeaNative}. + */ +export class SeaNativeLoader { + private cached: SeaNativeBinding | null | undefined; + + private cachedError: Error | undefined; + + /** + * @param load injectable module-require seam (stub a binding in tests) + * @param nodeMajor injectable Node-major detector. Defaults to reading the + * live `process.version`; injected in unit tests so the + * load/shape branches are exercised independently of the + * runner's actual Node version (the matrix spans 14–20). + */ + constructor( + private readonly load: () => SeaNativeBinding = defaultRequire, + private readonly nodeMajor: () => number = detectNodeMajor, + ) {} + + private tryLoad(): SeaNativeBinding | undefined { + const nodeMajor = this.nodeMajor(); + // Fail closed: if we cannot determine the Node major (NaN) or it is + // below the floor, refuse the load and fall back to Thrift. + if (!Number.isFinite(nodeMajor) || nodeMajor < MIN_NODE_MAJOR) { + this.cachedError = new Error( + `SEA native binding requires Node >=${MIN_NODE_MAJOR}; running Node ${process.version}. ` + + `Continue using the Thrift backend on this runtime.`, + ); + return undefined; + } + + try { + const binding = this.load(); + assertBindingShape(binding); + return binding; + } catch (err) { + if (err instanceof Error && 'code' in err) { + this.cachedError = new Error(loadFailureHint(err as NodeJS.ErrnoException)); + } else if (err instanceof Error) { + // Shape-check failure or any other Error — preserve its message. + this.cachedError = err; + } else { + this.cachedError = new Error(`SEA native binding failed to load with non-standard error: ${String(err)}`); + } + return undefined; + } + } + + /** + * Returns the loaded native binding. Throws a structured error if the + * binding is unavailable on this platform / Node version. + */ + get(): SeaNativeBinding { + if (this.cached === undefined) { + this.cached = this.tryLoad() ?? null; + } + if (this.cached === null) { + throw this.cachedError ?? new Error('SEA native binding unavailable'); + } + return this.cached; + } + + /** + * Returns the loaded binding or `undefined` if it could not be + * loaded. Use this for capability-detection at startup; use + * {@link get} at the point where SEA is actually required. + */ + tryGet(): SeaNativeBinding | undefined { + if (this.cached === undefined) { + this.cached = this.tryLoad() ?? null; + } + return this.cached ?? undefined; + } +} + +// Process-global default instance + thin convenience wrappers. +const defaultLoader = new SeaNativeLoader(); + +/** + * Returns the loaded native binding from the process-global loader. + * Throws a structured error if the binding is unavailable. + */ +export function getSeaNative(): SeaNativeBinding { + return defaultLoader.get(); +} + +/** + * Returns the loaded binding from the process-global loader, or + * `undefined` if it could not be loaded. + */ +export function tryGetSeaNative(): SeaNativeBinding | undefined { + return defaultLoader.tryGet(); +} diff --git a/native/sea/README.md b/native/sea/README.md new file mode 100644 index 00000000..2a246059 --- /dev/null +++ b/native/sea/README.md @@ -0,0 +1,87 @@ +# `native/sea/` — consumer-side directory for the Rust napi binding + +**The Rust binding source lives in the kernel repo** at +`databricks-sql-kernel/napi/`. Building it requires a local checkout +of that repo — see "Build for local dev" below. The published npm +package is `@databricks/sql-kernel-`. + +## Workspace topology + +The napi crate is a **standalone Cargo workspace** (`[workspace] +members = ["."]` in `napi/Cargo.toml`), **not** a sibling of `pyo3/` +in the kernel root workspace. + +The reason is Cargo feature unification. pyo3 builds the kernel with +the default `tls-native` feature (system OpenSSL via `native-tls`). +The napi crate has to opt INTO `tls-rustls` instead: napi modules are +loaded into Node.js processes that statically link OpenSSL 3.x, and +dynamically linking the system's OpenSSL 1.1 (which `native-tls` +pulls in on Linux) collides with Node's symbols at module-load time +and segfaults the process before any Rust code runs. `rustls` is +pure Rust + `ring` and avoids the conflict entirely. + +If napi lived in the same workspace as pyo3, `cargo build +--workspace` would unify the kernel's feature set to `tls-native ∪ +tls-rustls`, link both TLS stacks into the resulting napi cdylib, +and reintroduce the same clash. Standalone-workspace is the fix. + +## What lives in this directory + +- `index.d.ts` — TypeScript declarations consumed by `lib/sea/`. + Generated by napi-rs from the Rust source; checked in as the + consumer-facing type contract. +- `index.js` — napi-rs's per-platform router shim. Gitignored; + populated by `npm run build:native` for local dev. In published + tarballs it ships alongside the `.d.ts` and `require()`s the + right `@databricks/sql-kernel-` optional dependency. +- `index.*.node` — the actual native binary, one per platform. + Gitignored. In production these live in the per-triple optional + dependencies (`@databricks/sql-kernel-linux-x64-gnu`, etc.); for + local dev `npm run build:native` copies one into this directory. + +## Build for local dev + +```bash +# From the nodejs repo root: +export DATABRICKS_SQL_KERNEL_REPO=/path/to/your/databricks-sql-kernel +npm run build:native # release build (default) +BUILD_PROFILE= npm run build:native # debug build (empty BUILD_PROFILE drops --release) +``` + +`DATABRICKS_SQL_KERNEL_REPO` points at the kernel repo root (the +directory containing `napi/`) and is required when your kernel +checkout isn't at `../../databricks-sql-kernel` relative to the +nodejs repo. + +## Production load path + +At release time the kernel's CI publishes +`@databricks/sql-kernel-` npm packages — one per supported +platform — each containing a single `.node` binary. The nodejs +driver lists them as `optionalDependencies`; npm installs only the +one matching the consumer's `process.platform` / `process.arch`. +`native/sea/index.js` (the napi-rs router) then `require()`s the +installed package at load time. + +## Supported platforms (M0) + +M0 publishes a **single** triple: **`linux-x64-gnu`** (package +`@databricks/sql-kernel-linux-x64-gnu`). It is the only entry in the +driver's `optionalDependencies`. + +On every other platform (macOS, Windows, linux-arm64, linux-x64-musl +/ Alpine, …) the SEA binding is simply absent: `SeaNativeLoader` +returns `undefined` from `tryGet()` / throws a structured +`MODULE_NOT_FOUND` hint from `get()`, and the driver continues to use +the Thrift backend exclusively. This is expected, not a regression — +additional triples are added to `optionalDependencies` as the kernel +CI starts publishing them in later milestones. + +## Supply-chain note + +The unpublished triple names (`@databricks/sql-kernel-darwin-arm64`, +`…-win32-x64-msvc`, etc.) referenced by the router are **not** +squat-able: `@databricks` is a Databricks-owned npm scope, and npm +only allows org members to publish under a scope it owns. A third +party therefore cannot register `@databricks/sql-kernel-*` and have +the router autoload it. No placeholder packages are required. diff --git a/native/sea/index.d.ts b/native/sea/index.d.ts new file mode 100644 index 00000000..eb16e8ac --- /dev/null +++ b/native/sea/index.d.ts @@ -0,0 +1,297 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +/** + * JS-visible options for opening a Databricks SQL session over PAT. + * `token` is required. + * + * Catalog / schema / sessionConf are applied once at session creation + * and remain in effect for every statement run on the resulting + * `Connection`. The SEA wire protocol carries them on + * `CreateSession`, not on `ExecuteStatement` — so there is no + * per-statement override path on this binding. + */ +export interface ConnectionOptions { + /** + * Workspace host, e.g. `adb-…azuredatabricks.net`. The kernel + * normalises this — bare hostnames get `https://` prepended. + */ + hostName: string + /** + * JDBC-style HTTP path, e.g. `/sql/1.0/warehouses/abc123`. The + * kernel parses out the warehouse id. + */ + httpPath: string + /** + * Personal access token. Must be non-empty (the kernel rejects + * empty PATs at session construction). + */ + token: string + /** + * Default catalog for statements executed on this session. + * Routed through the kernel's `DefaultOpts` and onto the SEA + * `CreateSession.catalog` wire field. + */ + catalog?: string + /** + * Default schema for statements executed on this session. + * Routed through the kernel's `DefaultOpts` and onto the SEA + * `CreateSession.schema` wire field. + */ + schema?: string + /** + * Server-bound session conf (Spark conf, `ANSI_MODE`, `TIMEZONE`, + * query-tag presets, …). Forwarded verbatim to SEA + * `session_confs`. Unknown keys are rejected server-side. + */ + sessionConf?: Record + /** + * Maximum number of pooled HTTP connections per host. Routes + * through the kernel's [`HttpConfig::pool_max_idle_per_host`]. + * Tunes the underlying `reqwest` connection pool — higher values + * reduce reconnect overhead when many statements run + * concurrently against the same warehouse. + * + * When the JS caller does NOT provide `maxConnections`, the napi + * binding applies a NodeJS-driver-appropriate default of + * [`NAPI_DEFAULT_POOL_MAX_IDLE_PER_HOST`] (100) — chosen to match + * the JDBC driver's `HttpConnectionPoolSize` default and to close + * the throughput gap vs the NodeJS Thrift driver's + * `maxSockets: Infinity` pool for bursty workloads. The kernel + * core's [`HttpConfig::pool_max_idle_per_host`] default remains + * at the conservative kernel value (10); each binding chooses + * its own user-facing default. Mirrors the Python connector's + * `max_connections` kwarg on the SEA backend, which exposes the + * knob but keeps its own urllib3-aligned default of 10. + * + * Napi-rs serialises `u32` as JS `number`; values up to + * `2^32 - 1` round-trip safely (any reasonable pool size fits). + */ + maxConnections?: number +} +/** + * Open a Databricks SQL session over PAT auth and return an opaque + * `Connection` wrapping the kernel `Session`. + * + * The JS-visible name is `openSession` (napi-rs converts snake_case + * to camelCase for free functions). + */ +export declare function openSession(options: ConnectionOptions): Promise +/** + * A single Arrow IPC stream payload encoding one record batch (plus + * the schema header so the JS-side reader is stateless). + */ +export interface ArrowBatch { + /** + * Arrow IPC stream payload (schema header + 1 record-batch + * message). Decode with `apache-arrow`'s `RecordBatchReader`. + */ + ipcBytes: Buffer +} +/** + * An Arrow IPC stream payload encoding just the result schema (no + * record-batch messages). Returned by `Statement.schema()`. + */ +export interface ArrowSchema { + /** + * Arrow IPC stream payload (schema header only, no record-batch + * messages). Decode with `apache-arrow`'s `RecordBatchReader` — + * the reader will expose the schema and immediately end. + */ + ipcBytes: Buffer +} +/** + * Returns the native binding's crate version (`CARGO_PKG_VERSION`). + * + * Originally the round-1b smoke test; kept as a cheap "is the binding + * loaded?" probe for the JS-side loader's structured diagnostics. + */ +export declare function version(): string +/** + * Opaque connection handle wrapping a kernel `Session`. + * + * `inner` is `Arc>>` so: + * - the Drop impl can clone the `Arc` and `.take()` the session on a + * background tokio task without holding `&mut self` (which Drop is + * forbidden from doing across an `await`), + * - `close()` can `.take()` the session to consume it for the kernel's + * move-by-value `Session::close(self)` signature. + * + * **Current concurrency shape** — `executeStatement` holds + * `inner.lock()` across `stmt.execute().await`, so two concurrent + * `Promise.all([executeStatement(q1), executeStatement(q2)])` calls + * on the same Connection serialise even though the kernel transport + * supports concurrent statements per session, and `close()` blocks + * behind any in-flight execute. The kernel's `Session::statement()` + * is `&self`-callable, so the right shape is `Arc` with + * concurrent execute paths; that lands in the follow-up lock-shape + * refactor — see + * `sea-workflow/jira-candidates/2026-05-24-napi-cancel-during-fetch.md`. + */ +export declare class Connection { + /** + * Server-issued session id. Cached at construction; readable + * even after `close()` so JS-side log lines can correlate + * against kernel / server logs which key on the same id. + */ + get sessionId(): string + /** + * Execute a SQL statement and return a Statement handle that + * streams batches via `fetchNextBatch()`. + * + * No per-statement options: catalog / schema / sessionConf are + * session-level (`openSession`). + */ + executeStatement(sql: string): Promise + /** + * Explicit close. Awaits the server-side `DeleteSession` so the + * JS caller can observe failures (auth revoked mid-session, + * warehouse stopped, network error). Idempotent — a second call + * on an already-closed connection returns `Ok`. + * + * **Errors are terminal from the JS side.** The kernel session + * handle is consumed (`take()`) BEFORE the wire `DeleteSession` + * runs, because `Session::close` takes `self` by value. On `Err`, + * the napi `inner` is already `None`, so a JS-side retry sees a + * closed connection and returns `Ok(())` without re-attempting + * the wire call. The kernel's own `Drop` fire-and-forget retry + * runs once in the background — the JS caller can log the error + * but cannot drive a retry. If you need retry-on-failure + * semantics for `DeleteSession`, layer them above this method. + */ + close(): Promise +} +/** + * Opaque executed-statement handle. + * + * **Current concurrency shape** — every method takes `inner.lock()` + * and holds the guard across the kernel `.await`. tokio `Mutex` is + * FIFO, so cancel/close queue behind any in-flight `fetchNextBatch` + * until it returns naturally. This is a known limitation that exists + * because the napi shape has not yet been split into an + * `Arc` (for cancel/close, which the + * kernel exposes as `&self`-callable) plus a `Mutex>` only + * for the borrowed-mut fetch path. The lock-shape refactor needs a + * small kernel-side accessor and lands in a follow-up PR — see + * `sea-workflow/jira-candidates/2026-05-24-napi-cancel-during-fetch.md`. + * + * `schema` and `statement_id` are cached at construction so they + * survive `close()` — JS callers building error reports against a + * disposed statement can still read them. + */ +export declare class Statement { + /** + * Server-issued statement id. Cached at construction; readable + * even after `close()` so JS-side log lines can correlate against + * kernel / server logs which key on the same id. + */ + get statementId(): string + /** + * Number of rows modified by the statement (UPDATE / INSERT / + * DELETE / MERGE). `null` for SELECT and on warehouses that don't + * surface the counter. Mirrors Thrift's + * `TGetOperationStatusResp.numModifiedRows`. + */ + numModifiedRows(): Promise + /** + * Server-supplied user-facing message. Mirrors Thrift's + * `TGetOperationStatusResp.displayMessage`. **PII / sensitive- + * data note:** may contain SQL fragments or parameter values — + * redact before centralised logging. + * + * Populated on `Succeeded` / `Closed-with-inline-data` paths. + * On terminal-error states (`Failed` / `Cancelled` / + * `Closed-no-data`) the kernel returns an Error instead of a + * `Statement`, and the same field rides on the JS Error envelope + * under the same `displayMessage` key. + */ + displayMessage(): Promise + /** + * Server-supplied diagnostic detail — multi-line operator / + * stack context. Mirrors Thrift's + * `TGetOperationStatusResp.diagnosticInfo`. For support surfaces, + * not user-facing. Same reachability + PII caveats as + * `displayMessage`. + */ + diagnosticInfo(): Promise + /** + * Server-supplied JSON blob with extended error details. Mirrors + * Thrift's `TGetOperationStatusResp.errorDetailsJson`. + * Pass-through string — JS callers parse with `JSON.parse` if + * they need structured access. + * + * **Server-side gating:** populated only when the workspace has + * `spark.databricks.sql.errorDetailsJson.enabled = true` on the + * underlying SQL cluster. The flag is internal-only / default- + * false in the Databricks runtime, so for most JS callers this + * will return `null`. Admin-enabled workspaces return content + * shaped like `{"errorClass": "...", "messageTemplate": "..."}`. + * + * **Unbounded:** when populated, server can return a multi-MB + * blob; size before logging. + */ + errorDetailsJson(): Promise + /** + * Pull the next batch of results. Returns `null` when the stream + * is exhausted. The returned `ArrowBatch.ipcBytes` is a complete + * Arrow IPC stream (schema header + 1 record-batch message) + * suitable for handing to `apache-arrow`'s `RecordBatchReader`. + * + * On `Err`, the stream is in an unspecified state — call + * `close()` and discard the `Statement`. Subsequent + * `fetchNextBatch()` calls after an error are not guaranteed to + * succeed or fail consistently. + */ + fetchNextBatch(): Promise + /** + * Result schema as an Arrow IPC payload (schema header only, no + * record-batch message). Available before any batches have been + * fetched, and remains available after `close()` — the kernel + * materialises the schema eagerly so JS callers can build error + * reports against a disposed statement. + * + * Sync because the body has no `.await` — `encode_ipc_stream` is + * pure CPU work over an `Arc` already cached on the + * wrapper. Mirrors `pyo3/src/statement.rs::arrow_schema` (sync). + * napi-rs converts a panic in a sync `#[napi]` entry point into a + * thrown JS error via its own macro-expanded boundary, so the + * `util::guarded` `catch_unwind` wrapper that the `async fn` + * entry points use is not required for this method. + */ + schema(): ArrowSchema + /** + * Server-side cancel. + * + * Short-circuits to `Ok(())` if `fetchNextBatch` has already + * returned `null` (stream naturally exhausted) — matches the + * JDBC `Statement.cancel()` no-op-after-completion contract, so + * JS callers can fire cancel defensively without distinguishing + * "real cancel" from "raced with natural completion." + * + * Returns `KernelError(InvalidStatementHandle)` if the statement + * has been explicitly `close()`d. + */ + cancel(): Promise + /** + * Explicit close. Awaits the server-side `CloseStatement` so the + * JS caller can observe failures (auth revoked mid-session, + * network error, server-side error). Idempotent — a second call + * on an already-closed statement returns `Ok`. + * + * **Errors are terminal from the JS side.** The kernel executed + * handle is taken out of `inner` BEFORE the wire `CloseStatement` + * runs (so `Drop` knows there's nothing left to clean up). On + * `Err`, the napi `inner` is already `None`, so a JS-side retry + * sees a closed statement and returns `Ok(())` without re- + * attempting the wire call. The kernel-level `ExecutedStatement` + * has been consumed at that point and the value is dropped on + * the way out of the closure — the kernel's `ExecutedStatement:: + * Drop` then fires-and-forgets a single retry on the captured + * runtime. The JS caller can log the error but cannot drive a + * further retry. If you need retry-on-failure semantics for + * `CloseStatement`, layer them above this method. + */ + close(): Promise +} diff --git a/native/sea/index.js b/native/sea/index.js new file mode 100644 index 00000000..6153729d --- /dev/null +++ b/native/sea/index.js @@ -0,0 +1,318 @@ +/* tslint:disable */ +/* eslint-disable */ +/* prettier-ignore */ + +/* auto-generated by NAPI-RS */ + +const { existsSync, readFileSync } = require('fs') +const { join } = require('path') + +const { platform, arch } = process + +let nativeBinding = null +let localFileExisted = false +let loadError = null + +function isMusl() { + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'index.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./index.android-arm64.node') + } else { + nativeBinding = require('@databricks/sql-kernel-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'index.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./index.android-arm-eabi.node') + } else { + nativeBinding = require('@databricks/sql-kernel-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'index.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.win32-x64-msvc.node') + } else { + nativeBinding = require('@databricks/sql-kernel-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'index.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.win32-ia32-msvc.node') + } else { + nativeBinding = require('@databricks/sql-kernel-win32-ia32-msvc') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'index.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.win32-arm64-msvc.node') + } else { + nativeBinding = require('@databricks/sql-kernel-win32-arm64-msvc') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'index.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./index.darwin-universal.node') + } else { + nativeBinding = require('@databricks/sql-kernel-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'index.darwin-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./index.darwin-x64.node') + } else { + nativeBinding = require('@databricks/sql-kernel-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'index.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.darwin-arm64.node') + } else { + nativeBinding = require('@databricks/sql-kernel-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'index.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./index.freebsd-x64.node') + } else { + nativeBinding = require('@databricks/sql-kernel-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'index.linux-x64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-x64-musl.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-x64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'index.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-x64-gnu.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-x64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'index.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-arm64-musl.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'index.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-arm64-gnu.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'index.linux-arm-musleabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-arm-musleabihf.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-arm-musleabihf') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'index.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'index.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-riscv64-musl.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'index.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-riscv64-gnu.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'index.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./index.linux-s390x-gnu.node') + } else { + nativeBinding = require('@databricks/sql-kernel-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) +} + +if (!nativeBinding) { + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) +} + +const { Connection, openSession, Statement, version } = nativeBinding + +module.exports.Connection = Connection +module.exports.openSession = openSession +module.exports.Statement = Statement +module.exports.version = version diff --git a/package-lock.json b/package-lock.json index ee7678d5..35955d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "node": ">=14.0.0" }, "optionalDependencies": { + "@napi-rs/cli": "2.18.4", "lz4": "^0.6.5" } }, @@ -833,6 +834,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://npm-proxy.dev.databricks.com/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "license": "MIT", + "optional": true, + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7015,6 +7033,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://npm-proxy.dev.databricks.com/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "optional": true + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index f34d2078..fa36c2f6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "test": "nyc --report-dir=${NYC_REPORT_DIR:-coverage_unit} mocha --config tests/unit/.mocharc.js", "update-version": "node bin/update-version.js && prettier --write ./lib/version.ts", "build": "npm run update-version && tsc --project tsconfig.build.json", + "build:native": "bash -c 'cd ${DATABRICKS_SQL_KERNEL_REPO:-../../databricks-sql-kernel}/napi && npx --no-install @napi-rs/cli build --platform ${BUILD_PROFILE:---release} && cp index.* $OLDPWD/native/sea/'", + "prepack": "test -f native/sea/index.js || { echo 'ERROR: native/sea/index.js (napi-rs router) is missing — the published tarball would fail to load SEA. It is committed to git; run `npm run build:native` if you removed it.' >&2; exit 1; }", "watch": "tsc --project tsconfig.build.json --watch", "type-check": "tsc --noEmit", "prettier": "prettier . --check", @@ -89,6 +91,7 @@ "winston": "^3.8.2" }, "optionalDependencies": { - "lz4": "^0.6.5" + "lz4": "^0.6.5", + "@napi-rs/cli": "2.18.4" } } diff --git a/tests/e2e/sea/e2e-smoke.test.ts b/tests/e2e/sea/e2e-smoke.test.ts new file mode 100644 index 00000000..e96efe34 --- /dev/null +++ b/tests/e2e/sea/e2e-smoke.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { tableFromIPC } from 'apache-arrow'; +import { tryGetSeaNative, SeaConnection, SeaStatement } from '../../../lib/sea/SeaNativeLoader'; +import config from '../utils/config'; + +// End-to-end smoke test against a live warehouse: +// 1. Open a kernel `Session` over PAT. +// 2. Execute `SELECT 1`, decode the IPC payload, assert the value is 1. +// 3. Exercise lifecycle negative paths (drain-past-null, double-close). +// 4. Close the statement, then the connection. +// +// Credentials come from the shared e2e config (tests/e2e/utils/config.ts: +// E2E_HOST / E2E_PATH / E2E_ACCESS_TOKEN) — the single credential source +// used by every other e2e test, so `npm run e2e` has one consistent +// skip/fail contract rather than two. + +describe('SEA native binding — end-to-end smoke', function smoke() { + // Live-warehouse tests can take >2s through warm-up. + this.timeout(60_000); + + const binding = tryGetSeaNative(); + if (binding === undefined) { + // Optional dependency absent on this platform — never reach the live path. + it.skip('SEA native binding not available on this platform'); + return; + } + + const { host: hostName, path: httpPath, token } = config; + + it('opens a session, runs SELECT 1, decodes the IPC payload to 1', async () => { + const connection: SeaConnection = await binding.openSession({ hostName, httpPath, token }); + expect(connection).to.be.an('object'); + + let statement: SeaStatement | null = null; + try { + statement = await connection.executeStatement('SELECT 1'); + expect(statement).to.be.an('object'); + + const batch = await statement.fetchNextBatch(); + expect(batch).to.not.equal(null); + expect(batch!.ipcBytes).to.be.instanceOf(Buffer); + expect(batch!.ipcBytes.length).to.be.greaterThan(0); + + // Decode the IPC payload and verify the value, not just the shape. + const table = tableFromIPC(batch!.ipcBytes); + expect(table.numRows).to.equal(1); + expect(Number(table.getChildAt(0)!.get(0))).to.equal(1); + + // Drain-past-null: subsequent fetch returns null. + const after = await statement.fetchNextBatch(); + expect(after).to.equal(null); + + // Drain-past-drained: another fetch still returns null (idempotent). + const afterAgain = await statement.fetchNextBatch(); + expect(afterAgain).to.equal(null); + } finally { + if (statement !== null) { + await statement.close(); + } + await connection.close(); + } + }); + + it('returns a schema IPC payload before any batch is fetched', async () => { + const connection: SeaConnection = await binding.openSession({ hostName, httpPath, token }); + try { + const statement = await connection.executeStatement('SELECT 1'); + try { + // schema() is synchronous on the binding (cached at construction). + const schema = statement.schema(); + expect(schema.ipcBytes).to.be.instanceOf(Buffer); + expect(schema.ipcBytes.length).to.be.greaterThan(0); + } finally { + await statement.close(); + } + } finally { + await connection.close(); + } + }); +}); diff --git a/tests/unit/sea/loader.test.ts b/tests/unit/sea/loader.test.ts new file mode 100644 index 00000000..13d88632 --- /dev/null +++ b/tests/unit/sea/loader.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { SeaNativeLoader, SeaNativeBinding } from '../../../lib/sea/SeaNativeLoader'; + +// Pure-logic tests for SeaNativeLoader. These exercise the load-failure +// hint branches, the Node-version gate, the shape check, and caching via +// the injectable `load` and `nodeMajor` seams — so they run everywhere +// regardless of whether a real `.node` is installed on the test machine +// OR which Node version the runner happens to be (the CI matrix spans +// 14–20, below and above the >=18 floor). Tests that exercise the load +// path inject a supported Node major so the version gate never short- +// circuits them; the gate's own tests inject the version under test. +const SUPPORTED_NODE_MAJOR = () => 18; + +function stubBinding(overrides: Partial> = {}): SeaNativeBinding { + return { + version: () => '1.2.3', + openSession: async () => ({}), + Connection: function Connection() {}, + Statement: function Statement() {}, + ...overrides, + } as unknown as SeaNativeBinding; +} + +function errWithCode(code: string, message: string): NodeJS.ErrnoException { + const err = new Error(message) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +// Capture the message of the error thrown by `fn` (fails the test if +// nothing is thrown). Lets a single failure be asserted against several +// substrings without chai's `.and.to.throw` re-targeting quirk. +function thrownMessage(fn: () => unknown): string { + try { + fn(); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + return expect.fail('expected the call to throw, but it did not') as never; +} + +describe('SeaNativeLoader', () => { + describe('successful load', () => { + it('get() returns the binding from the injected loader', () => { + const binding = stubBinding(); + const loader = new SeaNativeLoader(() => binding, SUPPORTED_NODE_MAJOR); + expect(loader.get()).to.equal(binding); + expect(loader.tryGet()).to.equal(binding); + }); + + it('caches the result — the load function runs at most once', () => { + let calls = 0; + const binding = stubBinding(); + const loader = new SeaNativeLoader(() => { + calls += 1; + return binding; + }, SUPPORTED_NODE_MAJOR); + loader.get(); + loader.tryGet(); + loader.get(); + expect(calls).to.equal(1); + }); + }); + + describe('load-failure hints', () => { + it('MODULE_NOT_FOUND → "not installed" hint pointing at the README', () => { + const loader = new SeaNativeLoader(() => { + throw errWithCode('MODULE_NOT_FOUND', "Cannot find module '../../native/sea'"); + }, SUPPORTED_NODE_MAJOR); + expect(loader.tryGet()).to.equal(undefined); + const msg = thrownMessage(() => loader.get()); + expect(msg).to.match(/not installed/); + expect(msg).to.match(/README/); + }); + + it('ERR_DLOPEN_FAILED → includes the underlying dlerror string and remediation', () => { + const loader = new SeaNativeLoader(() => { + throw errWithCode('ERR_DLOPEN_FAILED', 'GLIBC_2.32 not found'); + }, SUPPORTED_NODE_MAJOR); + const msg = thrownMessage(() => loader.get()); + expect(msg).to.match(/GLIBC_2\.32 not found/); + expect(msg).to.match(/musl/); + expect(msg).to.match(/rm -rf node_modules/); + }); + + it('a generic Error (no code) preserves its message', () => { + const loader = new SeaNativeLoader(() => { + throw new Error('totally unexpected'); + }, SUPPORTED_NODE_MAJOR); + expect(() => loader.get()).to.throw(/totally unexpected/); + }); + + it('a non-Error throw is wrapped', () => { + const loader = new SeaNativeLoader(() => { + // eslint-disable-next-line no-throw-literal + throw 'a string'; + }, SUPPORTED_NODE_MAJOR); + expect(() => loader.get()).to.throw(/non-standard error/); + }); + }); + + describe('shape check', () => { + it('rejects a binding missing an expected export', () => { + const loader = new SeaNativeLoader(() => stubBinding({ openSession: undefined }), SUPPORTED_NODE_MAJOR); + expect(loader.tryGet()).to.equal(undefined); + const msg = thrownMessage(() => loader.get()); + expect(msg).to.match(/missing expected export/); + expect(msg).to.match(/openSession/); + }); + }); + + describe('Node-version gate', () => { + it('fails closed on a Node version below the floor', () => { + let loadCalled = false; + const loader = new SeaNativeLoader( + () => { + loadCalled = true; + return stubBinding(); + }, + () => 16, + ); + expect(() => loader.get()).to.throw(/requires Node >=18/); + expect(loadCalled, 'load() must not be attempted on an unsupported Node').to.equal(false); + }); + + it('fails closed when the Node version is unparseable (NaN)', () => { + const loader = new SeaNativeLoader( + () => stubBinding(), + () => NaN, + ); + expect(() => loader.get()).to.throw(/requires Node >=18/); + }); + }); +}); diff --git a/tests/unit/sea/version.test.ts b/tests/unit/sea/version.test.ts new file mode 100644 index 00000000..24a05d7a --- /dev/null +++ b/tests/unit/sea/version.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { tryGetSeaNative } from '../../../lib/sea/SeaNativeLoader'; + +// Fail loudly only when the binding is actually expected to be present — +// i.e. a CI step has provisioned it (a published `@databricks/sql-kernel-*` +// optional dep installed, or `npm run build:native` was run) and opts in via +// `SEA_NATIVE_EXPECTED=1`. A missing binding there is a real packaging / build +// regression that a silent skip would mask. +// +// Until those binding packages are published, the standard CI cannot install +// the optional dep and does not build the native binding, so the binding is +// legitimately absent — default to a skip rather than a spurious hard failure. +// (`npm ci` already skips the unpublished optional dep.) +function bindingIsExpected(): boolean { + return process.env.SEA_NATIVE_EXPECTED === '1'; +} + +describe('SEA native binding — smoke test', function smoke() { + const binding = tryGetSeaNative(); + + if (binding === undefined) { + if (bindingIsExpected()) { + it('fails loudly: the binding must load on the linux-x64 CI runner', () => { + expect.fail( + 'SEA native binding failed to load on a linux-x64 CI runner where ' + + '@databricks/sql-kernel-linux-x64-gnu is expected. Run `npm run build:native` or check packaging.', + ); + }); + return; + } + // Optional dependency absent on this platform — skip rather than fail. + // eslint-disable-next-line no-invalid-this + this.pending = true; + it.skip('SEA native binding not available on this platform'); + return; + } + + it('returns a semver version()', () => { + expect(binding.version()).to.match(/^\d+\.\d+\.\d+$/); + }); + + it('exposes the full binding surface the driver depends on', () => { + // Guards against kernel-side renames: if the kernel drops/renames a + // free function or class, this fails instead of staying green. + expect(binding.version, 'version()').to.be.a('function'); + expect(binding.openSession, 'openSession()').to.be.a('function'); + expect(binding.Connection, 'Connection class').to.be.a('function'); + expect(binding.Statement, 'Statement class').to.be.a('function'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9da406df..767f4166 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "sourceMap": true, "strict": true, "esModuleInterop": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "baseUrl": "./" }, "exclude": ["./dist/**/*"] }