diff --git a/.github/workflows/node-spanner-lib-wrapper-lint.yml b/.github/workflows/node-spanner-lib-wrapper-lint.yml new file mode 100644 index 00000000..76d6e614 --- /dev/null +++ b/.github/workflows/node-spanner-lib-wrapper-lint.yml @@ -0,0 +1,38 @@ +name: Node Wrapper Lint + +on: + push: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-node/**' + pull_request: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-node/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: spannerlib/wrappers/spannerlib-node + + steps: + - uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: spannerlib/wrappers/spannerlib-node/package.json + + - name: Install Dependencies + run: npm install + + - name: Run Lint + run: npm run lint diff --git a/.github/workflows/node-spanner-lib-wrapper-unit-tests.yml b/.github/workflows/node-spanner-lib-wrapper-unit-tests.yml new file mode 100644 index 00000000..a225a232 --- /dev/null +++ b/.github/workflows/node-spanner-lib-wrapper-unit-tests.yml @@ -0,0 +1,54 @@ +name: Node Wrapper Unit Tests + +on: + push: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-node/**' + pull_request: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-node/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit-tests: + name: Test ${{ matrix.os }} (Node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: ['20'] + + defaults: + run: + shell: bash + working-directory: ./spannerlib/wrappers/spannerlib-node + + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26.x' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: spannerlib/wrappers/spannerlib-node/package.json + + - name: Install Dependencies + run: npm install + + - name: Build Addon and TS + run: npm run build + + - name: Run Unit Tests + run: npm test diff --git a/.gitignore b/.gitignore index 8818a661..e8178a80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ gorm/ .idea .DS_Store +node_modules +build/ +package-lock.json +googleapis/ \ No newline at end of file diff --git a/spannerlib/.gitignore b/spannerlib/.gitignore index 02b98474..ddc2028a 100644 --- a/spannerlib/.gitignore +++ b/spannerlib/.gitignore @@ -10,3 +10,5 @@ vendor/bundle *.swp ext/ Gemfile.lock +/googleapis/ +package-lock.json \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-node/.editorconfig b/spannerlib/wrappers/spannerlib-node/.editorconfig new file mode 100644 index 00000000..79fe8026 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +insert_final_newline = true diff --git a/spannerlib/wrappers/spannerlib-node/.mocharc.json b/spannerlib/wrappers/spannerlib-node/.mocharc.json new file mode 100644 index 00000000..45449d62 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.mocharc.json @@ -0,0 +1,5 @@ +{ + "timeout": 60000, + "reporter": "spec", + "spec": ["build/esm/test/**/*.js"] +} diff --git a/spannerlib/wrappers/spannerlib-node/.nycrc b/spannerlib/wrappers/spannerlib-node/.nycrc new file mode 100644 index 00000000..1510070c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.nycrc @@ -0,0 +1,11 @@ +{ + "all": true, + "check-coverage": true, + "include": ["src/**/*.js", "index.js"], + "exclude": ["test/**"], + "reporter": ["text", "lcov", "html"], + "branches": 80, + "lines": 80, + "functions": 80, + "statements": 80 +} diff --git a/spannerlib/wrappers/spannerlib-node/.prettierrc b/spannerlib/wrappers/spannerlib-node/.prettierrc new file mode 100644 index 00000000..16547d9c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/spannerlib/wrappers/spannerlib-node/.prettierrc.js b/spannerlib/wrappers/spannerlib-node/.prettierrc.js new file mode 100644 index 00000000..c5166c2a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('gts/.prettierrc.json'), +}; diff --git a/spannerlib/wrappers/spannerlib-node/README.md b/spannerlib/wrappers/spannerlib-node/README.md new file mode 100644 index 00000000..3bd651b4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/README.md @@ -0,0 +1,52 @@ +# Node-API Wrapper for Spanner Shared Library + +This package provides a high-performance Node-API (N-API) bridge to the Go-based Spanner shared library. It offers superior stability and performance compared to traditional FFI approaches. + +## Prerequisites + +- Node.js >= 20.0.0 +- Go compiler (to build the underlying shared library, if not pre-built) +- C++ toolchain (GCC/Clang or MSVC) + +## Installation & Building + +To build the native addon, run: + +```bash +npm install +``` + +This will trigger `node-gyp` to compile the C++ bridge and link it with `libspanner.so`. + +## Usage + +```javascript +const { Pool, Connection } = require('spannerlib-node'); + +async function run() { + const pool = new Pool('my-user-agent', 'projects/.../instances/.../databases/...'); + await pool.create(); + + const conn = await pool.createConnection(); + const rows = await conn.executeSql('SELECT 1'); + + while (await rows.next()) { + // process rows + } + + await rows.close(); + await conn.close(); + await pool.close(); +} +``` + +## Architecture + +The wrapper consists of: +1. **`src/cpp/addon.cc`**: C++ Node-API bridge that handles thread boundaries and type conversions between V8 and C. +2. **`src/ffi/utils.ts`**: Helper functions to invoke native methods asynchronously using Promises. +3. **`src/lib/`**: JavaScript classes (`Pool`, `Connection`, `Rows`) that provide a clean object-oriented interface. + +### Component Interaction & Memory Management + +When a JavaScript object (like a `Pool` or `Connection`) is created, it holds an ID referencing a pinned Go object in memory. The `spannerLib` singleton maintains a **`FinalizationRegistry`**. This registry allows Node.js to listen for when the JavaScript object is garbage collected. When GC occurs, the registry automatically triggers a cleanup call to the native layer to release the corresponding Go object, preventing native memory leaks even if the developer forgets to call `.close()` explicitly. diff --git a/spannerlib/wrappers/spannerlib-node/binding.gyp b/spannerlib/wrappers/spannerlib-node/binding.gyp new file mode 100644 index 00000000..e2457df3 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/binding.gyp @@ -0,0 +1,68 @@ +{ + 'targets': [ + { + 'target_name': 'spanner_napi', + 'sources': [ 'src/cpp/addon.cc' ], + 'include_dirs': [ + '=20.0.0" + }, + "scripts": { + "build:go": "bash scripts/build-shared-lib.sh", + "build": "npm run build:go && node-gyp rebuild && npm run compile", + "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.dylib @loader_path/libspanner.dylib ./build/Release/spanner_napi.node')\"", + "compile:esm": "tsc -p .", + "compile:cjs": "tsc -p ./tsconfig.cjs.json && babel build/cjs --out-dir build/cjs --out-file-extension .cjs && node scripts/fix-extensions.cjs", + "compile": "npm run compile:esm && npm run compile:cjs", + "test:esm": "mocha build/esm/test/**/*.js", + "test:cjs": "mocha build/cjs/test/**/*.cjs", + "test": "npm run test:esm && npm run test:cjs", + "lint": "gts lint", + "clean": "gts clean", + "fix": "gts fix", + "prepare": "npm run compile", + "pretest": "npm run compile" + }, + "files": [ + "build/esm/src", + "build/cjs/src", + "build/Release/*.node" + ], + "dependencies": { + "node-addon-api": "^8.0.0", + "bindings": "^1.5.0", + "@google-cloud/spanner": "^7.13.0" + }, + "devDependencies": { + "mocha": "^10.2.0", + "typescript": "^5.6.3", + "@types/node": "^22.7.5", + "@types/bindings": "^1.5.0", + "@types/mocha": "^10.0.6", + "@babel/core": "^7.24.0", + "@babel/cli": "^7.23.9", + "sinon": "^18.0.0", + "@types/sinon": "^17.0.3", + "gts": "^7.0.0" + }, + "gypfile": false +} diff --git a/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh new file mode 100755 index 00000000..d8b5d027 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +# Copyright 2026 Google LLC +# +# 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. + +# Builds the shared library and places it in the shared directory. +# This script handles OS detection to use the correct file extension. + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +log "Starting Spannerlib Shared Library Build for Node Wrapper..." + +# Resolve absolute paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_LIB_DIR="$(cd "$SCRIPT_DIR/../../../shared" && pwd)" + +log "Script Directory: $SCRIPT_DIR" +log "Shared Lib Directory: $SHARED_LIB_DIR" + +# Auto-detect OS +case "$(uname -s)" in + Linux*) OS="Linux";; + Darwin*) OS="macOS";; + CYGWIN*|MINGW*|MSYS*) OS="Windows";; + *) OS="Unknown";; +esac +log "Auto-detected OS: $OS" + +if [ "$OS" == "macOS" ]; then + echo "Building for macOS..." + go build -C "$SHARED_LIB_DIR" -o libspanner.dylib -buildmode=c-shared shared_lib.go +elif [ "$OS" == "Linux" ]; then + echo "Building for Linux..." + go build -C "$SHARED_LIB_DIR" -o libspanner.so -buildmode=c-shared shared_lib.go +elif [ "$OS" == "Windows" ]; then + echo "Building for Windows..." + go build -C "$SHARED_LIB_DIR" -o libspanner.dll -buildmode=c-shared shared_lib.go +else + echo "Unsupported operating system: $OS" + exit 1 +fi + +echo "Build complete." diff --git a/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs new file mode 100644 index 00000000..9fd1428d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// 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. + +const fs = require('fs'); +const path = require('path'); + +const cjsDir = path.join(__dirname, '../build/cjs'); + +function traverseDir(dir) { + fs.readdirSync(dir).forEach(file => { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + traverseDir(fullPath); + } else if (fullPath.endsWith('.cjs')) { + let content = fs.readFileSync(fullPath, 'utf8'); + // Replace require paths starting with . / or .. / and ending with .js + content = content.replace(/require\(['"]((\.|\.\.)\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); + // Fix import.meta.url syntax error in CommonJS + content = content.replace(/import\.meta\.url/g, '""'); + fs.writeFileSync(fullPath, content); + } else if (fullPath.endsWith('.js')) { + // Delete the original .js file generated by tsc + fs.unlinkSync(fullPath); + } + }); +} + +if (fs.existsSync(cjsDir)) { + traverseDir(cjsDir); + console.log('Fixed extensions in .cjs files and removed .js files in build/cjs'); +} diff --git a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc new file mode 100644 index 00000000..6bb99767 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc @@ -0,0 +1,442 @@ +// Copyright 2026 Google LLC +// +// 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. + +#include +#include +#include "libspanner.h" + +// Documentation for Go function return fields (r0 - r4): +// r0: Pinner ID (used for memory management to keep Go objects pinned) +// r1: Error Code (0 for success, non-zero for error) +// r2: Object ID (Handle to the created object, e.g., Pool or Connection) +// r3: Message Length (Length of the protobuf message or error string in r4) +// r4: Message Data (Pointer to protobuf bytes or JSON error message) + +// +// Worker 1: CreatePool asynchronously +// +class CreatePoolWorker : public Napi::AsyncWorker { +public: + CreatePoolWorker(Napi::Function& callback, std::string userAgent, std::string connStr) + : AsyncWorker(callback), userAgent_(userAgent), connStr_(connStr), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoString goUserAgent = {userAgent_.c_str(), (ptrdiff_t)userAgent_.length()}; + GoString goConnStr = {connStr_.c_str(), (ptrdiff_t)connStr_.length()}; + result_ = CreatePool(goUserAgent, goConnStr); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + Callback().Call({env.Null(), obj}); + } + +private: + std::string userAgent_; + std::string connStr_; + CreatePool_return result_; +}; + +Napi::Value CreatePoolWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3) { + Napi::Error::New(env, "CreatePoolWrapper requires 3 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + std::string ua = info[0].As(); + std::string cs = info[1].As(); + Napi::Function cb = info[2].As(); + CreatePoolWorker* worker = new CreatePoolWorker(cb, ua, cs); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 2: ClosePool asynchronously +// +class ClosePoolWorker : public Napi::AsyncWorker { +public: + ClosePoolWorker(Napi::Function& callback, int64_t poolId) + : AsyncWorker(callback), poolId_(poolId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ClosePool(poolId_); + // Release the pinner ID of the response message to prevent native leak! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_; + ClosePool_return result_; +}; + +Napi::Value ClosePoolWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2) { + Napi::Error::New(env, "ClosePoolWrapper requires 2 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + Napi::Function cb = info[1].As(); + ClosePoolWorker* worker = new ClosePoolWorker(cb, pid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 3: CreateConnection asynchronously +// +class CreateConnectionWorker : public Napi::AsyncWorker { +public: + CreateConnectionWorker(Napi::Function& callback, int64_t poolId) + : AsyncWorker(callback), poolId_(poolId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = CreateConnection(poolId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_; + CreateConnection_return result_; +}; + +Napi::Value CreateConnectionWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2) { + Napi::Error::New(env, "CreateConnectionWrapper requires 2 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + Napi::Function cb = info[1].As(); + CreateConnectionWorker* worker = new CreateConnectionWorker(cb, pid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 4: CloseConnection asynchronously +// +class CloseConnectionWorker : public Napi::AsyncWorker { +public: + CloseConnectionWorker(Napi::Function& callback, int64_t poolId, int64_t connId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = CloseConnection(poolId_, connId_); + // Release the pinner ID of the response message to prevent native leak! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + CloseConnection_return result_; +}; + +Napi::Value CloseConnectionWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3) { + Napi::Error::New(env, "CloseConnectionWrapper requires 3 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + Napi::Function cb = info[2].As(); + CloseConnectionWorker* worker = new CloseConnectionWorker(cb, pid, cid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 5: Execute asynchronously +// +class ExecuteWorker : public Napi::AsyncWorker { +public: + ExecuteWorker(Napi::Function& callback, int64_t poolId, int64_t connId, std::string payload) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), payload_(payload), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoSlice goPayload = {(void*)payload_.data(), (ptrdiff_t)payload_.length(), (ptrdiff_t)payload_.length()}; + result_ = ::Execute(poolId_, connId_, goPayload); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + // Release the pinner ID of the response buffer immediately after copy! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + std::string payload_; + Execute_return result_; +}; + +Napi::Value ExecuteWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "ExecuteWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsBuffer()) { + Napi::Error::New(env, "Invalid argument types in ExecuteWrapper").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + + Napi::Buffer buffer = info[2].As>(); + std::string payload(reinterpret_cast(buffer.Data()), buffer.Length()); + + Napi::Function cb = info[3].As(); + ExecuteWorker* worker = new ExecuteWorker(cb, pid, cid, payload); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 6: Next asynchronously +// +class NextWorker : public Napi::AsyncWorker { +public: + NextWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId, int32_t numRows, int32_t encodeOtp) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), numRows_(numRows), encodeOtp_(encodeOtp), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Next(poolId_, connId_, rowsId_, numRows_, encodeOtp_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + // Release the pinner ID of the response buffer immediately after copy! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + int32_t numRows_, encodeOtp_; + Next_return result_; +}; + +Napi::Value NextWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 6) { + Napi::Error::New(env, "NextWrapper requires 6 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + int32_t num = info[3].As().Int32Value(); + int32_t encode = info[4].As().Int32Value(); + Napi::Function cb = info[5].As(); + + NextWorker* worker = new NextWorker(cb, pid, cid, rid, num, encode); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 7: Metadata asynchronously +// +class MetadataWorker : public Napi::AsyncWorker { +public: + MetadataWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Metadata(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + // Release the pinner ID of the response buffer immediately after copy! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + Metadata_return result_; +}; + +// +// Worker 8: CloseRows asynchronously +// +class CloseRowsWorker : public Napi::AsyncWorker { +public: + CloseRowsWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::CloseRows(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, result_.r0)); + obj.Set("r1", Napi::Number::New(env, result_.r1)); + obj.Set("r2", Napi::Number::New(env, result_.r2)); + obj.Set("r3", Napi::Number::New(env, result_.r3)); + if (result_.r4 != nullptr && result_.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result_.r4, result_.r3)); + } else { + obj.Set("r4", env.Null()); + } + // Release the pinner ID of the response message to prevent native leak! + if (result_.r0 > 0) { + ::Release(result_.r0); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + CloseRows_return result_; +}; + +Napi::Value MetadataWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "MetadataWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + MetadataWorker* worker = new MetadataWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + +// Memory Release (Synchronous as it is just freeing RAM via GC) +Napi::Value NativeRelease(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) return env.Null(); + int64_t pid = info[0].As().Int64Value(); + Release(pid); + return env.Undefined(); +} + +// CloseRows dummy/missing implementation for POC length if needed, or we just rely on GC. +Napi::Value CloseRowsWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "CloseRowsWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + CloseRowsWorker* worker = new CloseRowsWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("CreatePool", Napi::Function::New(env, CreatePoolWrapper)); + exports.Set("ClosePool", Napi::Function::New(env, ClosePoolWrapper)); + exports.Set("CreateConnection", Napi::Function::New(env, CreateConnectionWrapper)); + exports.Set("CloseConnection", Napi::Function::New(env, CloseConnectionWrapper)); + exports.Set("Execute", Napi::Function::New(env, ExecuteWrapper)); + exports.Set("Next", Napi::Function::New(env, NextWrapper)); + + exports.Set("CloseRows", Napi::Function::New(env, CloseRowsWrapper)); + exports.Set("Release", Napi::Function::New(env, NativeRelease)); + + exports.Set("Metadata", Napi::Function::New(env, MetadataWrapper)); + return exports; +} + +NODE_API_MODULE(spanner_napi, Init) diff --git a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts new file mode 100644 index 00000000..313b27f9 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -0,0 +1,84 @@ +// Copyright 2026 Google LLC +// +// 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 bindings from 'bindings'; +const addon = bindings('spanner_napi'); + +export const ENCODING_JSON = 0; +export const ENCODING_PROTOBUF = 1; + +export interface HandledResult { + objectId: number; + pinnerId: number; + protobufBytes: Buffer | null; +} + +/** + * Represents the structure of the result object returned by the native C++ addon. + * The fields map to the output parameters of the underlying Go library functions. + */ +interface AddonResult { + r0: number; + r1: number; + r2: number; + r3: number; + r4: Buffer | null; +} + +function invokeAsync( + funcName: string, + ...args: unknown[] +): Promise { + return new Promise((resolve, reject) => { + const callback = (err: unknown, result: AddonResult) => { + if (err) { + return reject(err); + } + if (result.r1 !== 0) { + if (result.r0 && result.r0 > 0) { + ffi.Release(result.r0); + } + if (result.r4 && result.r3 > 0) { + const errorJson = result.r4.toString('utf8'); + try { + const parsed = JSON.parse(errorJson); + return reject(new Error(parsed.message || errorJson)); + } catch { + return reject(new Error(errorJson)); + } + } + return reject(new Error(`Native Spanner Error Code: ${result.r1}`)); + } + + resolve({ + objectId: result.r2, + pinnerId: result.r0, + protobufBytes: result.r4, + }); + }; + + addon[funcName](...args, callback); + }); +} + +export const ffi = { + invokeAsync, + Release: addon.Release, +}; +export class SpannerLibError extends Error { + constructor(message: string) { + super(message); + this.name = 'SpannerLibError'; + } +} diff --git a/spannerlib/wrappers/spannerlib-node/src/index.ts b/spannerlib/wrappers/spannerlib-node/src/index.ts new file mode 100644 index 00000000..3420ba7c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/index.ts @@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// 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 { spannerLib } from './lib/spannerlib.js'; +import { Pool } from './lib/pool.js'; +import { Connection } from './lib/connection.js'; +import { Rows } from './lib/rows.js'; +import { SpannerLibError } from './ffi/utils.js'; + +/** + * Releases all pinned resources and handles managed by the library. + * This method should be called when shutting down the application or when the wrapper is no longer needed to prevent native memory leaks. + */ +export function cleanup(): void { + spannerLib.releaseAll(); +} + +export { Pool, Connection, Rows, SpannerLibError }; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts new file mode 100644 index 00000000..da1f79d0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// 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 { ffi } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Pool } from './pool.js'; +import { Rows } from './rows.js'; +// TODO: Avoid tight coupling to internal paths of full client libraries. +// Unlike other languages like Java, Python , Node client does not export its protos. +// We need to explore how to import protos in Node +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; + +/** + * Manages a connection to the Spanner database. + * + * This class wraps the connection handle from the underlying Go library, + * providing methods to execute SQL statements and manage transactions. + */ +export class Connection { + public pool: Pool | null; + public oid: number | null; + public pinnerId: number | null; + public closed: boolean; + + /** + * Creates a new connection within the specified pool. + * + * @param pool The pool to create the connection in. + * @returns A Promise that resolves to a new Connection instance. + * @throws {SpannerLibError} If creation fails in the Go library. + */ + static async create(pool: Pool): Promise { + const c = new Connection(); + c.pool = pool; + + const handled = await ffi.invokeAsync( + 'CreateConnection', + c, + spannerLib, + pool.oid + ); + + c.oid = handled.objectId; + c.pinnerId = handled.pinnerId; + spannerLib.register(c, handled.pinnerId); + return c; + } + + constructor() { + this.pool = null; + this.oid = null; + this.pinnerId = null; + this.closed = false; + } + + /** + * Executes a SQL statement on this connection. + * + * @param sqlString The SQL query string to execute. + * @returns A Promise that resolves to a Rows instance containing results. + * @throws {Error} If the connection is closed or not bound to a pool. + * @throws {SpannerLibError} If execution fails in the Go library. + */ + async executeSql(sqlString: string): Promise { + if (this.closed) throw new Error('Connection is already closed'); + if (!this.pool) throw new Error('Connection is not bound to a Pool'); + + const requestObj = { sql: sqlString, session: 'poc/dummy' }; + const ExecuteSqlRequestProto = google.spanner.v1.ExecuteSqlRequest; + const serializedPb = ExecuteSqlRequestProto.encode(requestObj).finish(); + + const rowsResult = await ffi.invokeAsync( + 'Execute', + null, + null, + this.pool.oid, + this.oid, + serializedPb + ); + const rowsId = rowsResult.objectId; + const rowsPinnerId = rowsResult.pinnerId; + + return new Rows(this, rowsId, rowsPinnerId); + } + + /** + * Closes the connection and releases associated resources. + * + * @returns A Promise that resolves when the connection is closed. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if (this.pool && this.oid !== null) { + await ffi.invokeAsync( + 'CloseConnection', + this, + spannerLib, + this.pool.oid, + this.oid + ); + } + } finally { + if (this.pinnerId !== null) { + spannerLib.unregister(this, this.pinnerId); + } + } + } + } +} diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts new file mode 100644 index 00000000..bcfb4425 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -0,0 +1,97 @@ +// Copyright 2026 Google LLC +// +// 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 { ffi } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; + +/** + * Manages a pool of database connections to Spanner. + * + * This class wraps the connection pool handle from the underlying Go library, + * providing methods to create connections and manage the pool lifecycle. + */ +export class Pool { + public oid: number | null; + public pinnerId: number | null; + public closed: boolean; + public userAgent: string; + public connStr: string; + + /** + * Creates a new connection pool. + * + * @param connectionString The connection string for the database. + * @returns A Promise that resolves to a new Pool instance. + * @throws {SpannerLibError} If creation fails in the Go library. + */ + static async create(connectionString: string): Promise { + // Detect if running in ESM context + const isESM = typeof require === 'undefined'; + const userAgentSuffix = isESM ? 'node-esm' : 'node-cjs'; + + const p = new Pool(userAgentSuffix, connectionString); + const handled = await ffi.invokeAsync( + 'CreatePool', + p, + spannerLib, + userAgentSuffix, + connectionString + ); + + p.oid = handled.objectId; + p.pinnerId = handled.pinnerId; + spannerLib.register(p, handled.pinnerId); + return p; + } + + constructor(userAgent: string, connectionString: string) { + this.oid = null; + this.pinnerId = null; + this.closed = false; + this.userAgent = userAgent; + this.connStr = connectionString; + } + + /** + * Creates a new connection from the pool. + * + * @returns A Promise that resolves to a new Connection instance. + * @throws {Error} If the pool is closed. + */ + async createConnection(): Promise { + if (this.closed) throw new Error('Pool is already closed'); + return await Connection.create(this); + } + + /** + * Closes the pool and releases associated resources. + * + * @returns A Promise that resolves when the pool is closed. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if (this.oid !== null) { + await ffi.invokeAsync('ClosePool', this, spannerLib, this.oid); + } + } finally { + if (this.pinnerId !== null) { + spannerLib.unregister(this, this.pinnerId); + } + } + } + } +} diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts new file mode 100644 index 00000000..5535f3a6 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -0,0 +1,100 @@ +// Copyright 2026 Google LLC +// +// 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 { ffi, ENCODING_PROTOBUF } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; +// TODO: Avoid tight coupling to internal paths of full client libraries. +// Unlike other languages like Java, Python , Node client does not export its protos. +// We need to explore how to import protos in Node +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; +const ListValue = google.protobuf.ListValue; + +/** + * An iterator over results returned by a SQL query. + * + * This class wraps the rows handle from the underlying Go library, + * providing methods to fetch rows one by one. + */ +export class Rows { + public connection: Connection; + public oid: number; + public pinnerId: number | null; + public closed: boolean; + constructor(connection: Connection, oid: number, pinnerId: number) { + this.connection = connection; + this.oid = oid; + this.pinnerId = pinnerId; + this.closed = false; + spannerLib.register(this, pinnerId); + } + + /** + * Fetches the next row of data. + * + * @returns A Promise that resolves to a ListValue containing the row data, or null if there are no more rows. + * @throws {Error} If the rows are already closed. + * @throws {SpannerLibError} If fetching fails in the Go library. + */ + async next(): Promise { + if (this.closed) throw new Error('Rows are already closed'); + + if (!this.connection.pool) { + throw new Error('Connection must be bound to a Pool to fetch rows'); + } + + const handled = await ffi.invokeAsync( + 'Next', + null, + null, + this.connection.pool.oid, + this.connection.oid, + this.oid, + 1, + ENCODING_PROTOBUF + ); + + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + return ListValue.decode(handled.protobufBytes); + } + + /** + * Closes the rows iterator and releases associated resources. + * + * @returns A Promise that resolves when the rows are closed. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + await ffi.invokeAsync( + 'CloseRows', + this, + spannerLib, + this.connection.pool!.oid, + this.connection.oid, + this.oid + ); + } finally { + if (this.pinnerId !== null) { + spannerLib.unregister(this, this.pinnerId); + } + } + } + } +} diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts new file mode 100644 index 00000000..c645aaa1 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// 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 { ffi } from '../ffi/utils.js'; + +export class SpannerLib { + private activePinners: Set; + private registry: FinalizationRegistry; + + constructor() { + this.activePinners = new Set(); + + this.registry = new FinalizationRegistry((pinnerId: number) => { + if (pinnerId && pinnerId > 0) { + ffi.Release(pinnerId); + this.activePinners.delete(pinnerId); + } + }); + } + + register(refInstance: object, pinnerId: number): void { + if (pinnerId > 0) { + this.activePinners.add(pinnerId); + this.registry.register(refInstance, pinnerId, refInstance); + } + } + + unregister(refInstance: object, pinnerId: number): void { + if (pinnerId > 0) { + ffi.Release(pinnerId); + this.registry.unregister(refInstance); + this.activePinners.delete(pinnerId); + } + } + + releaseAll(): void { + for (const pinnerId of this.activePinners) { + ffi.Release(pinnerId); + } + this.activePinners.clear(); + } +} + +export const spannerLib = new SpannerLib(); diff --git a/spannerlib/wrappers/spannerlib-node/test/connection.test.ts b/spannerlib/wrappers/spannerlib-node/test/connection.test.ts new file mode 100644 index 00000000..61c51b8e --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/connection.test.ts @@ -0,0 +1,87 @@ +// Copyright 2026 Google LLC +// +// 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 * as assert from 'assert'; +import { Connection } from '../src/lib/connection.js'; +import { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; + +describe('Connection', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + it('should execute SQL successfully', async () => { + // Mock Pool + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + // Mock invokeAsync response for Execute + stub + .onFirstCall() + .resolves({ objectId: 3, pinnerId: 0, protobufBytes: null }); + + const rows = await connection.executeSql('SELECT 1'); + + assert.ok(rows, 'Rows should be returned'); + assert.strictEqual(rows.oid, 3, 'Rows OID should match'); + assert.strictEqual( + rows.connection, + connection, + 'Rows connection should match' + ); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'Execute', + 'First call should be Execute' + ); + }); + + it('should throw error if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + + await assert.rejects(async () => { + await connection.executeSql('SELECT 1'); + }, /Connection is already closed/); + }); + + it('should throw error if pool is not bound', async () => { + const connection = new Connection(); + + await assert.rejects(async () => { + await connection.executeSql('SELECT 1'); + }, /Connection is not bound to a Pool/); + }); +}); diff --git a/spannerlib/wrappers/spannerlib-node/test/index.test.ts b/spannerlib/wrappers/spannerlib-node/test/index.test.ts new file mode 100644 index 00000000..5feb8ed9 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/index.test.ts @@ -0,0 +1,36 @@ +// Copyright 2026 Google LLC +// +// 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 * as assert from 'assert'; +import { Pool, Connection, Rows } from '../src/index.js'; + +describe('SpannerLib Node Wrapper', () => { + it('should correctly export the primary interface classes', () => { + assert.ok(Pool, 'Pool class should be exported'); + assert.ok(Connection, 'Connection class should be exported'); + assert.ok(Rows, 'Rows class should be exported'); + }); + + it('should instantiate a Pool object properly', () => { + const pool = new Pool( + 'test-agent', + 'projects/test/instances/test/databases/test' + ); + assert.strictEqual(pool.userAgent, 'test-agent'); + assert.strictEqual( + pool.connStr, + 'projects/test/instances/test/databases/test' + ); + }); +}); diff --git a/spannerlib/wrappers/spannerlib-node/test/pool.test.ts b/spannerlib/wrappers/spannerlib-node/test/pool.test.ts new file mode 100644 index 00000000..db58b581 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/pool.test.ts @@ -0,0 +1,78 @@ +// Copyright 2026 Google LLC +// +// 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 * as assert from 'assert'; +import { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; + +describe('Pool', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + it('should create a Pool successfully', async () => { + const connStr = 'projects/test/instances/test/databases/test'; + + // Mock invokeAsync response for CreatePool + stub + .onFirstCall() + .resolves({ objectId: 1, pinnerId: 2, protobufBytes: null }); + + const pool = await Pool.create(connStr); + + assert.ok(pool, 'Pool should be created'); + assert.strictEqual(pool.oid, 1, 'Pool OID should match'); + assert.strictEqual(pool.pinnerId, 2, 'Pool pinnerId should match'); + assert.strictEqual(pool.connStr, connStr, 'Connection string should match'); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'CreatePool', + 'First call should be CreatePool' + ); + + // Verify user agent suffix detection + const isESM = typeof require === 'undefined'; + const expectedUA = isESM ? 'node-esm' : 'node-cjs'; + assert.strictEqual( + stub.firstCall.args[3], + expectedUA, + `User agent should be ${expectedUA}` + ); + }); + + it('should throw error on create connection if pool is closed', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.closed = true; + + await assert.rejects(async () => { + await pool.createConnection(); + }, /Pool is already closed/); + }); +}); diff --git a/spannerlib/wrappers/spannerlib-node/test/rows.test.ts b/spannerlib/wrappers/spannerlib-node/test/rows.test.ts new file mode 100644 index 00000000..a864ede8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/rows.test.ts @@ -0,0 +1,101 @@ +// Copyright 2026 Google LLC +// +// 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 * as assert from 'assert'; +import { Rows } from '../src/lib/rows.js'; +import { Connection } from '../src/lib/connection.js'; +import { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; +const ListValue = google.protobuf.ListValue; +const Value = google.protobuf.Value; + +describe('Rows', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + interface MockListValue { + values: Array<{ stringValue: string }>; + } + + it('should fetch next row successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3, 4); + + // Create a dummy ListValue + const listValue = ListValue.create({ + values: [Value.create({ stringValue: '1' })], + }); + const buffer = ListValue.encode(listValue).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); + + const row = (await rows.next()) as unknown as MockListValue; + + assert.ok(row, 'Row should be returned'); + assert.strictEqual(row.values.length, 1, 'Row should have 1 value'); + assert.strictEqual(row.values[0].stringValue, '1', 'Value should be "1"'); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'Next', + 'First call should be Next' + ); + }); + + it('should return null when no more rows', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3, 4); + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: null }); + + const row = await rows.next(); + + assert.strictEqual(row, null, 'Row should be null'); + }); +}); diff --git a/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json new file mode 100644 index 00000000..9126f3f5 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "gts/tsconfig-google.json", + "compilerOptions": { + "target": "es2023", + "outDir": "build/cjs", + "esModuleInterop": true, + "types": ["node", "mocha"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/spannerlib/wrappers/spannerlib-node/tsconfig.json b/spannerlib/wrappers/spannerlib-node/tsconfig.json new file mode 100644 index 00000000..b3446bf4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "gts/tsconfig-google.json", + "compilerOptions": { + "target": "es2023", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "build/esm", + "esModuleInterop": true, + "types": ["node", "mocha"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "build"] +}