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
81 changes: 81 additions & 0 deletions lib/sea/SeaNativeLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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.

/**
* Loader for the SEA (Statement Execution API) native binding.
*
* Round 1b: minimal pass-through to the napi-rs auto-generated
* `index.js` shim in `native/sea/`. The shim itself picks the right
* per-platform `.node` artifact (linux-x64-gnu today; more triples in
* the bundling feature).
*
* Round 2+ will extend this with: lazy require to defer the `.node`
* load until the first SEA call, structured load-error diagnostics
* (which platform/arch was attempted, whether the package was
* installed at all), and a JS-side `DBSQLLogger` install path that
* forwards to the binding's `installLogger()` once that surface lands.
*/

// The path is relative to this file at runtime (`dist/sea/SeaNativeLoader.js`)
// resolving to `dist/sea/../../native/sea/index.js` once `tsc` has emitted
// to `dist/`. We use a require-time path resolution because the napi
// shim is plain CommonJS and not part of the TS source tree.
//
// eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-dynamic-require, global-require
const native = require('../../native/sea/index.js');

/**
* Public surface of the native binding exposed to the rest of the
* NodeJS driver. Round 2 lands `openSession` + opaque `Connection` /
* `Statement` classes (the binding-generated `.d.ts` is the source of
* truth for their method signatures — see `native/sea/index.d.ts`).
*
* We deliberately keep this typed loosely (`unknown` for the class
* shapes) so the loader layer doesn't have to import the binding's
* generated types and the JS adapter layer can introduce its own
* higher-level wrappers without conflicting with the binding's TS
* declarations.
*/
export interface SeaNativeBinding {
/** Returns the native crate version (smoke test for the binding's load path). */
version(): string;
/** Open a session over PAT auth. Returns an opaque Connection. */
openSession(opts: {
hostName: string;
httpPath: string;
token: string;
}): Promise<unknown>;
/** Opaque Connection class — instance methods on the binding-generated d.ts. */
Connection: Function;
/** Opaque Statement class — instance methods on the binding-generated d.ts. */
Statement: Function;
}

/**
* Returns the loaded native binding. Throws if the platform-specific
* `.node` artifact cannot be found (napi-rs's auto-generated shim
* surfaces a descriptive error in that case).
*/
export function getSeaNative(): SeaNativeBinding {
return native as SeaNativeBinding;
}

/**
* Convenience accessor for the smoke-test path. Equivalent to
* `getSeaNative().version()` but reads more naturally in tests and
* REPLs.
*/
export function version(): string {
return getSeaNative().version();
}
41 changes: 41 additions & 0 deletions native/sea/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `native/sea/` — consumer-side directory for the Rust napi binding

**The Rust binding source lives in the kernel repo** at
`databricks-sql-kernel/napi/`, as a workspace sibling of `pyo3/`.
See `databricks-sql-kernel`'s root `Cargo.toml` `[workspace] members`.

## Why

Per the architectural decision recorded in
`sea-workflow/decisions.md` (D-006), every language binding (PyO3,
napi-rs, future cgo) is a workspace member of the kernel crate. This
keeps Arrow version pinning lockstep, the path dep clean (`path = ".."`),
and CI single (`cargo build --workspace`). The pattern matches polars,
ruff, arrow-rs.

## What lives here

- `index.d.ts` — generated TypeScript declarations consumed by `lib/sea/`
- `index.linux-x64-gnu.node` (and other platform variants) — symlinked
or copied build artifacts from the kernel workspace at run time

## How to build the binding for local dev

```bash
# From the nodejs repo root:
npm run build:native
# which delegates to the kernel workspace:
# cd $DATABRICKS_SQL_KERNEL_REPO/napi && napi build --release
# and copies the artifact back here
```

`$DATABRICKS_SQL_KERNEL_REPO` defaults to a path published with the
release flow; for dev it points at a local checkout of
`databricks-sql-kernel`.

## How to consume in production

At release time the kernel CI publishes `@databricks/sea-native-<triple>`
npm packages with the `.node` binaries. The nodejs driver declares them
as `optionalDependencies` in `package.json`; `SeaNativeLoader.ts`
resolves the right one at runtime.
144 changes: 144 additions & 0 deletions native/sea/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/* tslint:disable */
/* eslint-disable */

/* auto-generated by NAPI-RS */

/**
* JS-visible per-execute options. M0 only carries
* initialCatalog / initialSchema / sessionConfig — parameters and
* per-statement overrides land in M1.
*/
export interface ExecuteOptions {
/** Default catalog applied to this statement via session conf. */
initialCatalog?: string
/** Default schema applied to this statement via session conf. */
initialSchema?: string
/**
* Per-statement session conf overrides (forwarded to SEA
* `parameters` / Thrift `confOverlay`).
*/
sessionConfig?: Record<string, string>
}
/**
* JS-visible options for opening a Databricks SQL session over PAT.
*
* M0 supports PAT only — `token` is required. OAuth M2M / U2M variants
* land in M1 along with a discriminated-union shape on the JS side.
*/
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
}
/**
* 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<Connection>
/**
* A single Arrow IPC stream payload encoding one record batch (plus
* the schema header so the JS-side reader is stateless).
*/
export interface ArrowBatch {
ipcBytes: Buffer
}
/**
* An Arrow IPC stream payload encoding just the result schema (no
* record-batch messages). Returned by `Statement.schema()`.
*/
export interface ArrowSchema {
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<Mutex<Option<Session>>>` 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`),
* - `executeStatement` can share immutable access to the session via
* the `Arc<SessionInner>` clones the kernel makes internally
* (`Session::statement()` only needs `&self`).
*/
export declare class Connection {
/**
* Execute a SQL statement and return a Statement handle that
* streams batches via `fetchNextBatch()`.
*/
executeStatement(sql: string, options: ExecuteOptions): Promise<Statement>
/**
* Explicit close. Marks the connection wrapper as closed so
* subsequent calls on this `Connection` return `InvalidArg`, then
* schedules a fire-and-forget server-side close on the runtime.
*
* **Why fire-and-forget and not `Session::close().await`:** the
* kernel's `Session::close(self).await` body holds a
* `tracing::EnteredSpan` (a `!Send` type) across an `.await`, so
* the future is not `Send`. napi-rs's `execute_tokio_future` glue
* rejects non-`Send` futures, and `Handle::spawn` does too. The
* kernel's `SessionInner::Drop` already spawns the
* `delete_session` RPC on the same runtime handle the napi
* binding captured, so dropping the value is functionally
* equivalent — the difference is that JS callers can't observe a
* `delete_session` failure from `close()`. Tracked as a kernel-
* side follow-up (clone the span rather than entering it) in
* Round 3 findings.
*/
close(): Promise<void>
}
/**
* Opaque executed-statement handle.
*
* `inner` is wrapped in `Arc<Mutex<Option<…>>>` so:
* - `fetch_next_batch` can `await` `ResultStream::next_batch` which
* requires `&mut ExecutedStatement` (via `result_stream_mut`),
* - `cancel` / `close` (which take `&self` on the kernel side via the
* `ExecutedStatementHandle` trait) can run concurrently with each
* other from a JS perspective without panicking,
* - `Drop` can hand the inner handle off to a tokio task without
* touching `&mut self` across an `await`.
*/
export declare class Statement {
/**
* Pull the next batch of results. Returns `None` 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`.
*/
fetchNextBatch(): Promise<ArrowBatch | null>
/**
* Result schema as an Arrow IPC payload (schema header only, no
* record-batch message). Available before any batches have been
* fetched.
*/
schema(): Promise<ArrowSchema>
/** Server-side cancel. No-op if already finished. */
cancel(): Promise<void>
/**
* Explicit close. Awaits the server-side close so the JS caller
* can observe failures.
*/
close(): Promise<void>
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-sea-WT/napi-binding}/napi && npx --yes @napi-rs/cli@2 build --platform --release && cp index.* $OLDPWD/native/sea/'",
"build:native:debug": "bash -c 'cd ${DATABRICKS_SQL_KERNEL_REPO:-../../databricks-sql-kernel-sea-WT/napi-binding}/napi && npx --yes @napi-rs/cli@2 build --platform && cp index.* $OLDPWD/native/sea/'",
"watch": "tsc --project tsconfig.build.json --watch",
"type-check": "tsc --noEmit",
"prettier": "prettier . --check",
Expand Down Expand Up @@ -91,4 +93,4 @@
"optionalDependencies": {
"lz4": "^0.6.5"
}
}
}
106 changes: 106 additions & 0 deletions tests/native/e2e-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 { getSeaNative } from '../../lib/sea/SeaNativeLoader';

// Round 2 end-to-end smoke test:
// 1. Open a kernel `Session` via `Database.open(...)` over PAT.
// 2. Execute `SELECT 1`.
// 3. Fetch the first batch — assert the IPC bytes are non-empty.
// 4. Close the statement, then the connection.
//
// Requires three env vars (exported by the developer's shell):
// - DATABRICKS_PECOTESTING_SERVER_HOSTNAME
// - DATABRICKS_PECOTESTING_HTTP_PATH
// - DATABRICKS_PECOTESTING_TOKEN_PERSONAL
// If any is missing, the test is skipped (so CI can keep the file in
// the suite without flapping when secrets aren't provisioned).

interface NativeBinding {
openSession(opts: {
hostName: string;
httpPath: string;
token: string;
}): Promise<NativeConnection>;
}

interface NativeConnection {
executeStatement(
sql: string,
options: {
initialCatalog?: string;
initialSchema?: string;
sessionConfig?: Record<string, string>;
},
): Promise<NativeStatement>;
close(): Promise<void>;
}

interface NativeStatement {
fetchNextBatch(): Promise<{ ipcBytes: Buffer } | null>;
schema(): Promise<{ ipcBytes: Buffer }>;
cancel(): Promise<void>;
close(): Promise<void>;
}

describe('SEA native binding — Round 2 end-to-end smoke test', function smoke() {
const hostName = process.env.DATABRICKS_PECOTESTING_SERVER_HOSTNAME;
const httpPath = process.env.DATABRICKS_PECOTESTING_HTTP_PATH;
const token = process.env.DATABRICKS_PECOTESTING_TOKEN_PERSONAL;

// Live-warehouse tests can take >2s through warm-up, so bump the
// mocha default (2000ms) generously.
this.timeout(60_000);

before(function gate() {
if (!hostName || !httpPath || !token) {
// Use `this.skip()` so the suite is reported as skipped rather
// than failing on dev machines without the secrets.
// eslint-disable-next-line no-invalid-this
this.skip();
}
});

it('opens a session, runs SELECT 1, and reads the first batch', async () => {
const binding = getSeaNative() as unknown as NativeBinding;

const connection = await binding.openSession({
hostName: hostName as string,
httpPath: httpPath as string,
token: token as string,
});
expect(connection).to.be.an('object');

let statement: NativeStatement | 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);

// Draining: subsequent fetch should return null (one-row result).
const after = await statement.fetchNextBatch();
expect(after).to.equal(null);
} finally {
if (statement !== null) {
await statement.close();
}
await connection.close();
}
});
});
Loading
Loading