From ec9fbc221dfb7c062e9a3a02de5bb86b5db0bc81 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Fri, 15 May 2026 00:48:48 +0000 Subject: [PATCH 1/5] sea-napi-binding: scaffold native/sea/ crate with version() smoke test Creates the napi-rs binding skeleton: Cargo.toml + lib.rs + module stubs for database/connection/statement/result/error/logger. Captures napi-rs tokio Handle via OnceCell in runtime.rs. Single working #[napi] fn version() proves the binding loads + executes end-to-end in Node. Depends on krn-async-public-api branch (path dep on kernel). Round 2 will add open/execute/fetch methods. --- lib/sea/SeaNativeLoader.ts | 59 +++++++ native/sea/.gitignore | 7 + native/sea/Cargo.toml | 54 ++++++ native/sea/build.rs | 17 ++ native/sea/index.d.ts | 60 +++++++ native/sea/index.js | 317 +++++++++++++++++++++++++++++++++++ native/sea/package.json | 23 +++ native/sea/src/connection.rs | 51 ++++++ native/sea/src/database.rs | 99 +++++++++++ native/sea/src/error.rs | 45 +++++ native/sea/src/lib.rs | 43 +++++ native/sea/src/logger.rs | 17 ++ native/sea/src/result.rs | 18 ++ native/sea/src/runtime.rs | 56 +++++++ native/sea/src/statement.rs | 20 +++ package.json | 2 + tests/native/version.test.ts | 40 +++++ 17 files changed, 928 insertions(+) create mode 100644 lib/sea/SeaNativeLoader.ts create mode 100644 native/sea/.gitignore create mode 100644 native/sea/Cargo.toml create mode 100644 native/sea/build.rs create mode 100644 native/sea/index.d.ts create mode 100644 native/sea/index.js create mode 100644 native/sea/package.json create mode 100644 native/sea/src/connection.rs create mode 100644 native/sea/src/database.rs create mode 100644 native/sea/src/error.rs create mode 100644 native/sea/src/lib.rs create mode 100644 native/sea/src/logger.rs create mode 100644 native/sea/src/result.rs create mode 100644 native/sea/src/runtime.rs create mode 100644 native/sea/src/statement.rs create mode 100644 tests/native/version.test.ts diff --git a/lib/sea/SeaNativeLoader.ts b/lib/sea/SeaNativeLoader.ts new file mode 100644 index 00000000..638ca6dc --- /dev/null +++ b/lib/sea/SeaNativeLoader.ts @@ -0,0 +1,59 @@ +// 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'); + +export interface SeaNativeBinding { + /** Returns the native crate version (smoke test for the binding's load path). */ + version(): string; +} + +/** + * 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/.gitignore b/native/sea/.gitignore new file mode 100644 index 00000000..92ba58de --- /dev/null +++ b/native/sea/.gitignore @@ -0,0 +1,7 @@ +# Rust build artifacts +target/ +Cargo.lock + +# Platform-specific `.node` binaries are produced per-platform by the +# bundling feature; not committed. +*.node diff --git a/native/sea/Cargo.toml b/native/sea/Cargo.toml new file mode 100644 index 00000000..d5c49046 --- /dev/null +++ b/native/sea/Cargo.toml @@ -0,0 +1,54 @@ +# 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. + +[package] +name = "databricks-sea-native" +version = "0.1.0" +edition = "2021" +authors = ["Databricks"] +license = "Apache-2.0" +description = "Databricks SQL Node.js SEA native binding (napi-rs)" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# napi-rs v2 line; `napi6` enables N-API 6 surface, `async` enables the +# `#[napi] async fn` glue that drives futures on napi-rs's tokio runtime. +napi = { version = "2", default-features = false, features = ["napi6", "async"] } +napi-derive = "2" + +# Kernel — path dep on the async-public-api branch worktree. Once the +# kernel is published this becomes a version dep. +databricks-sql-kernel = { path = "../../../../databricks-sql-kernel-sea-WT/async-public-api" } + +# Tokio is a transitive dep via the kernel and via napi's `async` feature; +# declared explicitly so we can name `tokio::runtime::Handle` directly. +tokio = { version = "1", default-features = false, features = ["rt"] } + +# Lazy `OnceCell` for the captured tokio Handle. +once_cell = "1" + +# Tracing for kernel + binding diagnostics. The real subscriber is wired +# in Round 3 via the ThreadsafeFunction logger bridge. +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } + +[build-dependencies] +napi-build = "2" + +[profile.release] +lto = true +strip = "symbols" diff --git a/native/sea/build.rs b/native/sea/build.rs new file mode 100644 index 00000000..398bb2da --- /dev/null +++ b/native/sea/build.rs @@ -0,0 +1,17 @@ +// 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. + +fn main() { + napi_build::setup(); +} diff --git a/native/sea/index.d.ts b/native/sea/index.d.ts new file mode 100644 index 00000000..202deddd --- /dev/null +++ b/native/sea/index.d.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +/** + * JS-visible connection options. Empty in Round 1b; Round 2 may add + * per-connection scope fields (catalog, schema, session config map). + */ +export interface ConnectionOptions { + +} +/** + * JS-visible constructor options. Round 2 will populate this with + * real fields (host, warehouseId, auth, …); for the scaffold it is + * intentionally empty so the JS smoke test can call `new Database({})` + * without TypeScript complaining about unknown properties. + */ +export interface DatabaseOptions { + /** + * Workspace host URL (e.g. `https://workspace.databricks.com`). + * Optional in Round 1b; Round 2 makes it required. + */ + host?: string + /** Warehouse id. Optional in Round 1b; Round 2 makes it required. */ + warehouseId?: string +} +/** + * Returns the native binding's crate version (`CARGO_PKG_VERSION`). + * + * Acts as the round-1b smoke test: a JS `require()` of the `.node` + * artifact that successfully calls `version()` proves the binding's + * build + load + dispatch path is wired correctly. + */ +export declare function version(): string +/** Opaque connection handle. Round 1b: marker only; no kernel state. */ +export declare class Connection { + /** + * Construct a new connection handle. Round 1b is a no-op shell; + * Round 2 will wire it to `Database`'s `Session` (likely via an + * async `Database::connect()` factory rather than a JS-side + * `new Connection()`). + */ + constructor(options: ConnectionOptions) +} +/** + * Opaque database handle on the JS side. + * + * Holds `Option` so `close()` (Round 2) can `.take()` the + * session out and `.await` an async close, leaving `inner = None`. + * The `Drop` impl checks `inner` to decide whether to schedule a + * fire-and-forget close on the captured tokio runtime. + */ +export declare class Database { + /** + * Construct a new database handle. Round 1b: the options are + * stashed for diagnostic purposes only — no network call. + */ + constructor(options: DatabaseOptions) +} diff --git a/native/sea/index.js b/native/sea/index.js new file mode 100644 index 00000000..6818d29b --- /dev/null +++ b/native/sea/index.js @@ -0,0 +1,317 @@ +/* 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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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, Database, version } = nativeBinding + +module.exports.Connection = Connection +module.exports.Database = Database +module.exports.version = version diff --git a/native/sea/package.json b/native/sea/package.json new file mode 100644 index 00000000..96d116dd --- /dev/null +++ b/native/sea/package.json @@ -0,0 +1,23 @@ +{ + "name": "@databricks/sea-native-linux-x64-gnu", + "version": "0.1.0", + "description": "Databricks SQL Node.js SEA native binding (linux-x64-gnu).", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "*.node" + ], + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + }, + "napi": { + "binaryName": "sea-native", + "targets": [ + "x86_64-unknown-linux-gnu" + ] + }, + "private": true +} diff --git a/native/sea/src/connection.rs b/native/sea/src/connection.rs new file mode 100644 index 00000000..ad9df612 --- /dev/null +++ b/native/sea/src/connection.rs @@ -0,0 +1,51 @@ +// 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. + +//! Opaque `Connection` wrapper. +//! +//! Round 1b: scaffold only. The kernel collapses ADBC's per-connection +//! state into the `Session` handle held by `Database` (see +//! `database.rs`). The JS-side `Connection` exists for API parity with +//! the existing Node driver but is currently a thin marker; Round 2 +//! decides whether to keep it as a pass-through on `Database` or to +//! attach per-connection scoping (e.g. default catalog/schema overrides). + +/// JS-visible connection options. Empty in Round 1b; Round 2 may add +/// per-connection scope fields (catalog, schema, session config map). +#[napi(object)] +pub struct ConnectionOptions {} + +/// Opaque connection handle. Round 1b: marker only; no kernel state. +#[napi] +pub struct Connection {} + +#[napi] +impl Connection { + /// Construct a new connection handle. Round 1b is a no-op shell; + /// Round 2 will wire it to `Database`'s `Session` (likely via an + /// async `Database::connect()` factory rather than a JS-side + /// `new Connection()`). + #[napi(constructor)] + pub fn new(_options: ConnectionOptions) -> Self { + Connection {} + } +} + +impl Drop for Connection { + fn drop(&mut self) { + // Round 1b: nothing to clean up. Round 2 will populate this + // with the same `runtime::get_handle().spawn(...)` pattern as + // `Database::drop`. + } +} diff --git a/native/sea/src/database.rs b/native/sea/src/database.rs new file mode 100644 index 00000000..800ca090 --- /dev/null +++ b/native/sea/src/database.rs @@ -0,0 +1,99 @@ +// 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. + +//! Opaque `Database` wrapper around the kernel's `Session` handle. +//! +//! Round 1b: scaffold only — `constructor` stores options and returns +//! immediately. Round 2 will add `open()` (calling `Session::open`), +//! `statement()`, `close()`, etc. +//! +//! The kernel collapses ADBC's `Database` + `Connection` into a single +//! `Session`. We keep the wrapper name `Database` on the JS side +//! because that matches the existing Node driver's mental model; the +//! actual session lives inside this struct. + +use databricks_sql_kernel::Session; + +use crate::runtime; + +/// JS-visible constructor options. Round 2 will populate this with +/// real fields (host, warehouseId, auth, …); for the scaffold it is +/// intentionally empty so the JS smoke test can call `new Database({})` +/// without TypeScript complaining about unknown properties. +#[napi(object)] +pub struct DatabaseOptions { + /// Workspace host URL (e.g. `https://workspace.databricks.com`). + /// Optional in Round 1b; Round 2 makes it required. + pub host: Option, + /// Warehouse id. Optional in Round 1b; Round 2 makes it required. + pub warehouse_id: Option, +} + +/// Opaque database handle on the JS side. +/// +/// Holds `Option` so `close()` (Round 2) can `.take()` the +/// session out and `.await` an async close, leaving `inner = None`. +/// The `Drop` impl checks `inner` to decide whether to schedule a +/// fire-and-forget close on the captured tokio runtime. +#[napi] +pub struct Database { + // TODO(round-2): populate this from `Session::open(config).await` + // inside an `open()` async method (or directly inside the + // constructor via a factory pattern). For now it stays `None` so + // Drop has nothing to clean up. + inner: Option, +} + +#[napi] +impl Database { + /// Construct a new database handle. Round 1b: the options are + /// stashed for diagnostic purposes only — no network call. + #[napi(constructor)] + pub fn new(_options: DatabaseOptions) -> Self { + Database { inner: None } + } +} + +impl Drop for Database { + fn drop(&mut self) { + // Pattern #5 from the napi-rs patterns doc: spawn cleanup on + // the captured runtime handle. We only enter this branch if + // the JS user dropped the handle without calling `close()` + // first (which Round 2 will provide). For Round 1b there is + // nothing to clean up, but the pattern is in place so the + // Round-2 work is a one-line addition. + let Some(session) = self.inner.take() else { + return; + }; + let Some(handle) = runtime::try_get_handle() else { + // No async entry point has ever run, so there cannot be a + // live `Session` either — but the destructor of `Session` + // itself uses the kernel's own borrowed handle, so we + // simply let it run. + drop(session); + return; + }; + // The kernel's `SessionInner::Drop` already spawns a + // fire-and-forget `delete_session` on its own captured runtime + // handle. To stay on napi-rs's runtime explicitly (so Round 2 + // can add binding-side cleanup steps before the kernel drop), + // hop onto a tokio task and let the kernel destructor run + // there. We do NOT call `Session::close().await` because that + // method enters a tracing span (`EnteredSpan` is `!Send`) and + // therefore cannot cross an `await` boundary inside a `spawn`. + handle.spawn(async move { + drop(session); + }); + } +} diff --git a/native/sea/src/error.rs b/native/sea/src/error.rs new file mode 100644 index 00000000..fc82a0b4 --- /dev/null +++ b/native/sea/src/error.rs @@ -0,0 +1,45 @@ +// 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. + +//! Minimal kernel-error → `napi::Error` mapping. +//! +//! Round 1b: just preserves the kernel error message and translates +//! the kernel's [`ErrorCode`] into a small set of napi statuses. Round +//! 2 will add a full taxonomy (sqlState, vendorCode, retryable, …) +//! attached as own-properties on the JS error object via +//! `Env::create_error` (pattern #7 in the napi-rs patterns doc). + +use databricks_sql_kernel::{Error as KernelError, ErrorCode}; +use napi::{Error as NapiError, Status}; + +/// Map a kernel `Error` into a `napi::Error`. The kernel `ErrorCode` +/// is used to pick a sensible napi `Status`; the kernel message is +/// preserved verbatim as the error reason. +/// +/// Round 1b has no callers — the scaffold doesn't return any kernel +/// errors yet. Round 2's `Database::open()` is the first consumer. +#[allow(dead_code)] +pub(crate) fn napi_err_from_kernel(e: KernelError) -> NapiError { + let status = match e.code { + ErrorCode::InvalidArgument | ErrorCode::InvalidStatementHandle => { + Status::InvalidArg + } + ErrorCode::Cancelled => Status::Cancelled, + // Everything else collapses to `GenericFailure`; Round 2 + // refines this with sqlState / vendorCode / category own- + // properties on a JS error object. + _ => Status::GenericFailure, + }; + NapiError::new(status, e.message) +} diff --git a/native/sea/src/lib.rs b/native/sea/src/lib.rs new file mode 100644 index 00000000..7d76cf9b --- /dev/null +++ b/native/sea/src/lib.rs @@ -0,0 +1,43 @@ +// 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. + +//! `databricks-sea-native` — napi-rs binding crate for the Databricks +//! SQL Node.js driver's SEA (Statement Execution API) path. +//! +//! Round 1b scaffold: module skeletons + a single working `version()` +//! `#[napi]` function that proves the binding loads end-to-end. Round 2 +//! adds `Database::open` / `Statement::execute` / fetch / cancel. + +#![deny(unsafe_op_in_unsafe_fn)] + +#[macro_use] +extern crate napi_derive; + +pub(crate) mod connection; +pub(crate) mod database; +pub(crate) mod error; +pub(crate) mod logger; +pub(crate) mod result; +pub(crate) mod runtime; +pub(crate) mod statement; + +/// Returns the native binding's crate version (`CARGO_PKG_VERSION`). +/// +/// Acts as the round-1b smoke test: a JS `require()` of the `.node` +/// artifact that successfully calls `version()` proves the binding's +/// build + load + dispatch path is wired correctly. +#[napi] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} diff --git a/native/sea/src/logger.rs b/native/sea/src/logger.rs new file mode 100644 index 00000000..2bfcd078 --- /dev/null +++ b/native/sea/src/logger.rs @@ -0,0 +1,17 @@ +// 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. + +//! `tracing` → JS `DBSQLLogger` bridge via `ThreadsafeFunction`. +//! +//! Round 3 work. Empty in Round 1b. diff --git a/native/sea/src/result.rs b/native/sea/src/result.rs new file mode 100644 index 00000000..f406c363 --- /dev/null +++ b/native/sea/src/result.rs @@ -0,0 +1,18 @@ +// 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. + +//! ResultStream wrapper. +//! +//! Round 2 work. Empty in Round 1b — see `statement.rs` for the same +//! reasoning. diff --git a/native/sea/src/runtime.rs b/native/sea/src/runtime.rs new file mode 100644 index 00000000..7f0ee42d --- /dev/null +++ b/native/sea/src/runtime.rs @@ -0,0 +1,56 @@ +// 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. + +//! Captured tokio `Handle` for napi-rs's process-global runtime. +//! +//! Per the napi-rs patterns doc (pattern #2): the first time any +//! `#[napi] async fn` runs, we are guaranteed to be on napi-rs's tokio +//! runtime. We snapshot the current `Handle` then and stash a clone in +//! a process-static `OnceCell`. Every subsequent kernel construction +//! reads the captured handle and hands a clone to the kernel, so +//! Drop-time cleanup (which runs on the V8 GC thread, *outside* any +//! tokio context) can still `spawn` cleanup tasks onto the same +//! runtime napi-rs is driving. +//! +//! `Handle::current()` MUST NOT be called from a synchronous JS-thread +//! entry point or from module init — both run before napi-rs has +//! constructed its runtime and would panic. `get()` returns `None` in +//! that case so callers can surface a useful error rather than abort. + +use once_cell::sync::OnceCell; +use tokio::runtime::Handle; + +static RUNTIME_HANDLE: OnceCell = OnceCell::new(); + +/// Capture the current tokio runtime handle on first call, return a +/// reference to the captured clone on subsequent calls. +/// +/// MUST be called from inside a `#[napi] async fn` body (or any other +/// tokio runtime context); otherwise `Handle::current()` panics on the +/// very first call. Subsequent calls are infallible and lock-free. +/// +/// Round 1b has no async entry points that exercise this yet; Round 2 +/// will call it from `Database::open()` and other `#[napi] async fn`s. +#[allow(dead_code)] +pub(crate) fn get_handle() -> &'static Handle { + RUNTIME_HANDLE.get_or_init(Handle::current) +} + +/// Non-panicking accessor — returns `None` if `get_handle()` has not +/// been called yet. Drop impls and other GC-thread call sites use this +/// to short-circuit cleanup when no async entry point has ever run +/// (i.e. there is no kernel state that needs closing either). +pub(crate) fn try_get_handle() -> Option<&'static Handle> { + RUNTIME_HANDLE.get() +} diff --git a/native/sea/src/statement.rs b/native/sea/src/statement.rs new file mode 100644 index 00000000..c449b402 --- /dev/null +++ b/native/sea/src/statement.rs @@ -0,0 +1,20 @@ +// 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. + +//! Statement / ExecutedStatement wrappers. +//! +//! Round 2 work. This module is intentionally empty in Round 1b — no +//! `#[napi]` types here yet. Adding empty stubs would require +//! `napi-rs` to generate JS bindings for them, which adds noise to the +//! `index.d.ts` without any callable surface. diff --git a/package.json b/package.json index e430181f..14d4d200 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": "cd native/sea && napi build --platform --release", + "build:native:debug": "cd native/sea && napi build --platform", "watch": "tsc --project tsconfig.build.json --watch", "type-check": "tsc --noEmit", "prettier": "prettier . --check", diff --git a/tests/native/version.test.ts b/tests/native/version.test.ts new file mode 100644 index 00000000..03210c3c --- /dev/null +++ b/tests/native/version.test.ts @@ -0,0 +1,40 @@ +// 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(); + // Round 1b: the native crate is at 0.1.0. Match the shape rather + // than the literal so the test does not need updating on every + // version bump. + expect(v).to.match(/^\d+\.\d+\.\d+$/); + }); + + it('exposes the Database opaque class', () => { + const binding = getSeaNative() as unknown as { Database: new (opts: object) => object }; + expect(typeof binding.Database).to.equal('function'); + const db = new binding.Database({}); + expect(db).to.be.an('object'); + }); + + it('exposes the Connection opaque class', () => { + const binding = getSeaNative() as unknown as { Connection: new (opts: object) => object }; + expect(typeof binding.Connection).to.equal('function'); + const conn = new binding.Connection({}); + expect(conn).to.be.an('object'); + }); +}); From e0864f23fe584dfdba1bbf4aa4bcfb1855e22ef8 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Fri, 15 May 2026 01:21:30 +0000 Subject: [PATCH 2/5] sea-napi-binding: Database/Connection/Statement/ResultStream methods wired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds real async methods on the opaque wrappers backing M0: - openSession (free function) with PAT → kernel Session - Connection::execute_statement → kernel ExecutedStatement - Statement::fetch_next_batch / schema / cancel / close → kernel ResultStream - Arrow batches returned as IPC bytes (per Layer 2 design) - Error mapping preserves kernel ErrorCode + SQLSTATE for TS layer - All entry points wrapped in catch_unwind End-to-end smoke test against pecotesting passes. No new dependencies beyond arrow-{ipc,array,schema} + futures. Uses kernel async public API (no block_on). Co-authored-by: Isaac --- lib/sea/SeaNativeLoader.ts | 22 ++++ native/sea/Cargo.toml | 19 +++- native/sea/index.d.ts | 148 ++++++++++++++++++------ native/sea/index.js | 5 +- native/sea/src/connection.rs | 161 ++++++++++++++++++++++---- native/sea/src/database.rs | 135 ++++++++++------------ native/sea/src/error.rs | 152 +++++++++++++++++++++---- native/sea/src/lib.rs | 13 ++- native/sea/src/result.rs | 24 +++- native/sea/src/statement.rs | 202 ++++++++++++++++++++++++++++++++- native/sea/src/util.rs | 62 ++++++++++ tests/native/e2e-smoke.test.ts | 106 +++++++++++++++++ tests/native/version.test.ts | 20 ++-- 13 files changed, 891 insertions(+), 178 deletions(-) create mode 100644 native/sea/src/util.rs create mode 100644 tests/native/e2e-smoke.test.ts diff --git a/lib/sea/SeaNativeLoader.ts b/lib/sea/SeaNativeLoader.ts index 638ca6dc..c66cdf33 100644 --- a/lib/sea/SeaNativeLoader.ts +++ b/lib/sea/SeaNativeLoader.ts @@ -35,9 +35,31 @@ // 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; } /** diff --git a/native/sea/Cargo.toml b/native/sea/Cargo.toml index d5c49046..c69fb93a 100644 --- a/native/sea/Cargo.toml +++ b/native/sea/Cargo.toml @@ -35,8 +35,9 @@ napi-derive = "2" databricks-sql-kernel = { path = "../../../../databricks-sql-kernel-sea-WT/async-public-api" } # Tokio is a transitive dep via the kernel and via napi's `async` feature; -# declared explicitly so we can name `tokio::runtime::Handle` directly. -tokio = { version = "1", default-features = false, features = ["rt"] } +# declared explicitly so we can name `tokio::runtime::Handle` and +# `tokio::sync::Mutex` directly. +tokio = { version = "1", default-features = false, features = ["rt", "sync"] } # Lazy `OnceCell` for the captured tokio Handle. once_cell = "1" @@ -46,6 +47,20 @@ once_cell = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } +# `catch_unwind` wrapper around async futures (pattern #8 of the +# napi-rs patterns doc). Transitively a dep of the kernel already, but +# declared here so we can `use FutureExt;` directly. +futures = { version = "0.3", default-features = false, features = ["std"] } + +# Arrow IPC encoding of result batches across the napi boundary. +# `arrow-array` / `arrow-schema` come in via the kernel's public types +# (`RecordBatch`, `SchemaRef`); `arrow-ipc` is for the `StreamWriter` +# we use on the encode side. Versions kept in lock-step with the +# kernel's `arrow-*` deps to avoid two arrow versions in the dep graph. +arrow-array = "57" +arrow-schema = "57" +arrow-ipc = "57" + [build-dependencies] napi-build = "2" diff --git a/native/sea/index.d.ts b/native/sea/index.d.ts index 202deddd..5fb5e902 100644 --- a/native/sea/index.d.ts +++ b/native/sea/index.d.ts @@ -4,57 +4,141 @@ /* auto-generated by NAPI-RS */ /** - * JS-visible connection options. Empty in Round 1b; Round 2 may add - * per-connection scope fields (catalog, schema, session config map). + * JS-visible per-execute options. M0 only carries + * initialCatalog / initialSchema / sessionConfig — parameters and + * per-statement overrides land in M1. */ -export interface ConnectionOptions { - +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 constructor options. Round 2 will populate this with - * real fields (host, warehouseId, auth, …); for the scaffold it is - * intentionally empty so the JS smoke test can call `new Database({})` - * without TypeScript complaining about unknown properties. + * 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 DatabaseOptions { +export interface ConnectionOptions { + /** + * Workspace host, e.g. `adb-…azuredatabricks.net`. The kernel + * normalises this — bare hostnames get `https://` prepended. + */ + hostName: string /** - * Workspace host URL (e.g. `https://workspace.databricks.com`). - * Optional in Round 1b; Round 2 makes it required. + * JDBC-style HTTP path, e.g. `/sql/1.0/warehouses/abc123`. The + * kernel parses out the warehouse id. */ - host?: string - /** Warehouse id. Optional in Round 1b; Round 2 makes it required. */ - warehouseId?: string + 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`). * - * Acts as the round-1b smoke test: a JS `require()` of the `.node` - * artifact that successfully calls `version()` proves the binding's - * build + load + dispatch path is wired correctly. + * 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. Round 1b: marker only; no kernel state. */ +/** + * 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 { /** - * Construct a new connection handle. Round 1b is a no-op shell; - * Round 2 will wire it to `Database`'s `Session` (likely via an - * async `Database::connect()` factory rather than a JS-side - * `new Connection()`). + * Execute a SQL statement and return a Statement handle that + * streams batches via `fetchNextBatch()`. */ - constructor(options: ConnectionOptions) + 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 database handle on the JS side. + * Opaque executed-statement handle. * - * Holds `Option` so `close()` (Round 2) can `.take()` the - * session out and `.await` an async close, leaving `inner = None`. - * The `Drop` impl checks `inner` to decide whether to schedule a - * fire-and-forget close on the captured tokio runtime. + * `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 Database { +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 /** - * Construct a new database handle. Round 1b: the options are - * stashed for diagnostic purposes only — no network call. + * Explicit close. Awaits the server-side close so the JS caller + * can observe failures. */ - constructor(options: DatabaseOptions) + close(): Promise } diff --git a/native/sea/index.js b/native/sea/index.js index 6818d29b..c7551305 100644 --- a/native/sea/index.js +++ b/native/sea/index.js @@ -310,8 +310,9 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { Connection, Database, version } = nativeBinding +const { Connection, openSession, Statement, version } = nativeBinding module.exports.Connection = Connection -module.exports.Database = Database +module.exports.openSession = openSession +module.exports.Statement = Statement module.exports.version = version diff --git a/native/sea/src/connection.rs b/native/sea/src/connection.rs index ad9df612..4afbd724 100644 --- a/native/sea/src/connection.rs +++ b/native/sea/src/connection.rs @@ -12,40 +12,155 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Opaque `Connection` wrapper. +//! Opaque `Connection` wrapper around the kernel's `Session`. //! -//! Round 1b: scaffold only. The kernel collapses ADBC's per-connection -//! state into the `Session` handle held by `Database` (see -//! `database.rs`). The JS-side `Connection` exists for API parity with -//! the existing Node driver but is currently a thin marker; Round 2 -//! decides whether to keep it as a pass-through on `Database` or to -//! attach per-connection scoping (e.g. default catalog/schema overrides). - -/// JS-visible connection options. Empty in Round 1b; Round 2 may add -/// per-connection scope fields (catalog, schema, session config map). +//! The kernel collapses ADBC's `Database` + `Connection` into a single +//! `Session`. We keep the wrapper name `Connection` on the JS side because +//! that matches the existing Node driver's mental model. +//! +//! M0 surface (Round 2): +//! - `Connection.executeStatement(sql, options)` — builds a kernel +//! `Statement`, sets the spec, awaits `execute()`, wraps the result +//! in a JS-visible `Statement` opaque handle. +//! - `Connection.close()` — explicit async close. Drop schedules a +//! fire-and-forget close on the captured runtime handle if explicit +//! close was never called. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +use databricks_sql_kernel::Session; + +use crate::error::napi_err_from_kernel; +use crate::runtime; +use crate::statement::Statement; +use crate::util::guarded; + +/// JS-visible per-execute options. M0 only carries +/// initialCatalog / initialSchema / sessionConfig — parameters and +/// per-statement overrides land in M1. #[napi(object)] -pub struct ConnectionOptions {} +pub struct ExecuteOptions { + /// Default catalog applied to this statement via session conf. + pub initial_catalog: Option, + /// Default schema applied to this statement via session conf. + pub initial_schema: Option, + /// Per-statement session conf overrides (forwarded to SEA + /// `parameters` / Thrift `confOverlay`). + pub session_config: Option>, +} -/// Opaque connection handle. Round 1b: marker only; no kernel state. +/// 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`). #[napi] -pub struct Connection {} +pub struct Connection { + pub(crate) inner: Arc>>, +} #[napi] impl Connection { - /// Construct a new connection handle. Round 1b is a no-op shell; - /// Round 2 will wire it to `Database`'s `Session` (likely via an - /// async `Database::connect()` factory rather than a JS-side - /// `new Connection()`). - #[napi(constructor)] - pub fn new(_options: ConnectionOptions) -> Self { - Connection {} + /// Execute a SQL statement and return a Statement handle that + /// streams batches via `fetchNextBatch()`. + #[napi] + pub async fn execute_statement( + &self, + sql: String, + options: ExecuteOptions, + ) -> napi::Result { + let inner = Arc::clone(&self.inner); + guarded(async move { + let guard = inner.lock().await; + let session = guard.as_ref().ok_or_else(|| { + napi::Error::new(napi::Status::InvalidArg, "connection already closed") + })?; + + // Build a per-statement spec on the kernel's mutable + // Statement. Session conf overrides surface through the + // statement_conf overlay; M0 has no parameter binding. + let mut stmt = session.statement(); + stmt.spec().sql(sql); + + let mut overlay: HashMap = + options.session_config.unwrap_or_default(); + if let Some(catalog) = options.initial_catalog { + overlay.insert("default_catalog".to_string(), catalog); + } + if let Some(schema) = options.initial_schema { + overlay.insert("default_schema".to_string(), schema); + } + if !overlay.is_empty() { + stmt.spec().statement_conf(overlay); + } + + let executed = stmt.execute().await.map_err(napi_err_from_kernel)?; + Ok(Statement::from_executed(executed)) + }) + .await + } + + /// 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. + #[napi] + pub async fn close(&self) -> napi::Result<()> { + let inner = Arc::clone(&self.inner); + guarded(async move { + let _taken = { + let mut guard = inner.lock().await; + guard.take() + }; + // `_taken` drops here. Kernel's `SessionInner::Drop` + // spawns `delete_session` on its captured handle. + Ok(()) + }) + .await } } impl Drop for Connection { fn drop(&mut self) { - // Round 1b: nothing to clean up. Round 2 will populate this - // with the same `runtime::get_handle().spawn(...)` pattern as - // `Database::drop`. + // Fire-and-forget close on the captured runtime. If `close()` + // was already called, `inner` holds `None` and the spawned + // task is a trivial no-op. + let Some(handle) = runtime::try_get_handle() else { + // No async entry point ever ran — there's nothing to close. + return; + }; + let inner = Arc::clone(&self.inner); + handle.spawn(async move { + // Drop the session value on the runtime. The kernel's + // `SessionInner::Drop` already spawns a fire-and-forget + // `delete_session` against its own captured handle. We do + // NOT call `Session::close().await` here because that + // method holds a `tracing::EnteredSpan` (`!Send`) across + // its body, which would conflict with `Handle::spawn`'s + // `Send` bound on the future. + let _taken = { + let mut guard = inner.lock().await; + guard.take() + }; + // `_taken` drops here; kernel's SessionInner::Drop fires. + }); } } diff --git a/native/sea/src/database.rs b/native/sea/src/database.rs index 800ca090..7f86760e 100644 --- a/native/sea/src/database.rs +++ b/native/sea/src/database.rs @@ -12,88 +12,79 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Opaque `Database` wrapper around the kernel's `Session` handle. -//! -//! Round 1b: scaffold only — `constructor` stores options and returns -//! immediately. Round 2 will add `open()` (calling `Session::open`), -//! `statement()`, `close()`, etc. +//! `openSession()` — the binding's session-construction entry point. //! //! The kernel collapses ADBC's `Database` + `Connection` into a single -//! `Session`. We keep the wrapper name `Database` on the JS side -//! because that matches the existing Node driver's mental model; the -//! actual session lives inside this struct. +//! `Session`. The TS adapter layer reconstructs a `DBSQLClient` / +//! `Database` wrapper on top of this binding, so the napi surface itself +//! stays flat: one free function, one opaque `Connection` class. +//! +//! Rationale for a free function over a static class method: +//! - napi-rs v2's static-method codegen for async functions returning a +//! `#[napi]` struct is fragile — the runtime registration sometimes +//! omits the method from the class object. Free `#[napi]` functions +//! go through a different, more stable codegen path. +//! - There is no kernel-side `Database` state to wrap; everything +//! meaningful lives on `Session`. A wrapper class with no fields adds +//! a JS object allocation per session for no benefit. -use databricks_sql_kernel::Session; +use std::sync::Arc; +use tokio::sync::Mutex; +use databricks_sql_kernel::{AuthConfig, Session}; + +use crate::connection::Connection; +use crate::error::napi_err_from_kernel; use crate::runtime; +use crate::util::guarded; -/// JS-visible constructor options. Round 2 will populate this with -/// real fields (host, warehouseId, auth, …); for the scaffold it is -/// intentionally empty so the JS smoke test can call `new Database({})` -/// without TypeScript complaining about unknown properties. +/// 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. #[napi(object)] -pub struct DatabaseOptions { - /// Workspace host URL (e.g. `https://workspace.databricks.com`). - /// Optional in Round 1b; Round 2 makes it required. - pub host: Option, - /// Warehouse id. Optional in Round 1b; Round 2 makes it required. - pub warehouse_id: Option, +pub struct ConnectionOptions { + /// Workspace host, e.g. `adb-…azuredatabricks.net`. The kernel + /// normalises this — bare hostnames get `https://` prepended. + pub host_name: String, + /// JDBC-style HTTP path, e.g. `/sql/1.0/warehouses/abc123`. The + /// kernel parses out the warehouse id. + pub http_path: String, + /// Personal access token. Must be non-empty (the kernel rejects + /// empty PATs at session construction). + pub token: String, } -/// Opaque database handle on the JS side. +/// Open a Databricks SQL session over PAT auth and return an opaque +/// `Connection` wrapping the kernel `Session`. /// -/// Holds `Option` so `close()` (Round 2) can `.take()` the -/// session out and `.await` an async close, leaving `inner = None`. -/// The `Drop` impl checks `inner` to decide whether to schedule a -/// fire-and-forget close on the captured tokio runtime. +/// The JS-visible name is `openSession` (napi-rs converts snake_case +/// to camelCase for free functions). #[napi] -pub struct Database { - // TODO(round-2): populate this from `Session::open(config).await` - // inside an `open()` async method (or directly inside the - // constructor via a factory pattern). For now it stays `None` so - // Drop has nothing to clean up. - inner: Option, -} - -#[napi] -impl Database { - /// Construct a new database handle. Round 1b: the options are - /// stashed for diagnostic purposes only — no network call. - #[napi(constructor)] - pub fn new(_options: DatabaseOptions) -> Self { - Database { inner: None } - } -} +pub async fn open_session(options: ConnectionOptions) -> napi::Result { + guarded(async move { + // Cache the napi-rs tokio Handle on the very first async call + // so Drop impls (which run on the V8 GC thread, outside any + // tokio context) can still `spawn` cleanup tasks onto the + // runtime that's driving this future. + let _ = runtime::get_handle(); -impl Drop for Database { - fn drop(&mut self) { - // Pattern #5 from the napi-rs patterns doc: spawn cleanup on - // the captured runtime handle. We only enter this branch if - // the JS user dropped the handle without calling `close()` - // first (which Round 2 will provide). For Round 1b there is - // nothing to clean up, but the pattern is in place so the - // Round-2 work is a one-line addition. - let Some(session) = self.inner.take() else { - return; - }; - let Some(handle) = runtime::try_get_handle() else { - // No async entry point has ever run, so there cannot be a - // live `Session` either — but the destructor of `Session` - // itself uses the kernel's own borrowed handle, so we - // simply let it run. - drop(session); - return; - }; - // The kernel's `SessionInner::Drop` already spawns a - // fire-and-forget `delete_session` on its own captured runtime - // handle. To stay on napi-rs's runtime explicitly (so Round 2 - // can add binding-side cleanup steps before the kernel drop), - // hop onto a tokio task and let the kernel destructor run - // there. We do NOT call `Session::close().await` because that - // method enters a tracing span (`EnteredSpan` is `!Send`) and - // therefore cannot cross an `await` boundary inside a `spawn`. - handle.spawn(async move { - drop(session); - }); - } + // SessionConfig is `#[non_exhaustive]` — go through the + // builder, which is the only public path that constructs it. + // `http_path()` is the convenience setter that maps a bare + // hostname + `/sql/1.0/warehouses/{id}` path into the kernel's + // `ConnectionConfig`. + let session = Session::builder() + .http_path(options.host_name, options.http_path) + .auth(AuthConfig::Pat { + token: options.token, + }) + .open() + .await + .map_err(napi_err_from_kernel)?; + Ok(Connection { + inner: Arc::new(Mutex::new(Some(session))), + }) + }) + .await } diff --git a/native/sea/src/error.rs b/native/sea/src/error.rs index fc82a0b4..d06e1600 100644 --- a/native/sea/src/error.rs +++ b/native/sea/src/error.rs @@ -12,34 +12,142 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Minimal kernel-error → `napi::Error` mapping. +//! Kernel-error → `napi::Error` mapping. //! -//! Round 1b: just preserves the kernel error message and translates -//! the kernel's [`ErrorCode`] into a small set of napi statuses. Round -//! 2 will add a full taxonomy (sqlState, vendorCode, retryable, …) -//! attached as own-properties on the JS error object via -//! `Env::create_error` (pattern #7 in the napi-rs patterns doc). +//! The kernel returns a richly-typed [`Error`](databricks_sql_kernel::Error) +//! with `code`, `sql_state`, `error_code`, `vendor_code`, `http_status`, +//! `retryable`, and `query_id` fields. The napi `Error` type only +//! carries `status` + `reason` directly — to attach the extra fields +//! as own-properties on the JS error object we'd need an `Env` +//! reference, which `#[napi] async fn` bodies don't have access to +//! cheaply. +//! +//! Compromise (one helper, DRY): encode the structured metadata into +//! the `reason` field as a JSON envelope prefixed with a sentinel +//! `__databricks_error__:` token. The TS adapter detects the sentinel, +//! parses the payload, and reconstructs the typed error class +//! (`DBSQLError`, `AuthError`, …). Plain-string errors from the +//! binding's own code paths fall through the sentinel detection +//! unchanged. +//! +//! Round 3 may switch to the `Env::create_error` + own-properties +//! pattern once we have a stable point in each entry where `env: Env` +//! is available (likely by wrapping the async glue in a sync entry +//! point that calls `tokio::spawn` after capturing `env`). use databricks_sql_kernel::{Error as KernelError, ErrorCode}; use napi::{Error as NapiError, Status}; -/// Map a kernel `Error` into a `napi::Error`. The kernel `ErrorCode` -/// is used to pick a sensible napi `Status`; the kernel message is -/// preserved verbatim as the error reason. -/// -/// Round 1b has no callers — the scaffold doesn't return any kernel -/// errors yet. Round 2's `Database::open()` is the first consumer. -#[allow(dead_code)] +/// Sentinel that tells the TS adapter the `reason` string is a JSON +/// envelope rather than a plain message. Has to be ASCII-only so it +/// survives any `String` round-trip the napi layer might do. +pub(crate) const ERROR_SENTINEL: &str = "__databricks_error__:"; + +/// Map a kernel [`Error`] into a `napi::Error`. Preserves the kernel +/// `ErrorCode` (mapped to the closest napi `Status`), and stuffs the +/// remaining structured fields into a JSON envelope on the reason so +/// the TS layer can reconstruct the typed error class. pub(crate) fn napi_err_from_kernel(e: KernelError) -> NapiError { - let status = match e.code { - ErrorCode::InvalidArgument | ErrorCode::InvalidStatementHandle => { - Status::InvalidArg - } + let status = status_from_kernel_code(e.code); + + // Build a minimal JSON envelope. We hand-build it (no serde_json + // dep) — the field set is small and fixed, and avoiding serde + // keeps the crate dep graph trim. + let mut envelope = String::with_capacity(e.message.len() + 128); + envelope.push_str(ERROR_SENTINEL); + envelope.push('{'); + push_json_str_field(&mut envelope, "code", error_code_str(e.code)); + envelope.push(','); + push_json_str_field(&mut envelope, "message", &e.message); + if let Some(s) = &e.sql_state { + envelope.push(','); + push_json_str_field(&mut envelope, "sqlState", s); + } + if let Some(ec) = &e.error_code { + envelope.push(','); + push_json_str_field(&mut envelope, "errorCode", ec); + } + if let Some(vc) = e.vendor_code { + envelope.push(','); + envelope.push_str("\"vendorCode\":"); + envelope.push_str(&vc.to_string()); + } + if let Some(hs) = e.http_status { + envelope.push(','); + envelope.push_str("\"httpStatus\":"); + envelope.push_str(&hs.to_string()); + } + if e.retryable { + envelope.push_str(",\"retryable\":true"); + } + if let Some(qid) = &e.query_id { + envelope.push(','); + push_json_str_field(&mut envelope, "queryId", qid); + } + envelope.push('}'); + + NapiError::new(status, envelope) +} + +/// Map kernel `ErrorCode` → napi `Status`. The status is mostly +/// cosmetic on the napi side (the TS layer dispatches on `code` from +/// the envelope); we pick the closest match so unwrapped errors still +/// look reasonable in raw napi consumers. +fn status_from_kernel_code(code: ErrorCode) -> Status { + match code { + ErrorCode::InvalidArgument | ErrorCode::InvalidStatementHandle => Status::InvalidArg, ErrorCode::Cancelled => Status::Cancelled, - // Everything else collapses to `GenericFailure`; Round 2 - // refines this with sqlState / vendorCode / category own- - // properties on a JS error object. _ => Status::GenericFailure, - }; - NapiError::new(status, e.message) + } +} + +/// String tag for each kernel `ErrorCode` — stable across kernel +/// versions because v0's `ErrorCode` is `#[non_exhaustive]` and we +/// pattern-match exhaustively against the known set. +fn error_code_str(code: ErrorCode) -> &'static str { + match code { + ErrorCode::InvalidArgument => "InvalidArgument", + ErrorCode::Unauthenticated => "Unauthenticated", + ErrorCode::PermissionDenied => "PermissionDenied", + ErrorCode::NotFound => "NotFound", + ErrorCode::ResourceExhausted => "ResourceExhausted", + ErrorCode::Unavailable => "Unavailable", + ErrorCode::Timeout => "Timeout", + ErrorCode::Cancelled => "Cancelled", + ErrorCode::DataLoss => "DataLoss", + ErrorCode::Internal => "Internal", + ErrorCode::InvalidStatementHandle => "InvalidStatementHandle", + ErrorCode::NetworkError => "NetworkError", + ErrorCode::SqlError => "SqlError", + // Forward-compat: ErrorCode is `#[non_exhaustive]`. Any new + // variant the kernel adds in v0.x lands here until we mirror + // it in this match. The TS layer treats Unknown as a generic + // failure. + _ => "Unknown", + } +} + +/// Append `"key":"value"` to the JSON buffer, escaping the value's +/// `"` and `\` characters and control chars to keep the envelope +/// JSON-parseable. The narrow set of escapes is sufficient for the +/// human-readable error messages the kernel produces (no embedded +/// binary blobs, no Unicode surrogate pairs). +fn push_json_str_field(out: &mut String, key: &str, value: &str) { + out.push('"'); + out.push_str(key); + out.push_str("\":\""); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); } diff --git a/native/sea/src/lib.rs b/native/sea/src/lib.rs index 7d76cf9b..6de102ea 100644 --- a/native/sea/src/lib.rs +++ b/native/sea/src/lib.rs @@ -15,9 +15,10 @@ //! `databricks-sea-native` — napi-rs binding crate for the Databricks //! SQL Node.js driver's SEA (Statement Execution API) path. //! -//! Round 1b scaffold: module skeletons + a single working `version()` -//! `#[napi]` function that proves the binding loads end-to-end. Round 2 -//! adds `Database::open` / `Statement::execute` / fetch / cancel. +//! Round 2 surface: `Database.open` → `Connection.execute_statement` +//! → `Statement.fetch_next_batch` / `schema` / `cancel` / `close`. +//! Results cross the FFI as Arrow IPC bytes (see `result.rs`); the +//! TS adapter decodes them via `apache-arrow`. #![deny(unsafe_op_in_unsafe_fn)] @@ -31,12 +32,12 @@ pub(crate) mod logger; pub(crate) mod result; pub(crate) mod runtime; pub(crate) mod statement; +pub(crate) mod util; /// Returns the native binding's crate version (`CARGO_PKG_VERSION`). /// -/// Acts as the round-1b smoke test: a JS `require()` of the `.node` -/// artifact that successfully calls `version()` proves the binding's -/// build + load + dispatch path is wired correctly. +/// Originally the round-1b smoke test; kept as a cheap "is the binding +/// loaded?" probe for the JS-side loader's structured diagnostics. #[napi] pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string() diff --git a/native/sea/src/result.rs b/native/sea/src/result.rs index f406c363..488c0851 100644 --- a/native/sea/src/result.rs +++ b/native/sea/src/result.rs @@ -12,7 +12,25 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! ResultStream wrapper. +//! Arrow IPC payload types crossed across the napi boundary. //! -//! Round 2 work. Empty in Round 1b — see `statement.rs` for the same -//! reasoning. +//! Per sea-design.md Layer 2: "The binding ships the batch across the +//! FFI as Arrow IPC bytes. The adapter converts those bytes into +//! JavaScript rows…" — so the napi boundary is intentionally narrow: +//! one envelope per batch, one envelope per schema. + +use napi::bindgen_prelude::Buffer; + +/// A single Arrow IPC stream payload encoding one record batch (plus +/// the schema header so the JS-side reader is stateless). +#[napi(object)] +pub struct ArrowBatch { + pub ipc_bytes: Buffer, +} + +/// An Arrow IPC stream payload encoding just the result schema (no +/// record-batch messages). Returned by `Statement.schema()`. +#[napi(object)] +pub struct ArrowSchema { + pub ipc_bytes: Buffer, +} diff --git a/native/sea/src/statement.rs b/native/sea/src/statement.rs index c449b402..6d7b8761 100644 --- a/native/sea/src/statement.rs +++ b/native/sea/src/statement.rs @@ -12,9 +12,201 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Statement / ExecutedStatement wrappers. +//! Opaque `Statement` wrapper around the kernel's `ExecutedStatement`. //! -//! Round 2 work. This module is intentionally empty in Round 1b — no -//! `#[napi]` types here yet. Adding empty stubs would require -//! `napi-rs` to generate JS bindings for them, which adds noise to the -//! `index.d.ts` without any callable surface. +//! M0 surface (Round 2): +//! - `Statement.fetchNextBatch() -> Option` — drives +//! `ResultStream::next_batch().await`, serialises the borrowed +//! `RecordBatch` to Arrow IPC bytes, returns them to JS. +//! - `Statement.schema() -> ArrowSchema` — returns the cached schema +//! from the kernel side, serialised as a schema-only IPC payload. +//! - `Statement.cancel()` / `Statement.close()` — forwards to +//! `ExecutedStatement::cancel/close` via the +//! `ExecutedStatementHandle` trait. Drop fires-and-forgets close +//! if not already explicitly closed. + +use std::sync::Arc; +use tokio::sync::Mutex; + +use arrow_ipc::writer::StreamWriter; +use databricks_sql_kernel::{ExecutedStatement, ExecutedStatementHandle, ResultBatch}; + +use crate::error::napi_err_from_kernel; +use crate::result::{ArrowBatch, ArrowSchema}; +use crate::runtime; +use crate::util::guarded; + +/// 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`. +#[napi] +pub struct Statement { + inner: Arc>>, +} + +impl Statement { + /// Crate-internal constructor — called from + /// `Connection::execute_statement` once the kernel hands back the + /// `ExecutedStatement`. + pub(crate) fn from_executed(executed: ExecutedStatement) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(executed))), + } + } +} + +#[napi] +impl 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`. + #[napi] + pub async fn fetch_next_batch(&self) -> napi::Result> { + let inner = Arc::clone(&self.inner); + guarded(async move { + let mut guard = inner.lock().await; + let executed = guard.as_mut().ok_or_else(|| { + napi::Error::new(napi::Status::InvalidArg, "statement already closed") + })?; + + let stream = executed.result_stream_mut(); + // Capture the schema before borrowing the next batch — we + // include the schema header in every IPC payload so the + // JS-side consumer can decode each batch independently + // without carrying state across calls. + let schema = stream.schema(); + let maybe_batch = stream.next_batch().await.map_err(napi_err_from_kernel)?; + let Some(batch) = maybe_batch else { + return Ok(None); + }; + // `ResultBatch` is `#[non_exhaustive]`; v0 only ever + // yields `Arrow`. The error arm exists for forward + // compat — v1+ may add ColumnarThrift / JsonRows / etc., + // and we want the binding to surface that as a typed + // error rather than silently misbehaving. + let record_batch = match batch { + ResultBatch::Arrow(rb) => rb, + _ => { + return Err(napi::Error::new( + napi::Status::GenericFailure, + "non-Arrow ResultBatch variant — binding needs upgrade", + )); + } + }; + let bytes = encode_ipc_stream(&schema, Some(record_batch))?; + Ok(Some(ArrowBatch { + ipc_bytes: bytes.into(), + })) + }) + .await + } + + /// Result schema as an Arrow IPC payload (schema header only, no + /// record-batch message). Available before any batches have been + /// fetched. + #[napi] + pub async fn schema(&self) -> napi::Result { + let inner = Arc::clone(&self.inner); + guarded(async move { + let guard = inner.lock().await; + let executed = guard.as_ref().ok_or_else(|| { + napi::Error::new(napi::Status::InvalidArg, "statement already closed") + })?; + let schema = executed.schema(); + let bytes = encode_ipc_stream(&schema, None)?; + Ok(ArrowSchema { + ipc_bytes: bytes.into(), + }) + }) + .await + } + + /// Server-side cancel. No-op if already finished. + #[napi] + pub async fn cancel(&self) -> napi::Result<()> { + let inner = Arc::clone(&self.inner); + guarded(async move { + let guard = inner.lock().await; + let executed = guard.as_ref().ok_or_else(|| { + napi::Error::new(napi::Status::InvalidArg, "statement already closed") + })?; + executed.cancel().await.map_err(napi_err_from_kernel) + }) + .await + } + + /// Explicit close. Awaits the server-side close so the JS caller + /// can observe failures. + #[napi] + pub async fn close(&self) -> napi::Result<()> { + let inner = Arc::clone(&self.inner); + guarded(async move { + // Take the handle out so `Drop` knows there's nothing left + // to clean up. + let executed = { + let mut guard = inner.lock().await; + guard.take() + }; + if let Some(executed) = executed { + executed.close().await.map_err(napi_err_from_kernel)?; + } + Ok(()) + }) + .await + } +} + +impl Drop for Statement { + fn drop(&mut self) { + let Some(handle) = runtime::try_get_handle() else { + return; + }; + let inner = Arc::clone(&self.inner); + handle.spawn(async move { + // Drop the executed statement on the runtime. The kernel's + // `ExecutedStatement::Drop` already spawns a fire-and-forget + // `close_statement` against its own captured handle, so we + // just need to ensure the value is dropped inside a tokio + // context (the kernel's Drop reads `runtime_handle.clone()` + // and spawns; that handle is the same one we captured here). + let _taken = { + let mut guard = inner.lock().await; + guard.take() + }; + }); + } +} + +/// Encode an Arrow schema (and optional one record batch) as an IPC +/// stream payload. Used for both `schema()` (schema only) and +/// `fetchNextBatch()` (schema + one batch). Returning a self-contained +/// IPC stream per call is wasteful header-wise but lets the JS adapter +/// stay stateless — it decodes each `ipcBytes` independently via the +/// same `apache-arrow` `RecordBatchReader` path. +fn encode_ipc_stream( + schema: &arrow_schema::SchemaRef, + batch: Option<&arrow_array::RecordBatch>, +) -> napi::Result> { + let mut buf: Vec = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buf, schema) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; + if let Some(rb) = batch { + writer + .write(rb) + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; + } + writer + .finish() + .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; + } + Ok(buf) +} diff --git a/native/sea/src/util.rs b/native/sea/src/util.rs new file mode 100644 index 00000000..4ba7e346 --- /dev/null +++ b/native/sea/src/util.rs @@ -0,0 +1,62 @@ +// 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. + +//! Shared helpers — one place for the `catch_unwind` wrapping that +//! every async entry point goes through (pattern #8 in the napi-rs +//! patterns doc). One helper, called once per entry point — DRY. +//! +//! Why a helper rather than a macro: helper + `async move {}` reads +//! better at call sites and keeps the stack trace shallow when a panic +//! actually fires (a macro would expand into the caller's body). + +use std::any::Any; +use std::future::Future; +use std::panic::AssertUnwindSafe; + +use futures::FutureExt; +use napi::{Error as NapiError, Result as NapiResult, Status}; + +/// Run `fut` and convert any panic the future raises into a +/// `napi::Error` so the JS caller sees a rejected promise instead of +/// the Node process aborting. +/// +/// `catch_unwind` does not catch `std::process::abort`, double-panic, +/// or allocator OOM — those still bring down the process. That's by +/// design: a corrupted process state isn't something we can pretend to +/// recover from. +pub(crate) async fn guarded(fut: F) -> NapiResult +where + F: Future>, +{ + match AssertUnwindSafe(fut).catch_unwind().await { + Ok(res) => res, + Err(panic) => Err(NapiError::new( + Status::GenericFailure, + format!("panic in native binding: {}", panic_payload_msg(panic)), + )), + } +} + +/// Best-effort downcast of a panic payload to a human-readable string. +/// `panic!("…")` produces `&'static str` or `String`; the rest fall +/// through to a generic marker so the JS caller still sees *something*. +fn panic_payload_msg(p: Box) -> String { + if let Some(s) = p.downcast_ref::<&'static str>() { + return (*s).to_string(); + } + if let Some(s) = p.downcast_ref::() { + return s.clone(); + } + "non-string panic payload".to_string() +} 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 index 03210c3c..72a69f43 100644 --- a/tests/native/version.test.ts +++ b/tests/native/version.test.ts @@ -18,23 +18,21 @@ import { version, getSeaNative } from '../../lib/sea/SeaNativeLoader'; describe('SEA native binding — smoke test', () => { it('loads the .node artifact and returns version()', () => { const v = version(); - // Round 1b: the native crate is at 0.1.0. Match the shape rather - // than the literal so the test does not need updating on every - // version bump. expect(v).to.match(/^\d+\.\d+\.\d+$/); }); - it('exposes the Database opaque class', () => { - const binding = getSeaNative() as unknown as { Database: new (opts: object) => object }; - expect(typeof binding.Database).to.equal('function'); - const db = new binding.Database({}); - expect(db).to.be.an('object'); + 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: new (opts: object) => object }; + const binding = getSeaNative() as unknown as { Connection: Function }; expect(typeof binding.Connection).to.equal('function'); - const conn = new binding.Connection({}); - expect(conn).to.be.an('object'); + }); + + it('exposes the Statement opaque class', () => { + const binding = getSeaNative() as unknown as { Statement: Function }; + expect(typeof binding.Statement).to.equal('function'); }); }); From 685098a0b40dce27d1bb68eb128152da63e315fa Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Fri, 15 May 2026 01:26:17 +0000 Subject: [PATCH 3/5] =?UTF-8?q?sea-napi-binding:=20cleanup=20=E2=80=94=20d?= =?UTF-8?q?rop=20unused=20tracing=20deps;=20address=20bloat=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 scaffold declared tracing + tracing-subscriber as deps but never used them. Removed. Logger bridge will re-add in round 3. Other findings from 6b3affd-2026-05-15.md reviewed: - Finding 2 (Database::Drop unreachable in Round 1b) — obsoleted by Round 2 (40d0b57): database.rs no longer declares a Database struct or Drop impl; it is now an `open_session` free function. - Finding 3 (empty Connection::Drop) — obsoleted by Round 2: the Drop impl now spawns a real fire-and-forget close on the captured tokio handle. Co-authored-by: Isaac --- native/sea/Cargo.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/native/sea/Cargo.toml b/native/sea/Cargo.toml index c69fb93a..c001e04b 100644 --- a/native/sea/Cargo.toml +++ b/native/sea/Cargo.toml @@ -42,11 +42,6 @@ tokio = { version = "1", default-features = false, features = ["rt", "sync"] } # Lazy `OnceCell` for the captured tokio Handle. once_cell = "1" -# Tracing for kernel + binding diagnostics. The real subscriber is wired -# in Round 3 via the ThreadsafeFunction logger bridge. -tracing = "0.1" -tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } - # `catch_unwind` wrapper around async futures (pattern #8 of the # napi-rs patterns doc). Transitively a dep of the kernel already, but # declared here so we can `use FutureExt;` directly. From 5c04082e5c060b368891750c1cc3743be7d7a20d Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Fri, 15 May 2026 09:02:08 +0000 Subject: [PATCH 4/5] sea-napi-binding: relocate Rust source to kernel workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per D-006 architectural decision (Python team's workspace pattern): all language bindings (PyO3, napi-rs) now live as workspace siblings in the kernel repo at databricks-sql-kernel/{pyo3,napi}/. What this commit removes from the nodejs repo: - native/sea/Cargo.toml (path dep relocated; package now at databricks-sql-kernel/napi/Cargo.toml with path = "..") - native/sea/build.rs - native/sea/src/* (lib, runtime, database, connection, statement, result, error, logger, util — all 9 files) - native/sea/package.json (the @databricks/sea-native-linux-x64-gnu sub-package moves to the kernel workspace too) - native/sea/index.js (regenerated artifact) What stays in nodejs: - native/sea/index.d.ts — TS declarations consumed by lib/sea/ adapter - native/sea/README.md (new) — explains the move; points readers at databricks-sql-kernel/napi/ What's updated: - package.json: `build:native` and `build:native:debug` scripts now delegate to the kernel workspace via $DATABRICKS_SQL_KERNEL_REPO (defaults to ../../databricks-sql-kernel-sea-WT/napi-binding for the local dev worktree layout). Build copies index.node + index.d.ts back into native/sea/ for the loader to find. Why workspace co-location: - Arrow version pinning lockstep — no silent IPC version drift - path = ".." (clean) vs ../../../../databricks-sql-kernel-sea-WT/... - Single CI: cargo build --workspace covers kernel + pyo3 + napi - Kernel API changes that break either binding caught at PR-review time - Future cgo binding for Go SEA slots in as another workspace member This branch (sea-napi-binding) is now a thin consumer of the kernel napi crate. The actual Rust code lives at krn-napi-binding HEAD on the kernel repo (commit debe3d7). --- native/sea/.gitignore | 7 - native/sea/Cargo.toml | 64 ------- native/sea/README.md | 41 +++++ native/sea/build.rs | 17 -- native/sea/index.js | 318 ----------------------------------- native/sea/package.json | 23 --- native/sea/src/connection.rs | 166 ------------------ native/sea/src/database.rs | 90 ---------- native/sea/src/error.rs | 153 ----------------- native/sea/src/lib.rs | 44 ----- native/sea/src/logger.rs | 17 -- native/sea/src/result.rs | 36 ---- native/sea/src/runtime.rs | 56 ------ native/sea/src/statement.rs | 212 ----------------------- native/sea/src/util.rs | 62 ------- package.json | 6 +- 16 files changed, 44 insertions(+), 1268 deletions(-) delete mode 100644 native/sea/.gitignore delete mode 100644 native/sea/Cargo.toml create mode 100644 native/sea/README.md delete mode 100644 native/sea/build.rs delete mode 100644 native/sea/index.js delete mode 100644 native/sea/package.json delete mode 100644 native/sea/src/connection.rs delete mode 100644 native/sea/src/database.rs delete mode 100644 native/sea/src/error.rs delete mode 100644 native/sea/src/lib.rs delete mode 100644 native/sea/src/logger.rs delete mode 100644 native/sea/src/result.rs delete mode 100644 native/sea/src/runtime.rs delete mode 100644 native/sea/src/statement.rs delete mode 100644 native/sea/src/util.rs diff --git a/native/sea/.gitignore b/native/sea/.gitignore deleted file mode 100644 index 92ba58de..00000000 --- a/native/sea/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Rust build artifacts -target/ -Cargo.lock - -# Platform-specific `.node` binaries are produced per-platform by the -# bundling feature; not committed. -*.node diff --git a/native/sea/Cargo.toml b/native/sea/Cargo.toml deleted file mode 100644 index c001e04b..00000000 --- a/native/sea/Cargo.toml +++ /dev/null @@ -1,64 +0,0 @@ -# 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. - -[package] -name = "databricks-sea-native" -version = "0.1.0" -edition = "2021" -authors = ["Databricks"] -license = "Apache-2.0" -description = "Databricks SQL Node.js SEA native binding (napi-rs)" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -# napi-rs v2 line; `napi6` enables N-API 6 surface, `async` enables the -# `#[napi] async fn` glue that drives futures on napi-rs's tokio runtime. -napi = { version = "2", default-features = false, features = ["napi6", "async"] } -napi-derive = "2" - -# Kernel — path dep on the async-public-api branch worktree. Once the -# kernel is published this becomes a version dep. -databricks-sql-kernel = { path = "../../../../databricks-sql-kernel-sea-WT/async-public-api" } - -# Tokio is a transitive dep via the kernel and via napi's `async` feature; -# declared explicitly so we can name `tokio::runtime::Handle` and -# `tokio::sync::Mutex` directly. -tokio = { version = "1", default-features = false, features = ["rt", "sync"] } - -# Lazy `OnceCell` for the captured tokio Handle. -once_cell = "1" - -# `catch_unwind` wrapper around async futures (pattern #8 of the -# napi-rs patterns doc). Transitively a dep of the kernel already, but -# declared here so we can `use FutureExt;` directly. -futures = { version = "0.3", default-features = false, features = ["std"] } - -# Arrow IPC encoding of result batches across the napi boundary. -# `arrow-array` / `arrow-schema` come in via the kernel's public types -# (`RecordBatch`, `SchemaRef`); `arrow-ipc` is for the `StreamWriter` -# we use on the encode side. Versions kept in lock-step with the -# kernel's `arrow-*` deps to avoid two arrow versions in the dep graph. -arrow-array = "57" -arrow-schema = "57" -arrow-ipc = "57" - -[build-dependencies] -napi-build = "2" - -[profile.release] -lto = true -strip = "symbols" 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/build.rs b/native/sea/build.rs deleted file mode 100644 index 398bb2da..00000000 --- a/native/sea/build.rs +++ /dev/null @@ -1,17 +0,0 @@ -// 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. - -fn main() { - napi_build::setup(); -} diff --git a/native/sea/index.js b/native/sea/index.js deleted file mode 100644 index c7551305..00000000 --- a/native/sea/index.js +++ /dev/null @@ -1,318 +0,0 @@ -/* 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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/sea-native-linux-x64-gnu-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/native/sea/package.json b/native/sea/package.json deleted file mode 100644 index 96d116dd..00000000 --- a/native/sea/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@databricks/sea-native-linux-x64-gnu", - "version": "0.1.0", - "description": "Databricks SQL Node.js SEA native binding (linux-x64-gnu).", - "main": "index.js", - "types": "index.d.ts", - "files": [ - "index.js", - "index.d.ts", - "*.node" - ], - "license": "Apache-2.0", - "engines": { - "node": ">=14.0.0" - }, - "napi": { - "binaryName": "sea-native", - "targets": [ - "x86_64-unknown-linux-gnu" - ] - }, - "private": true -} diff --git a/native/sea/src/connection.rs b/native/sea/src/connection.rs deleted file mode 100644 index 4afbd724..00000000 --- a/native/sea/src/connection.rs +++ /dev/null @@ -1,166 +0,0 @@ -// 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. - -//! Opaque `Connection` wrapper around the kernel's `Session`. -//! -//! The kernel collapses ADBC's `Database` + `Connection` into a single -//! `Session`. We keep the wrapper name `Connection` on the JS side because -//! that matches the existing Node driver's mental model. -//! -//! M0 surface (Round 2): -//! - `Connection.executeStatement(sql, options)` — builds a kernel -//! `Statement`, sets the spec, awaits `execute()`, wraps the result -//! in a JS-visible `Statement` opaque handle. -//! - `Connection.close()` — explicit async close. Drop schedules a -//! fire-and-forget close on the captured runtime handle if explicit -//! close was never called. - -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::Mutex; - -use databricks_sql_kernel::Session; - -use crate::error::napi_err_from_kernel; -use crate::runtime; -use crate::statement::Statement; -use crate::util::guarded; - -/// JS-visible per-execute options. M0 only carries -/// initialCatalog / initialSchema / sessionConfig — parameters and -/// per-statement overrides land in M1. -#[napi(object)] -pub struct ExecuteOptions { - /// Default catalog applied to this statement via session conf. - pub initial_catalog: Option, - /// Default schema applied to this statement via session conf. - pub initial_schema: Option, - /// Per-statement session conf overrides (forwarded to SEA - /// `parameters` / Thrift `confOverlay`). - pub session_config: Option>, -} - -/// 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`). -#[napi] -pub struct Connection { - pub(crate) inner: Arc>>, -} - -#[napi] -impl Connection { - /// Execute a SQL statement and return a Statement handle that - /// streams batches via `fetchNextBatch()`. - #[napi] - pub async fn execute_statement( - &self, - sql: String, - options: ExecuteOptions, - ) -> napi::Result { - let inner = Arc::clone(&self.inner); - guarded(async move { - let guard = inner.lock().await; - let session = guard.as_ref().ok_or_else(|| { - napi::Error::new(napi::Status::InvalidArg, "connection already closed") - })?; - - // Build a per-statement spec on the kernel's mutable - // Statement. Session conf overrides surface through the - // statement_conf overlay; M0 has no parameter binding. - let mut stmt = session.statement(); - stmt.spec().sql(sql); - - let mut overlay: HashMap = - options.session_config.unwrap_or_default(); - if let Some(catalog) = options.initial_catalog { - overlay.insert("default_catalog".to_string(), catalog); - } - if let Some(schema) = options.initial_schema { - overlay.insert("default_schema".to_string(), schema); - } - if !overlay.is_empty() { - stmt.spec().statement_conf(overlay); - } - - let executed = stmt.execute().await.map_err(napi_err_from_kernel)?; - Ok(Statement::from_executed(executed)) - }) - .await - } - - /// 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. - #[napi] - pub async fn close(&self) -> napi::Result<()> { - let inner = Arc::clone(&self.inner); - guarded(async move { - let _taken = { - let mut guard = inner.lock().await; - guard.take() - }; - // `_taken` drops here. Kernel's `SessionInner::Drop` - // spawns `delete_session` on its captured handle. - Ok(()) - }) - .await - } -} - -impl Drop for Connection { - fn drop(&mut self) { - // Fire-and-forget close on the captured runtime. If `close()` - // was already called, `inner` holds `None` and the spawned - // task is a trivial no-op. - let Some(handle) = runtime::try_get_handle() else { - // No async entry point ever ran — there's nothing to close. - return; - }; - let inner = Arc::clone(&self.inner); - handle.spawn(async move { - // Drop the session value on the runtime. The kernel's - // `SessionInner::Drop` already spawns a fire-and-forget - // `delete_session` against its own captured handle. We do - // NOT call `Session::close().await` here because that - // method holds a `tracing::EnteredSpan` (`!Send`) across - // its body, which would conflict with `Handle::spawn`'s - // `Send` bound on the future. - let _taken = { - let mut guard = inner.lock().await; - guard.take() - }; - // `_taken` drops here; kernel's SessionInner::Drop fires. - }); - } -} diff --git a/native/sea/src/database.rs b/native/sea/src/database.rs deleted file mode 100644 index 7f86760e..00000000 --- a/native/sea/src/database.rs +++ /dev/null @@ -1,90 +0,0 @@ -// 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. - -//! `openSession()` — the binding's session-construction entry point. -//! -//! The kernel collapses ADBC's `Database` + `Connection` into a single -//! `Session`. The TS adapter layer reconstructs a `DBSQLClient` / -//! `Database` wrapper on top of this binding, so the napi surface itself -//! stays flat: one free function, one opaque `Connection` class. -//! -//! Rationale for a free function over a static class method: -//! - napi-rs v2's static-method codegen for async functions returning a -//! `#[napi]` struct is fragile — the runtime registration sometimes -//! omits the method from the class object. Free `#[napi]` functions -//! go through a different, more stable codegen path. -//! - There is no kernel-side `Database` state to wrap; everything -//! meaningful lives on `Session`. A wrapper class with no fields adds -//! a JS object allocation per session for no benefit. - -use std::sync::Arc; -use tokio::sync::Mutex; - -use databricks_sql_kernel::{AuthConfig, Session}; - -use crate::connection::Connection; -use crate::error::napi_err_from_kernel; -use crate::runtime; -use crate::util::guarded; - -/// 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. -#[napi(object)] -pub struct ConnectionOptions { - /// Workspace host, e.g. `adb-…azuredatabricks.net`. The kernel - /// normalises this — bare hostnames get `https://` prepended. - pub host_name: String, - /// JDBC-style HTTP path, e.g. `/sql/1.0/warehouses/abc123`. The - /// kernel parses out the warehouse id. - pub http_path: String, - /// Personal access token. Must be non-empty (the kernel rejects - /// empty PATs at session construction). - pub 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). -#[napi] -pub async fn open_session(options: ConnectionOptions) -> napi::Result { - guarded(async move { - // Cache the napi-rs tokio Handle on the very first async call - // so Drop impls (which run on the V8 GC thread, outside any - // tokio context) can still `spawn` cleanup tasks onto the - // runtime that's driving this future. - let _ = runtime::get_handle(); - - // SessionConfig is `#[non_exhaustive]` — go through the - // builder, which is the only public path that constructs it. - // `http_path()` is the convenience setter that maps a bare - // hostname + `/sql/1.0/warehouses/{id}` path into the kernel's - // `ConnectionConfig`. - let session = Session::builder() - .http_path(options.host_name, options.http_path) - .auth(AuthConfig::Pat { - token: options.token, - }) - .open() - .await - .map_err(napi_err_from_kernel)?; - Ok(Connection { - inner: Arc::new(Mutex::new(Some(session))), - }) - }) - .await -} diff --git a/native/sea/src/error.rs b/native/sea/src/error.rs deleted file mode 100644 index d06e1600..00000000 --- a/native/sea/src/error.rs +++ /dev/null @@ -1,153 +0,0 @@ -// 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. - -//! Kernel-error → `napi::Error` mapping. -//! -//! The kernel returns a richly-typed [`Error`](databricks_sql_kernel::Error) -//! with `code`, `sql_state`, `error_code`, `vendor_code`, `http_status`, -//! `retryable`, and `query_id` fields. The napi `Error` type only -//! carries `status` + `reason` directly — to attach the extra fields -//! as own-properties on the JS error object we'd need an `Env` -//! reference, which `#[napi] async fn` bodies don't have access to -//! cheaply. -//! -//! Compromise (one helper, DRY): encode the structured metadata into -//! the `reason` field as a JSON envelope prefixed with a sentinel -//! `__databricks_error__:` token. The TS adapter detects the sentinel, -//! parses the payload, and reconstructs the typed error class -//! (`DBSQLError`, `AuthError`, …). Plain-string errors from the -//! binding's own code paths fall through the sentinel detection -//! unchanged. -//! -//! Round 3 may switch to the `Env::create_error` + own-properties -//! pattern once we have a stable point in each entry where `env: Env` -//! is available (likely by wrapping the async glue in a sync entry -//! point that calls `tokio::spawn` after capturing `env`). - -use databricks_sql_kernel::{Error as KernelError, ErrorCode}; -use napi::{Error as NapiError, Status}; - -/// Sentinel that tells the TS adapter the `reason` string is a JSON -/// envelope rather than a plain message. Has to be ASCII-only so it -/// survives any `String` round-trip the napi layer might do. -pub(crate) const ERROR_SENTINEL: &str = "__databricks_error__:"; - -/// Map a kernel [`Error`] into a `napi::Error`. Preserves the kernel -/// `ErrorCode` (mapped to the closest napi `Status`), and stuffs the -/// remaining structured fields into a JSON envelope on the reason so -/// the TS layer can reconstruct the typed error class. -pub(crate) fn napi_err_from_kernel(e: KernelError) -> NapiError { - let status = status_from_kernel_code(e.code); - - // Build a minimal JSON envelope. We hand-build it (no serde_json - // dep) — the field set is small and fixed, and avoiding serde - // keeps the crate dep graph trim. - let mut envelope = String::with_capacity(e.message.len() + 128); - envelope.push_str(ERROR_SENTINEL); - envelope.push('{'); - push_json_str_field(&mut envelope, "code", error_code_str(e.code)); - envelope.push(','); - push_json_str_field(&mut envelope, "message", &e.message); - if let Some(s) = &e.sql_state { - envelope.push(','); - push_json_str_field(&mut envelope, "sqlState", s); - } - if let Some(ec) = &e.error_code { - envelope.push(','); - push_json_str_field(&mut envelope, "errorCode", ec); - } - if let Some(vc) = e.vendor_code { - envelope.push(','); - envelope.push_str("\"vendorCode\":"); - envelope.push_str(&vc.to_string()); - } - if let Some(hs) = e.http_status { - envelope.push(','); - envelope.push_str("\"httpStatus\":"); - envelope.push_str(&hs.to_string()); - } - if e.retryable { - envelope.push_str(",\"retryable\":true"); - } - if let Some(qid) = &e.query_id { - envelope.push(','); - push_json_str_field(&mut envelope, "queryId", qid); - } - envelope.push('}'); - - NapiError::new(status, envelope) -} - -/// Map kernel `ErrorCode` → napi `Status`. The status is mostly -/// cosmetic on the napi side (the TS layer dispatches on `code` from -/// the envelope); we pick the closest match so unwrapped errors still -/// look reasonable in raw napi consumers. -fn status_from_kernel_code(code: ErrorCode) -> Status { - match code { - ErrorCode::InvalidArgument | ErrorCode::InvalidStatementHandle => Status::InvalidArg, - ErrorCode::Cancelled => Status::Cancelled, - _ => Status::GenericFailure, - } -} - -/// String tag for each kernel `ErrorCode` — stable across kernel -/// versions because v0's `ErrorCode` is `#[non_exhaustive]` and we -/// pattern-match exhaustively against the known set. -fn error_code_str(code: ErrorCode) -> &'static str { - match code { - ErrorCode::InvalidArgument => "InvalidArgument", - ErrorCode::Unauthenticated => "Unauthenticated", - ErrorCode::PermissionDenied => "PermissionDenied", - ErrorCode::NotFound => "NotFound", - ErrorCode::ResourceExhausted => "ResourceExhausted", - ErrorCode::Unavailable => "Unavailable", - ErrorCode::Timeout => "Timeout", - ErrorCode::Cancelled => "Cancelled", - ErrorCode::DataLoss => "DataLoss", - ErrorCode::Internal => "Internal", - ErrorCode::InvalidStatementHandle => "InvalidStatementHandle", - ErrorCode::NetworkError => "NetworkError", - ErrorCode::SqlError => "SqlError", - // Forward-compat: ErrorCode is `#[non_exhaustive]`. Any new - // variant the kernel adds in v0.x lands here until we mirror - // it in this match. The TS layer treats Unknown as a generic - // failure. - _ => "Unknown", - } -} - -/// Append `"key":"value"` to the JSON buffer, escaping the value's -/// `"` and `\` characters and control chars to keep the envelope -/// JSON-parseable. The narrow set of escapes is sufficient for the -/// human-readable error messages the kernel produces (no embedded -/// binary blobs, no Unicode surrogate pairs). -fn push_json_str_field(out: &mut String, key: &str, value: &str) { - out.push('"'); - out.push_str(key); - out.push_str("\":\""); - for ch in value.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => { - out.push_str(&format!("\\u{:04x}", c as u32)); - } - c => out.push(c), - } - } - out.push('"'); -} diff --git a/native/sea/src/lib.rs b/native/sea/src/lib.rs deleted file mode 100644 index 6de102ea..00000000 --- a/native/sea/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -// 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. - -//! `databricks-sea-native` — napi-rs binding crate for the Databricks -//! SQL Node.js driver's SEA (Statement Execution API) path. -//! -//! Round 2 surface: `Database.open` → `Connection.execute_statement` -//! → `Statement.fetch_next_batch` / `schema` / `cancel` / `close`. -//! Results cross the FFI as Arrow IPC bytes (see `result.rs`); the -//! TS adapter decodes them via `apache-arrow`. - -#![deny(unsafe_op_in_unsafe_fn)] - -#[macro_use] -extern crate napi_derive; - -pub(crate) mod connection; -pub(crate) mod database; -pub(crate) mod error; -pub(crate) mod logger; -pub(crate) mod result; -pub(crate) mod runtime; -pub(crate) mod statement; -pub(crate) mod util; - -/// 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. -#[napi] -pub fn version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} diff --git a/native/sea/src/logger.rs b/native/sea/src/logger.rs deleted file mode 100644 index 2bfcd078..00000000 --- a/native/sea/src/logger.rs +++ /dev/null @@ -1,17 +0,0 @@ -// 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. - -//! `tracing` → JS `DBSQLLogger` bridge via `ThreadsafeFunction`. -//! -//! Round 3 work. Empty in Round 1b. diff --git a/native/sea/src/result.rs b/native/sea/src/result.rs deleted file mode 100644 index 488c0851..00000000 --- a/native/sea/src/result.rs +++ /dev/null @@ -1,36 +0,0 @@ -// 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. - -//! Arrow IPC payload types crossed across the napi boundary. -//! -//! Per sea-design.md Layer 2: "The binding ships the batch across the -//! FFI as Arrow IPC bytes. The adapter converts those bytes into -//! JavaScript rows…" — so the napi boundary is intentionally narrow: -//! one envelope per batch, one envelope per schema. - -use napi::bindgen_prelude::Buffer; - -/// A single Arrow IPC stream payload encoding one record batch (plus -/// the schema header so the JS-side reader is stateless). -#[napi(object)] -pub struct ArrowBatch { - pub ipc_bytes: Buffer, -} - -/// An Arrow IPC stream payload encoding just the result schema (no -/// record-batch messages). Returned by `Statement.schema()`. -#[napi(object)] -pub struct ArrowSchema { - pub ipc_bytes: Buffer, -} diff --git a/native/sea/src/runtime.rs b/native/sea/src/runtime.rs deleted file mode 100644 index 7f0ee42d..00000000 --- a/native/sea/src/runtime.rs +++ /dev/null @@ -1,56 +0,0 @@ -// 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. - -//! Captured tokio `Handle` for napi-rs's process-global runtime. -//! -//! Per the napi-rs patterns doc (pattern #2): the first time any -//! `#[napi] async fn` runs, we are guaranteed to be on napi-rs's tokio -//! runtime. We snapshot the current `Handle` then and stash a clone in -//! a process-static `OnceCell`. Every subsequent kernel construction -//! reads the captured handle and hands a clone to the kernel, so -//! Drop-time cleanup (which runs on the V8 GC thread, *outside* any -//! tokio context) can still `spawn` cleanup tasks onto the same -//! runtime napi-rs is driving. -//! -//! `Handle::current()` MUST NOT be called from a synchronous JS-thread -//! entry point or from module init — both run before napi-rs has -//! constructed its runtime and would panic. `get()` returns `None` in -//! that case so callers can surface a useful error rather than abort. - -use once_cell::sync::OnceCell; -use tokio::runtime::Handle; - -static RUNTIME_HANDLE: OnceCell = OnceCell::new(); - -/// Capture the current tokio runtime handle on first call, return a -/// reference to the captured clone on subsequent calls. -/// -/// MUST be called from inside a `#[napi] async fn` body (or any other -/// tokio runtime context); otherwise `Handle::current()` panics on the -/// very first call. Subsequent calls are infallible and lock-free. -/// -/// Round 1b has no async entry points that exercise this yet; Round 2 -/// will call it from `Database::open()` and other `#[napi] async fn`s. -#[allow(dead_code)] -pub(crate) fn get_handle() -> &'static Handle { - RUNTIME_HANDLE.get_or_init(Handle::current) -} - -/// Non-panicking accessor — returns `None` if `get_handle()` has not -/// been called yet. Drop impls and other GC-thread call sites use this -/// to short-circuit cleanup when no async entry point has ever run -/// (i.e. there is no kernel state that needs closing either). -pub(crate) fn try_get_handle() -> Option<&'static Handle> { - RUNTIME_HANDLE.get() -} diff --git a/native/sea/src/statement.rs b/native/sea/src/statement.rs deleted file mode 100644 index 6d7b8761..00000000 --- a/native/sea/src/statement.rs +++ /dev/null @@ -1,212 +0,0 @@ -// 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. - -//! Opaque `Statement` wrapper around the kernel's `ExecutedStatement`. -//! -//! M0 surface (Round 2): -//! - `Statement.fetchNextBatch() -> Option` — drives -//! `ResultStream::next_batch().await`, serialises the borrowed -//! `RecordBatch` to Arrow IPC bytes, returns them to JS. -//! - `Statement.schema() -> ArrowSchema` — returns the cached schema -//! from the kernel side, serialised as a schema-only IPC payload. -//! - `Statement.cancel()` / `Statement.close()` — forwards to -//! `ExecutedStatement::cancel/close` via the -//! `ExecutedStatementHandle` trait. Drop fires-and-forgets close -//! if not already explicitly closed. - -use std::sync::Arc; -use tokio::sync::Mutex; - -use arrow_ipc::writer::StreamWriter; -use databricks_sql_kernel::{ExecutedStatement, ExecutedStatementHandle, ResultBatch}; - -use crate::error::napi_err_from_kernel; -use crate::result::{ArrowBatch, ArrowSchema}; -use crate::runtime; -use crate::util::guarded; - -/// 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`. -#[napi] -pub struct Statement { - inner: Arc>>, -} - -impl Statement { - /// Crate-internal constructor — called from - /// `Connection::execute_statement` once the kernel hands back the - /// `ExecutedStatement`. - pub(crate) fn from_executed(executed: ExecutedStatement) -> Self { - Self { - inner: Arc::new(Mutex::new(Some(executed))), - } - } -} - -#[napi] -impl 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`. - #[napi] - pub async fn fetch_next_batch(&self) -> napi::Result> { - let inner = Arc::clone(&self.inner); - guarded(async move { - let mut guard = inner.lock().await; - let executed = guard.as_mut().ok_or_else(|| { - napi::Error::new(napi::Status::InvalidArg, "statement already closed") - })?; - - let stream = executed.result_stream_mut(); - // Capture the schema before borrowing the next batch — we - // include the schema header in every IPC payload so the - // JS-side consumer can decode each batch independently - // without carrying state across calls. - let schema = stream.schema(); - let maybe_batch = stream.next_batch().await.map_err(napi_err_from_kernel)?; - let Some(batch) = maybe_batch else { - return Ok(None); - }; - // `ResultBatch` is `#[non_exhaustive]`; v0 only ever - // yields `Arrow`. The error arm exists for forward - // compat — v1+ may add ColumnarThrift / JsonRows / etc., - // and we want the binding to surface that as a typed - // error rather than silently misbehaving. - let record_batch = match batch { - ResultBatch::Arrow(rb) => rb, - _ => { - return Err(napi::Error::new( - napi::Status::GenericFailure, - "non-Arrow ResultBatch variant — binding needs upgrade", - )); - } - }; - let bytes = encode_ipc_stream(&schema, Some(record_batch))?; - Ok(Some(ArrowBatch { - ipc_bytes: bytes.into(), - })) - }) - .await - } - - /// Result schema as an Arrow IPC payload (schema header only, no - /// record-batch message). Available before any batches have been - /// fetched. - #[napi] - pub async fn schema(&self) -> napi::Result { - let inner = Arc::clone(&self.inner); - guarded(async move { - let guard = inner.lock().await; - let executed = guard.as_ref().ok_or_else(|| { - napi::Error::new(napi::Status::InvalidArg, "statement already closed") - })?; - let schema = executed.schema(); - let bytes = encode_ipc_stream(&schema, None)?; - Ok(ArrowSchema { - ipc_bytes: bytes.into(), - }) - }) - .await - } - - /// Server-side cancel. No-op if already finished. - #[napi] - pub async fn cancel(&self) -> napi::Result<()> { - let inner = Arc::clone(&self.inner); - guarded(async move { - let guard = inner.lock().await; - let executed = guard.as_ref().ok_or_else(|| { - napi::Error::new(napi::Status::InvalidArg, "statement already closed") - })?; - executed.cancel().await.map_err(napi_err_from_kernel) - }) - .await - } - - /// Explicit close. Awaits the server-side close so the JS caller - /// can observe failures. - #[napi] - pub async fn close(&self) -> napi::Result<()> { - let inner = Arc::clone(&self.inner); - guarded(async move { - // Take the handle out so `Drop` knows there's nothing left - // to clean up. - let executed = { - let mut guard = inner.lock().await; - guard.take() - }; - if let Some(executed) = executed { - executed.close().await.map_err(napi_err_from_kernel)?; - } - Ok(()) - }) - .await - } -} - -impl Drop for Statement { - fn drop(&mut self) { - let Some(handle) = runtime::try_get_handle() else { - return; - }; - let inner = Arc::clone(&self.inner); - handle.spawn(async move { - // Drop the executed statement on the runtime. The kernel's - // `ExecutedStatement::Drop` already spawns a fire-and-forget - // `close_statement` against its own captured handle, so we - // just need to ensure the value is dropped inside a tokio - // context (the kernel's Drop reads `runtime_handle.clone()` - // and spawns; that handle is the same one we captured here). - let _taken = { - let mut guard = inner.lock().await; - guard.take() - }; - }); - } -} - -/// Encode an Arrow schema (and optional one record batch) as an IPC -/// stream payload. Used for both `schema()` (schema only) and -/// `fetchNextBatch()` (schema + one batch). Returning a self-contained -/// IPC stream per call is wasteful header-wise but lets the JS adapter -/// stay stateless — it decodes each `ipcBytes` independently via the -/// same `apache-arrow` `RecordBatchReader` path. -fn encode_ipc_stream( - schema: &arrow_schema::SchemaRef, - batch: Option<&arrow_array::RecordBatch>, -) -> napi::Result> { - let mut buf: Vec = Vec::new(); - { - let mut writer = StreamWriter::try_new(&mut buf, schema) - .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; - if let Some(rb) = batch { - writer - .write(rb) - .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; - } - writer - .finish() - .map_err(|e| napi::Error::new(napi::Status::GenericFailure, e.to_string()))?; - } - Ok(buf) -} diff --git a/native/sea/src/util.rs b/native/sea/src/util.rs deleted file mode 100644 index 4ba7e346..00000000 --- a/native/sea/src/util.rs +++ /dev/null @@ -1,62 +0,0 @@ -// 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. - -//! Shared helpers — one place for the `catch_unwind` wrapping that -//! every async entry point goes through (pattern #8 in the napi-rs -//! patterns doc). One helper, called once per entry point — DRY. -//! -//! Why a helper rather than a macro: helper + `async move {}` reads -//! better at call sites and keeps the stack trace shallow when a panic -//! actually fires (a macro would expand into the caller's body). - -use std::any::Any; -use std::future::Future; -use std::panic::AssertUnwindSafe; - -use futures::FutureExt; -use napi::{Error as NapiError, Result as NapiResult, Status}; - -/// Run `fut` and convert any panic the future raises into a -/// `napi::Error` so the JS caller sees a rejected promise instead of -/// the Node process aborting. -/// -/// `catch_unwind` does not catch `std::process::abort`, double-panic, -/// or allocator OOM — those still bring down the process. That's by -/// design: a corrupted process state isn't something we can pretend to -/// recover from. -pub(crate) async fn guarded(fut: F) -> NapiResult -where - F: Future>, -{ - match AssertUnwindSafe(fut).catch_unwind().await { - Ok(res) => res, - Err(panic) => Err(NapiError::new( - Status::GenericFailure, - format!("panic in native binding: {}", panic_payload_msg(panic)), - )), - } -} - -/// Best-effort downcast of a panic payload to a human-readable string. -/// `panic!("…")` produces `&'static str` or `String`; the rest fall -/// through to a generic marker so the JS caller still sees *something*. -fn panic_payload_msg(p: Box) -> String { - if let Some(s) = p.downcast_ref::<&'static str>() { - return (*s).to_string(); - } - if let Some(s) = p.downcast_ref::() { - return s.clone(); - } - "non-string panic payload".to_string() -} diff --git a/package.json b/package.json index 14d4d200..a60ca74f 100644 --- a/package.json +++ b/package.json @@ -17,8 +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": "cd native/sea && napi build --platform --release", - "build:native:debug": "cd native/sea && napi build --platform", + "build:native": "bash -c 'cd ${DATABRICKS_SQL_KERNEL_REPO:-../../databricks-sql-kernel-sea-WT/napi-binding}/napi && npx --yes @napi-rs/cli@2 build --release && cp index.node $OLDPWD/native/sea/index.linux-x64-gnu.node && cp index.d.ts $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 && cp index.node $OLDPWD/native/sea/index.linux-x64-gnu.node && cp index.d.ts $OLDPWD/native/sea/'", "watch": "tsc --project tsconfig.build.json --watch", "type-check": "tsc --noEmit", "prettier": "prettier . --check", @@ -93,4 +93,4 @@ "optionalDependencies": { "lz4": "^0.6.5" } -} +} \ No newline at end of file From 20231c8f91674b321cb8c720a99aeaf44a35bc28 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Fri, 15 May 2026 09:04:34 +0000 Subject: [PATCH 5/5] sea-napi-binding: build:native uses --platform so index.js router is generated --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a60ca74f..f5400ed4 100644 --- a/package.json +++ b/package.json @@ -17,8 +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 --release && cp index.node $OLDPWD/native/sea/index.linux-x64-gnu.node && cp index.d.ts $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 && cp index.node $OLDPWD/native/sea/index.linux-x64-gnu.node && cp index.d.ts $OLDPWD/native/sea/'", + "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",