|
| 1 | +// Copyright (c) 2026 Databricks, Inc. |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +/** |
| 16 | + * Lazy loader for the SEA (Statement Execution API) native binding. |
| 17 | + * |
| 18 | + * Mirrors the load-failure-tolerant pattern of `lib/utils/lz4.ts`: the |
| 19 | + * `.node` artifact ships via per-platform optional dependencies |
| 20 | + * (`@databricks/sql-kernel-<triple>`), so its absence must not crash |
| 21 | + * a Thrift-only consumer of the driver. Callers that actually need |
| 22 | + * SEA construct a {@link SeaNativeLoader} (or use the process-global |
| 23 | + * {@link getSeaNative}) which throws a structured error if the binding |
| 24 | + * could not be loaded. |
| 25 | + * |
| 26 | + * M0 publishes a single triple (`linux-x64-gnu`); see |
| 27 | + * `native/sea/README.md` for the supported-platform policy. |
| 28 | + */ |
| 29 | + |
| 30 | +import type { |
| 31 | + Connection as NativeConnection, |
| 32 | + Statement as NativeStatement, |
| 33 | + ConnectionOptions as NativeConnectionOptions, |
| 34 | + ArrowBatch as NativeArrowBatch, |
| 35 | + ArrowSchema as NativeArrowSchema, |
| 36 | +} from '../../native/sea'; |
| 37 | + |
| 38 | +// SEA-prefixed re-exports. The kernel-generated `.d.ts` keeps the |
| 39 | +// napi-rs default names (`ConnectionOptions`, `ArrowBatch`, …); we |
| 40 | +// disambiguate on the TS-wrapper side so these never collide with the |
| 41 | +// Thrift-side `ConnectionOptions` (lib/contracts/IDBSQLClient.ts) or |
| 42 | +// `ArrowBatch` (lib/result/utils.ts) when imported elsewhere. |
| 43 | +export type SeaConnectionOptions = NativeConnectionOptions; |
| 44 | +export type SeaArrowBatch = NativeArrowBatch; |
| 45 | +export type SeaArrowSchema = NativeArrowSchema; |
| 46 | +export type SeaConnection = NativeConnection; |
| 47 | +export type SeaStatement = NativeStatement; |
| 48 | + |
| 49 | +/** |
| 50 | + * The full native binding surface, derived from the generated module |
| 51 | + * so it can never drift from the `.d.ts` contract: when the kernel |
| 52 | + * adds or renames a free function / class, this type follows |
| 53 | + * automatically and `defaultRequire`'s cast stays correct. |
| 54 | + */ |
| 55 | +export type SeaNativeBinding = typeof import('../../native/sea'); |
| 56 | + |
| 57 | +const MIN_NODE_MAJOR = 18; |
| 58 | + |
| 59 | +function detectNodeMajor(): number { |
| 60 | + // `process.version` is `vX.Y.Z`; parseInt stops at the first non-digit. |
| 61 | + return parseInt(process.version.slice(1), 10); |
| 62 | +} |
| 63 | + |
| 64 | +function platformLabel(): string { |
| 65 | + return `${process.platform}-${process.arch}`; |
| 66 | +} |
| 67 | + |
| 68 | +function loadFailureHint(err: NodeJS.ErrnoException): string { |
| 69 | + const platform = platformLabel(); |
| 70 | + // Do not name a concrete package: the published name uses the napi-rs |
| 71 | + // triple (e.g. `-linux-x64-gnu` / `-linux-x64-musl` / `-win32-x64-msvc`), |
| 72 | + // not the bare `${platform}` shown here, so a literal example would |
| 73 | + // 404. Point at the README's supported-triple list instead. |
| 74 | + const installHint = |
| 75 | + 'Install the matching @databricks/sql-kernel-* optional dependency for your platform ' + |
| 76 | + '(see native/sea/README.md for the supported triples; M0 ships linux-x64-gnu only).'; |
| 77 | + if (err.code === 'MODULE_NOT_FOUND') { |
| 78 | + return `SEA native binding not installed for platform ${platform} on Node ${process.version}. ${installHint}`; |
| 79 | + } |
| 80 | + if (err.code === 'ERR_DLOPEN_FAILED') { |
| 81 | + // Surface the underlying dlerror string (e.g. `GLIBC_2.32 not found`) |
| 82 | + // plus concrete remediation — without it the cause is invisible. |
| 83 | + return ( |
| 84 | + `SEA native binding present but failed to dlopen on platform ${platform} / Node ${process.version}: ` + |
| 85 | + `${err.message}. Common causes: glibc/musl mismatch (e.g. Alpine Linux — install the -musl variant), ` + |
| 86 | + `Node ABI mismatch (try \`rm -rf node_modules && npm install\`), or CPU-architecture mismatch. ` + |
| 87 | + `The binding requires Node >=${MIN_NODE_MAJOR}.` |
| 88 | + ); |
| 89 | + } |
| 90 | + return `SEA native binding failed to load on platform ${platform} / Node ${process.version}: ${err.message}`; |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Default loader: resolves `native/sea/index.js` (the napi-rs router), |
| 95 | + * which selects the per-platform `.node`. `.js` is omitted so eslint's |
| 96 | + * `import/extensions` rule accepts the call. |
| 97 | + */ |
| 98 | +function defaultRequire(): SeaNativeBinding { |
| 99 | + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require |
| 100 | + return require('../../native/sea') as SeaNativeBinding; |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Verify the loaded module exposes the surface the driver depends on. |
| 105 | + * Catches kernel-side renames at load time rather than letting them |
| 106 | + * surface as `undefined is not a function` deep in a call path. |
| 107 | + */ |
| 108 | +function assertBindingShape(binding: SeaNativeBinding): void { |
| 109 | + const missing: string[] = []; |
| 110 | + if (typeof binding.version !== 'function') missing.push('version'); |
| 111 | + if (typeof binding.openSession !== 'function') missing.push('openSession'); |
| 112 | + if (typeof binding.Connection !== 'function') missing.push('Connection'); |
| 113 | + if (typeof binding.Statement !== 'function') missing.push('Statement'); |
| 114 | + if (missing.length > 0) { |
| 115 | + throw new Error( |
| 116 | + `SEA native binding loaded but is missing expected export(s): ${missing.join(', ')}. ` + |
| 117 | + `The kernel-generated binding and the JS loader are out of sync.`, |
| 118 | + ); |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +/** |
| 123 | + * Loads and caches the SEA native binding. Exposed as a class with an |
| 124 | + * injectable `load` seam so consumers (e.g. `SeaBackend`) can be unit |
| 125 | + * tested with a stub binding instead of requiring a real `.node` on the |
| 126 | + * test machine. Most production code uses the process-global default |
| 127 | + * via {@link getSeaNative} / {@link tryGetSeaNative}. |
| 128 | + */ |
| 129 | +export class SeaNativeLoader { |
| 130 | + private cached: SeaNativeBinding | null | undefined; |
| 131 | + |
| 132 | + private cachedError: Error | undefined; |
| 133 | + |
| 134 | + /** |
| 135 | + * @param load injectable module-require seam (stub a binding in tests) |
| 136 | + * @param nodeMajor injectable Node-major detector. Defaults to reading the |
| 137 | + * live `process.version`; injected in unit tests so the |
| 138 | + * load/shape branches are exercised independently of the |
| 139 | + * runner's actual Node version (the matrix spans 14–20). |
| 140 | + */ |
| 141 | + constructor( |
| 142 | + private readonly load: () => SeaNativeBinding = defaultRequire, |
| 143 | + private readonly nodeMajor: () => number = detectNodeMajor, |
| 144 | + ) {} |
| 145 | + |
| 146 | + private tryLoad(): SeaNativeBinding | undefined { |
| 147 | + const nodeMajor = this.nodeMajor(); |
| 148 | + // Fail closed: if we cannot determine the Node major (NaN) or it is |
| 149 | + // below the floor, refuse the load and fall back to Thrift. |
| 150 | + if (!Number.isFinite(nodeMajor) || nodeMajor < MIN_NODE_MAJOR) { |
| 151 | + this.cachedError = new Error( |
| 152 | + `SEA native binding requires Node >=${MIN_NODE_MAJOR}; running Node ${process.version}. ` + |
| 153 | + `Continue using the Thrift backend on this runtime.`, |
| 154 | + ); |
| 155 | + return undefined; |
| 156 | + } |
| 157 | + |
| 158 | + try { |
| 159 | + const binding = this.load(); |
| 160 | + assertBindingShape(binding); |
| 161 | + return binding; |
| 162 | + } catch (err) { |
| 163 | + if (err instanceof Error && 'code' in err) { |
| 164 | + this.cachedError = new Error(loadFailureHint(err as NodeJS.ErrnoException)); |
| 165 | + } else if (err instanceof Error) { |
| 166 | + // Shape-check failure or any other Error — preserve its message. |
| 167 | + this.cachedError = err; |
| 168 | + } else { |
| 169 | + this.cachedError = new Error(`SEA native binding failed to load with non-standard error: ${String(err)}`); |
| 170 | + } |
| 171 | + return undefined; |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * Returns the loaded native binding. Throws a structured error if the |
| 177 | + * binding is unavailable on this platform / Node version. |
| 178 | + */ |
| 179 | + get(): SeaNativeBinding { |
| 180 | + if (this.cached === undefined) { |
| 181 | + this.cached = this.tryLoad() ?? null; |
| 182 | + } |
| 183 | + if (this.cached === null) { |
| 184 | + throw this.cachedError ?? new Error('SEA native binding unavailable'); |
| 185 | + } |
| 186 | + return this.cached; |
| 187 | + } |
| 188 | + |
| 189 | + /** |
| 190 | + * Returns the loaded binding or `undefined` if it could not be |
| 191 | + * loaded. Use this for capability-detection at startup; use |
| 192 | + * {@link get} at the point where SEA is actually required. |
| 193 | + */ |
| 194 | + tryGet(): SeaNativeBinding | undefined { |
| 195 | + if (this.cached === undefined) { |
| 196 | + this.cached = this.tryLoad() ?? null; |
| 197 | + } |
| 198 | + return this.cached ?? undefined; |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +// Process-global default instance + thin convenience wrappers. |
| 203 | +const defaultLoader = new SeaNativeLoader(); |
| 204 | + |
| 205 | +/** |
| 206 | + * Returns the loaded native binding from the process-global loader. |
| 207 | + * Throws a structured error if the binding is unavailable. |
| 208 | + */ |
| 209 | +export function getSeaNative(): SeaNativeBinding { |
| 210 | + return defaultLoader.get(); |
| 211 | +} |
| 212 | + |
| 213 | +/** |
| 214 | + * Returns the loaded binding from the process-global loader, or |
| 215 | + * `undefined` if it could not be loaded. |
| 216 | + */ |
| 217 | +export function tryGetSeaNative(): SeaNativeBinding | undefined { |
| 218 | + return defaultLoader.tryGet(); |
| 219 | +} |
0 commit comments