diff --git a/lib/sea/SeaNativeLoader.ts b/lib/sea/SeaNativeLoader.ts new file mode 100644 index 00000000..c66cdf33 --- /dev/null +++ b/lib/sea/SeaNativeLoader.ts @@ -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; + /** 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(); +} diff --git a/native/sea/README.md b/native/sea/README.md new file mode 100644 index 00000000..5efab5c3 --- /dev/null +++ b/native/sea/README.md @@ -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-` +npm packages with the `.node` binaries. The nodejs driver declares them +as `optionalDependencies` in `package.json`; `SeaNativeLoader.ts` +resolves the right one at runtime. diff --git a/native/sea/index.d.ts b/native/sea/index.d.ts new file mode 100644 index 00000000..5fb5e902 --- /dev/null +++ b/native/sea/index.d.ts @@ -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 +} +/** + * 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 +/** + * 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>>` 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` 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 + /** + * 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 +} +/** + * Opaque executed-statement handle. + * + * `inner` is wrapped in `Arc>>` 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 + /** + * Result schema as an Arrow IPC payload (schema header only, no + * record-batch message). Available before any batches have been + * fetched. + */ + schema(): Promise + /** Server-side cancel. No-op if already finished. */ + cancel(): Promise + /** + * Explicit close. Awaits the server-side close so the JS caller + * can observe failures. + */ + close(): Promise +} diff --git a/package.json b/package.json index e430181f..f5400ed4 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-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", @@ -91,4 +93,4 @@ "optionalDependencies": { "lz4": "^0.6.5" } -} +} \ No newline at end of file diff --git a/tests/native/e2e-smoke.test.ts b/tests/native/e2e-smoke.test.ts new file mode 100644 index 00000000..8ab6d22f --- /dev/null +++ b/tests/native/e2e-smoke.test.ts @@ -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; +} + +interface NativeConnection { + executeStatement( + sql: string, + options: { + initialCatalog?: string; + initialSchema?: string; + sessionConfig?: Record; + }, + ): Promise; + close(): Promise; +} + +interface NativeStatement { + fetchNextBatch(): Promise<{ ipcBytes: Buffer } | null>; + schema(): Promise<{ ipcBytes: Buffer }>; + cancel(): Promise; + close(): Promise; +} + +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(); + } + }); +}); diff --git a/tests/native/version.test.ts b/tests/native/version.test.ts new file mode 100644 index 00000000..72a69f43 --- /dev/null +++ b/tests/native/version.test.ts @@ -0,0 +1,38 @@ +// 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 { version, getSeaNative } from '../../lib/sea/SeaNativeLoader'; + +describe('SEA native binding — smoke test', () => { + it('loads the .node artifact and returns version()', () => { + const v = version(); + expect(v).to.match(/^\d+\.\d+\.\d+$/); + }); + + it('exposes the openSession factory function', () => { + const binding = getSeaNative() as unknown as { openSession: Function }; + expect(typeof binding.openSession).to.equal('function'); + }); + + it('exposes the Connection opaque class', () => { + const binding = getSeaNative() as unknown as { Connection: Function }; + expect(typeof binding.Connection).to.equal('function'); + }); + + it('exposes the Statement opaque class', () => { + const binding = getSeaNative() as unknown as { Statement: Function }; + expect(typeof binding.Statement).to.equal('function'); + }); +});