From 1b4d97d50c149c2844f5c0cb1ef7ae7fbdb731fd Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 11:42:05 +0530 Subject: [PATCH 01/11] feat(spannerlib-node): add support for Node wrapper with TypeScript and dual-publishing (ESM/CJS) --- .gitignore | 4 + spannerlib/.gitignore | 2 + .../wrappers/spannerlib-node/.mocharc.json | 5 + spannerlib/wrappers/spannerlib-node/.nycrc | 11 + .../wrappers/spannerlib-node/.prettierrc | 10 + spannerlib/wrappers/spannerlib-node/README.md | 48 +++ .../wrappers/spannerlib-node/binding.gyp | 57 +++ .../wrappers/spannerlib-node/package.json | 47 +++ .../wrappers/spannerlib-node/src/cpp/addon.cc | 338 ++++++++++++++++++ .../wrappers/spannerlib-node/src/ffi/utils.ts | 51 +++ .../wrappers/spannerlib-node/src/index.ts | 16 + .../spannerlib-node/src/lib/connection.ts | 90 +++++ .../wrappers/spannerlib-node/src/lib/pool.ts | 54 +++ .../wrappers/spannerlib-node/src/lib/rows.ts | 90 +++++ .../spannerlib-node/src/lib/spannerlib.ts | 41 +++ .../spannerlib-node/test/index.test.ts | 16 + .../spannerlib-node/tsconfig.cjs.json | 16 + .../wrappers/spannerlib-node/tsconfig.json | 17 + 18 files changed, 913 insertions(+) create mode 100644 spannerlib/wrappers/spannerlib-node/.mocharc.json create mode 100644 spannerlib/wrappers/spannerlib-node/.nycrc create mode 100644 spannerlib/wrappers/spannerlib-node/.prettierrc create mode 100644 spannerlib/wrappers/spannerlib-node/README.md create mode 100644 spannerlib/wrappers/spannerlib-node/binding.gyp create mode 100644 spannerlib/wrappers/spannerlib-node/package.json create mode 100644 spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc create mode 100644 spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts create mode 100644 spannerlib/wrappers/spannerlib-node/src/index.ts create mode 100644 spannerlib/wrappers/spannerlib-node/src/lib/connection.ts create mode 100644 spannerlib/wrappers/spannerlib-node/src/lib/pool.ts create mode 100644 spannerlib/wrappers/spannerlib-node/src/lib/rows.ts create mode 100644 spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts create mode 100644 spannerlib/wrappers/spannerlib-node/test/index.test.ts create mode 100644 spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json create mode 100644 spannerlib/wrappers/spannerlib-node/tsconfig.json 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/.mocharc.json b/spannerlib/wrappers/spannerlib-node/.mocharc.json new file mode 100644 index 00000000..fe2a1ae3 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/.mocharc.json @@ -0,0 +1,5 @@ +{ + "timeout": 10000, + "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/README.md b/spannerlib/wrappers/spannerlib-node/README.md new file mode 100644 index 00000000..12185f07 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/README.md @@ -0,0 +1,48 @@ +# 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.js`**: Helper functions to invoke native methods asynchronously using Promises. +3. **`src/lib/`**: JavaScript classes (`Pool`, `Connection`, `Rows`) that provide a clean object-oriented interface. diff --git a/spannerlib/wrappers/spannerlib-node/binding.gyp b/spannerlib/wrappers/spannerlib-node/binding.gyp new file mode 100644 index 00000000..6b8affcd --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/binding.gyp @@ -0,0 +1,57 @@ +{ + 'targets': [ + { + 'target_name': 'spanner_napi', + 'sources': [ 'src/cpp/addon.cc' ], + 'include_dirs': [ + '=20.0.0" + }, + "scripts": { + "build": "node-gyp rebuild", + "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.so @loader_path/libspanner.so ./build/Release/spanner_napi.node')\"", + "compile:esm": "tsc -p .", + "compile:cjs": "tsc -p ./tsconfig.cjs.json", + "compile": "npm run compile:esm && npm run compile:cjs", + "test": "mocha build/esm/test/**/*.js" + }, + "files": [ + "build/esm", + "build/cjs", + "build/Release/*.node" + ], + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "devDependencies": { + "mocha": "^10.2.0", + "typescript": "^5.4.0", + "@types/node": "^20.11.0", + "@types/mocha": "^10.0.6", + "@google-cloud/spanner": "^7.13.0" + }, + "gypfile": false +} 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..f24b811e --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc @@ -0,0 +1,338 @@ +#include +#include +#include "libspanner.h" + +// +// 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(); + 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_); + } + + 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(); + 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(); + 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_); + } + + 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(); + 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()); + } + 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(); + 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()); + } + 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(); + 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()); + } + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + Metadata_return result_; +}; + +Napi::Value MetadataWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + 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() < 3) return env.Null(); + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + + // N-API sync close implementation + CloseRows(pid, cid, rid); + + // invokeAsync appends a callback at the end of properties + if (info.Length() >= 4 && info[3].IsFunction()) { + Napi::Object obj = Napi::Object::New(env); + obj.Set("r1", Napi::Number::New(env, 0)); + Napi::Function cb = info[3].As(); + cb.Call({env.Null(), obj}); // Mock empty GoReturnTuple callback + } + 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..f17e7769 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -0,0 +1,51 @@ +import { createRequire } from 'module'; +// @ts-ignore +const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +const addon = _require('../../../Release/spanner_napi.node'); + +export const ENCODING_JSON = 0; +export const ENCODING_PROTOBUF = 1; + +export interface HandledResult { + objectId: number; + pinnerId: number; + protobufBytes: Buffer | null; +} + +export function invokeAsync(funcName: string, constructor1: any, constructor2: any, ...args: any[]): Promise { + return new Promise((resolve, reject) => { + const callback = (err: any, result: any) => { + if (err) { + return reject(err); + } + if (result.r1 !== 0) { + 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 (e) { + 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 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..50c10440 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/index.ts @@ -0,0 +1,16 @@ +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'; + +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..86eacf67 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -0,0 +1,90 @@ +import { invokeAsync } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Pool } from './pool.js'; +import { Rows } from './rows.js'; +import { createRequire } from 'module'; +// @ts-ignore +const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); + +export class Connection { + public pool: Pool | null; + public oid: number | null; + public pinnerId: number | null; + public closed: boolean; + + static async create(pool: Pool): Promise { + const c = new Connection(); + c.pool = pool; + + const handled = await invokeAsync( + "CreateConnection", + c, + spannerLib, + pool.oid + ); + + c.oid = handled.objectId; + c.pinnerId = handled.pinnerId; + return c; + } + + constructor() { + this.pool = null; + this.oid = null; + this.pinnerId = null; + this.closed = false; + } + + 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 invokeAsync( + "Execute", + null, + null, + this.pool.oid, + this.oid, + serializedPb + ); + const rowsId = rowsResult.objectId; + + const metadataResult = await invokeAsync( + "Metadata", + null, + null, + this.pool.oid, + this.oid, + rowsId + ); + + const ResultSetMetadataProto = google.spanner.v1.ResultSetMetadata; + const metadata = ResultSetMetadataProto.decode(metadataResult.protobufBytes); + const columnInfo = metadata.rowType.fields.map((field: any) => ({ + name: field.name, + typeCode: field.type.code + })); + + return new Rows(this, rowsId, columnInfo); + } + + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if (this.pool && this.oid !== null) { + await 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..08192b86 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -0,0 +1,54 @@ +import { invokeAsync } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; + +export class Pool { + public oid: number | null; + public pinnerId: number | null; + public closed: boolean; + public userAgent: string; + public connStr: string; + + static async create(userAgent: string, connectionString: string): Promise { + const p = new Pool(userAgent, connectionString); + const handled = await invokeAsync( + "CreatePool", + p, + spannerLib, + userAgent, + connectionString + ); + + p.oid = handled.objectId; + p.pinnerId = handled.pinnerId; + return p; + } + + constructor(userAgent: string, connectionString: string) { + this.oid = null; + this.pinnerId = null; + this.closed = false; + this.userAgent = userAgent; + this.connStr = connectionString; + } + + async createConnection(): Promise { + if (this.closed) throw new Error("Pool is already closed"); + return await Connection.create(this); + } + + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if (this.oid !== null) { + await 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..d9d70691 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -0,0 +1,90 @@ +import { invokeAsync, ENCODING_PROTOBUF } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; +import { createRequire } from 'module'; +// @ts-ignore +const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +const ListValue = google.protobuf.ListValue; + +function parseRowToObject(buffer: Buffer | null, columnInfo: Array<{name: string, typeCode: number}>): object | null { + if (!buffer || buffer.length === 0) { + return null; + } + + const listValue = ListValue.decode(buffer); + const rowObject: any = {}; + const values = listValue.values; + + columnInfo.forEach((column, index) => { + const value = values[index]; + const columnName = column.name; + let parsedValue; + + switch (value.kind) { + case 'nullValue': + parsedValue = null; + break; + case 'numberValue': + parsedValue = value.numberValue; + break; + case 'stringValue': + parsedValue = value.stringValue; + break; + case 'boolValue': + parsedValue = value.boolValue; + break; + default: + parsedValue = undefined; + } + rowObject[columnName] = parsedValue; + }); + + return rowObject; +} + +export class Rows { + public connection: Connection; + public oid: number; + public pinnerId: number | null; + public closed: boolean; + public columnInfo: Array<{name: string, typeCode: number}>; + + constructor(connection: Connection, oid: number, columnInfo: Array<{name: string, typeCode: number}>) { + this.connection = connection; + this.oid = oid; + this.pinnerId = null; + this.closed = false; + this.columnInfo = columnInfo; + } + + async next(): Promise { + if (this.closed) throw new Error("Rows are already closed"); + + const handled = await invokeAsync( + "Next", + null, + null, + this.connection.pool!.oid, + this.connection.oid, + this.oid, + 1, + ENCODING_PROTOBUF + ); + + return parseRowToObject(handled.protobufBytes, this.columnInfo); + } + + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + await 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..b6181d9a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts @@ -0,0 +1,41 @@ +import { Release } 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) { + 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) { + Release(pinnerId); + this.registry.unregister(refInstance); + this.activePinners.delete(pinnerId); + } + } + + releaseAll(): void { + for (const pinnerId of this.activePinners) { + Release(pinnerId); + } + this.activePinners.clear(); + } +} + +export const spannerLib = new SpannerLib(); 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..628e13dc --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/index.test.ts @@ -0,0 +1,16 @@ +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/tsconfig.cjs.json b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json new file mode 100644 index 00000000..8f34eb59 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "commonjs", + "lib": ["es2023"], + "strict": true, + "noImplicitAny": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "build/cjs", + "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..21634c53 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["es2023"], + "strict": true, + "noImplicitAny": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "build/esm", + "types": ["node", "mocha"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "build"] +} From 1ebcdf937f158194bc5fd6d753b0af335589a0d4 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 12:24:38 +0530 Subject: [PATCH 02/11] feat(spannerlib-node): configure Babel for .cjs outputs and update entry points --- .../wrappers/spannerlib-node/babel.config.json | 5 +++++ spannerlib/wrappers/spannerlib-node/package.json | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 spannerlib/wrappers/spannerlib-node/babel.config.json diff --git a/spannerlib/wrappers/spannerlib-node/babel.config.json b/spannerlib/wrappers/spannerlib-node/babel.config.json new file mode 100644 index 00000000..b3b4cda3 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/babel.config.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + ["replace-import-extension", { "extMapping": { ".js": ".cjs" } }] + ] +} diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index b88dd1ff..5ee7981b 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -1,9 +1,9 @@ { "name": "spannerlib-node", "version": "0.1.0", - "main": "./build/cjs/src/index.js", + "main": "./build/cjs/src/index.cjs", "module": "./build/esm/src/index.js", - "types": "./build/esm/src/index.d.ts", + "types": "./build/cjs/src/index.d.ts", "type": "module", "exports": { ".": { @@ -13,7 +13,7 @@ }, "require": { "types": "./build/cjs/src/index.d.ts", - "default": "./build/cjs/src/index.js" + "default": "./build/cjs/src/index.cjs" } } }, @@ -24,7 +24,7 @@ "build": "node-gyp rebuild", "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.so @loader_path/libspanner.so ./build/Release/spanner_napi.node')\"", "compile:esm": "tsc -p .", - "compile:cjs": "tsc -p ./tsconfig.cjs.json", + "compile:cjs": "tsc -p ./tsconfig.cjs.json && babel build/cjs --out-dir build/cjs --out-file-extension .cjs && find build/cjs -name '*.js' -delete", "compile": "npm run compile:esm && npm run compile:cjs", "test": "mocha build/esm/test/**/*.js" }, @@ -41,7 +41,10 @@ "typescript": "^5.4.0", "@types/node": "^20.11.0", "@types/mocha": "^10.0.6", - "@google-cloud/spanner": "^7.13.0" + "@google-cloud/spanner": "^7.13.0", + "@babel/core": "^7.24.0", + "@babel/cli": "^7.23.9", + "babel-plugin-replace-import-extension": "^1.1.4" }, "gypfile": false } From 6bdd9872b3eecb0b4a56111619d12f8e8ec56c59 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 12:48:50 +0530 Subject: [PATCH 03/11] chore(spannerlib-node): add copyright headers and update package configuration --- .../wrappers/spannerlib-node/package.json | 6 ++--- .../wrappers/spannerlib-node/src/cpp/addon.cc | 14 +++++++++++ .../wrappers/spannerlib-node/src/ffi/utils.ts | 14 +++++++++++ .../wrappers/spannerlib-node/src/index.ts | 14 +++++++++++ .../spannerlib-node/src/lib/connection.ts | 14 +++++++++++ .../wrappers/spannerlib-node/src/lib/pool.ts | 24 ++++++++++++++++--- .../wrappers/spannerlib-node/src/lib/rows.ts | 14 +++++++++++ .../spannerlib-node/src/lib/spannerlib.ts | 14 +++++++++++ .../spannerlib-node/test/index.test.ts | 14 +++++++++++ 9 files changed, 122 insertions(+), 6 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index 5ee7981b..8f876919 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -21,7 +21,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "node-gyp rebuild", + "build": "node-gyp rebuild && npm run compile", "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.so @loader_path/libspanner.so ./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 && find build/cjs -name '*.js' -delete", @@ -29,8 +29,8 @@ "test": "mocha build/esm/test/**/*.js" }, "files": [ - "build/esm", - "build/cjs", + "build/esm/src", + "build/cjs/src", "build/Release/*.node" ], "dependencies": { diff --git a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc index f24b811e..ae4e2743 100644 --- a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc +++ b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc @@ -1,3 +1,17 @@ +// 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" diff --git a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts index f17e7769..fb37b976 100644 --- a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -1,3 +1,17 @@ +// 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 { createRequire } from 'module'; // @ts-ignore const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); diff --git a/spannerlib/wrappers/spannerlib-node/src/index.ts b/spannerlib/wrappers/spannerlib-node/src/index.ts index 50c10440..00d066ff 100644 --- a/spannerlib/wrappers/spannerlib-node/src/index.ts +++ b/spannerlib/wrappers/spannerlib-node/src/index.ts @@ -1,3 +1,17 @@ +// 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'; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts index 86eacf67..f3865b10 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -1,3 +1,17 @@ +// 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 { invokeAsync } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Pool } from './pool.js'; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts index 08192b86..001c0894 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -1,3 +1,17 @@ +// 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 { invokeAsync } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Connection } from './connection.js'; @@ -9,13 +23,17 @@ export class Pool { public userAgent: string; public connStr: string; - static async create(userAgent: string, connectionString: string): Promise { - const p = new Pool(userAgent, connectionString); + 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 invokeAsync( "CreatePool", p, spannerLib, - userAgent, + userAgentSuffix, connectionString ); diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts index d9d70691..b8b50f05 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -1,3 +1,17 @@ +// 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 { invokeAsync, ENCODING_PROTOBUF } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Connection } from './connection.js'; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts index b6181d9a..116906c2 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts @@ -1,3 +1,17 @@ +// 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 { Release } from '../ffi/utils.js'; export class SpannerLib { diff --git a/spannerlib/wrappers/spannerlib-node/test/index.test.ts b/spannerlib/wrappers/spannerlib-node/test/index.test.ts index 628e13dc..000d6f37 100644 --- a/spannerlib/wrappers/spannerlib-node/test/index.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/index.test.ts @@ -1,3 +1,17 @@ +// 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'; From d05bae55e8cbcfe99d413f50ac64770d92af6f41 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 14:38:23 +0530 Subject: [PATCH 04/11] feat(spannerlib-node): return raw protobuf values and update configuration --- .../wrappers/spannerlib-node/binding.gyp | 4 +- .../wrappers/spannerlib-node/package.json | 2 +- .../spannerlib-node/src/lib/connection.ts | 18 +------ .../wrappers/spannerlib-node/src/lib/rows.ts | 47 +++---------------- 4 files changed, 11 insertions(+), 60 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-node/binding.gyp b/spannerlib/wrappers/spannerlib-node/binding.gyp index 6b8affcd..8bd799d9 100644 --- a/spannerlib/wrappers/spannerlib-node/binding.gyp +++ b/spannerlib/wrappers/spannerlib-node/binding.gyp @@ -23,7 +23,7 @@ 'conditions': [ ['OS=="mac"', { 'libraries': [ - '<(module_root_dir)/../../shared/libspanner.so' + '<(module_root_dir)/../../shared/libspanner.dylib' ], 'xcode_settings': { 'OTHER_LDFLAGS': [ @@ -33,7 +33,7 @@ 'copies': [ { 'destination': '<(PRODUCT_DIR)', - 'files': [ '<(module_root_dir)/../../shared/libspanner.so' ] + 'files': [ '<(module_root_dir)/../../shared/libspanner.dylib' ] } ] }], diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index 8f876919..65475075 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -22,7 +22,7 @@ }, "scripts": { "build": "node-gyp rebuild && npm run compile", - "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.so @loader_path/libspanner.so ./build/Release/spanner_napi.node')\"", + "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 && find build/cjs -name '*.js' -delete", "compile": "npm run compile:esm && npm run compile:cjs", diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts index f3865b10..05582688 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -68,23 +68,7 @@ export class Connection { ); const rowsId = rowsResult.objectId; - const metadataResult = await invokeAsync( - "Metadata", - null, - null, - this.pool.oid, - this.oid, - rowsId - ); - - const ResultSetMetadataProto = google.spanner.v1.ResultSetMetadata; - const metadata = ResultSetMetadataProto.decode(metadataResult.protobufBytes); - const columnInfo = metadata.rowType.fields.map((field: any) => ({ - name: field.name, - typeCode: field.type.code - })); - - return new Rows(this, rowsId, columnInfo); + return new Rows(this, rowsId); } async close(): Promise { diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts index b8b50f05..e5029de9 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -21,58 +21,21 @@ const _require = typeof require !== 'undefined' ? require : createRequire(import const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); const ListValue = google.protobuf.ListValue; -function parseRowToObject(buffer: Buffer | null, columnInfo: Array<{name: string, typeCode: number}>): object | null { - if (!buffer || buffer.length === 0) { - return null; - } - - const listValue = ListValue.decode(buffer); - const rowObject: any = {}; - const values = listValue.values; - columnInfo.forEach((column, index) => { - const value = values[index]; - const columnName = column.name; - let parsedValue; - - switch (value.kind) { - case 'nullValue': - parsedValue = null; - break; - case 'numberValue': - parsedValue = value.numberValue; - break; - case 'stringValue': - parsedValue = value.stringValue; - break; - case 'boolValue': - parsedValue = value.boolValue; - break; - default: - parsedValue = undefined; - } - rowObject[columnName] = parsedValue; - }); - - return rowObject; -} export class Rows { public connection: Connection; public oid: number; public pinnerId: number | null; public closed: boolean; - public columnInfo: Array<{name: string, typeCode: number}>; - - constructor(connection: Connection, oid: number, columnInfo: Array<{name: string, typeCode: number}>) { + constructor(connection: Connection, oid: number) { this.connection = connection; this.oid = oid; this.pinnerId = null; this.closed = false; - this.columnInfo = columnInfo; } - async next(): Promise { + async next(): Promise { if (this.closed) throw new Error("Rows are already closed"); const handled = await invokeAsync( @@ -86,7 +49,11 @@ export class Rows { ENCODING_PROTOBUF ); - return parseRowToObject(handled.protobufBytes, this.columnInfo); + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + return ListValue.decode(handled.protobufBytes); } async close(): Promise { From a607af737d9a5eb3569ae0773a9c5d3c6f780d34 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 21:56:01 +0530 Subject: [PATCH 05/11] feat(spannerlib-node): implement ffi object refactor and add unit tests --- .../spannerlib-node/babel.config.json | 5 -- .../wrappers/spannerlib-node/package.json | 9 +- .../scripts/fix-extensions.cjs | 30 +++++++ .../wrappers/spannerlib-node/src/ffi/utils.ts | 7 +- .../spannerlib-node/src/lib/connection.ts | 8 +- .../wrappers/spannerlib-node/src/lib/pool.ts | 6 +- .../wrappers/spannerlib-node/src/lib/rows.ts | 6 +- .../spannerlib-node/src/lib/spannerlib.ts | 8 +- .../spannerlib-node/test/connection.test.ts | 76 +++++++++++++++++ .../spannerlib-node/test/pool.test.ts | 64 ++++++++++++++ .../spannerlib-node/test/rows.test.ts | 84 +++++++++++++++++++ 11 files changed, 279 insertions(+), 24 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-node/babel.config.json create mode 100644 spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs create mode 100644 spannerlib/wrappers/spannerlib-node/test/connection.test.ts create mode 100644 spannerlib/wrappers/spannerlib-node/test/pool.test.ts create mode 100644 spannerlib/wrappers/spannerlib-node/test/rows.test.ts diff --git a/spannerlib/wrappers/spannerlib-node/babel.config.json b/spannerlib/wrappers/spannerlib-node/babel.config.json deleted file mode 100644 index b3b4cda3..00000000 --- a/spannerlib/wrappers/spannerlib-node/babel.config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": [ - ["replace-import-extension", { "extMapping": { ".js": ".cjs" } }] - ] -} diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index 65475075..04e189e6 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -24,9 +24,11 @@ "build": "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 && find build/cjs -name '*.js' -delete", + "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": "mocha build/esm/test/**/*.js" + "test:esm": "mocha build/esm/test/**/*.js", + "test:cjs": "mocha build/cjs/test/**/*.cjs", + "test": "npm run test:esm && npm run test:cjs" }, "files": [ "build/esm/src", @@ -44,7 +46,8 @@ "@google-cloud/spanner": "^7.13.0", "@babel/core": "^7.24.0", "@babel/cli": "^7.23.9", - "babel-plugin-replace-import-extension": "^1.1.4" + "sinon": "^18.0.0", + "@types/sinon": "^17.0.3" }, "gypfile": false } 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..dc5caa4b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs @@ -0,0 +1,30 @@ +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('./... .js') with require('./... .cjs') + content = content.replace(/require\(['"](\.\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); + // Also handle ../ + 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/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts index fb37b976..1aa422ae 100644 --- a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -26,7 +26,7 @@ export interface HandledResult { protobufBytes: Buffer | null; } -export function invokeAsync(funcName: string, constructor1: any, constructor2: any, ...args: any[]): Promise { +function invokeAsync(funcName: string, constructor1: any, constructor2: any, ...args: any[]): Promise { return new Promise((resolve, reject) => { const callback = (err: any, result: any) => { if (err) { @@ -56,7 +56,10 @@ export function invokeAsync(funcName: string, constructor1: any, constructor2: a }); } -export const Release = addon.Release; +export const ffi = { + invokeAsync, + Release: addon.Release +}; export class SpannerLibError extends Error { constructor(message: string) { super(message); diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts index 05582688..5c63f357 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { invokeAsync } from '../ffi/utils.js'; +import { ffi } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Pool } from './pool.js'; import { Rows } from './rows.js'; @@ -31,7 +31,7 @@ export class Connection { const c = new Connection(); c.pool = pool; - const handled = await invokeAsync( + const handled = await ffi.invokeAsync( "CreateConnection", c, spannerLib, @@ -58,7 +58,7 @@ export class Connection { const ExecuteSqlRequestProto = google.spanner.v1.ExecuteSqlRequest; const serializedPb = ExecuteSqlRequestProto.encode(requestObj).finish(); - const rowsResult = await invokeAsync( + const rowsResult = await ffi.invokeAsync( "Execute", null, null, @@ -76,7 +76,7 @@ export class Connection { this.closed = true; try { if (this.pool && this.oid !== null) { - await invokeAsync("CloseConnection", this, spannerLib, this.pool.oid, this.oid); + await ffi.invokeAsync("CloseConnection", this, spannerLib, this.pool.oid, this.oid); } } finally { if (this.pinnerId !== null) { diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts index 001c0894..c1ffa713 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { invokeAsync } from '../ffi/utils.js'; +import { ffi } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Connection } from './connection.js'; @@ -29,7 +29,7 @@ export class Pool { const userAgentSuffix = isESM ? 'node-esm' : 'node-cjs'; const p = new Pool(userAgentSuffix, connectionString); - const handled = await invokeAsync( + const handled = await ffi.invokeAsync( "CreatePool", p, spannerLib, @@ -60,7 +60,7 @@ export class Pool { this.closed = true; try { if (this.oid !== null) { - await invokeAsync("ClosePool", this, spannerLib, this.oid); + await ffi.invokeAsync("ClosePool", this, spannerLib, this.oid); } } finally { if (this.pinnerId !== null) { diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts index e5029de9..f9028588 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { invokeAsync, ENCODING_PROTOBUF } from '../ffi/utils.js'; +import { ffi, ENCODING_PROTOBUF } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Connection } from './connection.js'; import { createRequire } from 'module'; @@ -38,7 +38,7 @@ export class Rows { async next(): Promise { if (this.closed) throw new Error("Rows are already closed"); - const handled = await invokeAsync( + const handled = await ffi.invokeAsync( "Next", null, null, @@ -60,7 +60,7 @@ export class Rows { if (!this.closed) { this.closed = true; try { - await invokeAsync("CloseRows", this, spannerLib, this.connection.pool!.oid, this.connection.oid, this.oid); + 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 index 116906c2..922cd81b 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Release } from '../ffi/utils.js'; +import { ffi } from '../ffi/utils.js'; export class SpannerLib { private activePinners: Set; @@ -23,7 +23,7 @@ export class SpannerLib { this.registry = new FinalizationRegistry((pinnerId: number) => { if (pinnerId && pinnerId > 0) { - Release(pinnerId); + ffi.Release(pinnerId); this.activePinners.delete(pinnerId); } }); @@ -38,7 +38,7 @@ export class SpannerLib { unregister(refInstance: object, pinnerId: number): void { if (pinnerId > 0) { - Release(pinnerId); + ffi.Release(pinnerId); this.registry.unregister(refInstance); this.activePinners.delete(pinnerId); } @@ -46,7 +46,7 @@ export class SpannerLib { releaseAll(): void { for (const pinnerId of this.activePinners) { - Release(pinnerId); + ffi.Release(pinnerId); } this.activePinners.clear(); } 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..a064c9d0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/connection.test.ts @@ -0,0 +1,76 @@ +// 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/pool.test.ts b/spannerlib/wrappers/spannerlib-node/test/pool.test.ts new file mode 100644 index 00000000..c05f2ea5 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/pool.test.ts @@ -0,0 +1,64 @@ +// 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..95c691a7 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/test/rows.test.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 * 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 { createRequire } from 'module'; + +// @ts-ignore +const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +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(); + }); + + 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); + + // 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(); + + 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); + + stub.onFirstCall().resolves({ objectId: 0, pinnerId: 0, protobufBytes: null }); + + const row = await rows.next(); + + assert.strictEqual(row, null, 'Row should be null'); + }); +}); From 926abbe36db8331ca9d8b18c54f4ec106e8fb9d2 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 27 Apr 2026 22:14:59 +0530 Subject: [PATCH 06/11] test(spannerlib-node): increase mocha timeout for production tests --- spannerlib/wrappers/spannerlib-node/.mocharc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-node/.mocharc.json b/spannerlib/wrappers/spannerlib-node/.mocharc.json index fe2a1ae3..45449d62 100644 --- a/spannerlib/wrappers/spannerlib-node/.mocharc.json +++ b/spannerlib/wrappers/spannerlib-node/.mocharc.json @@ -1,5 +1,5 @@ { - "timeout": 10000, + "timeout": 60000, "reporter": "spec", "spec": ["build/esm/test/**/*.js"] } From 570928f8f2e82caac495b56babb0de1072f4f5ff Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 28 Apr 2026 12:31:25 +0530 Subject: [PATCH 07/11] feat(spannerlib-node): add class-level documentation and TODOs for proto resolution --- spannerlib/wrappers/spannerlib-node/README.md | 6 +++- .../wrappers/spannerlib-node/package.json | 5 ++-- .../scripts/fix-extensions.cjs | 14 +++++++++ .../wrappers/spannerlib-node/src/cpp/addon.cc | 7 +++++ .../wrappers/spannerlib-node/src/ffi/utils.ts | 2 +- .../wrappers/spannerlib-node/src/index.ts | 4 +++ .../spannerlib-node/src/lib/connection.ts | 29 +++++++++++++++++++ .../wrappers/spannerlib-node/src/lib/pool.ts | 24 +++++++++++++++ .../wrappers/spannerlib-node/src/lib/rows.ts | 23 +++++++++++++-- 9 files changed, 108 insertions(+), 6 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-node/README.md b/spannerlib/wrappers/spannerlib-node/README.md index 12185f07..3bd651b4 100644 --- a/spannerlib/wrappers/spannerlib-node/README.md +++ b/spannerlib/wrappers/spannerlib-node/README.md @@ -44,5 +44,9 @@ async function run() { 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.js`**: Helper functions to invoke native methods asynchronously using Promises. +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/package.json b/spannerlib/wrappers/spannerlib-node/package.json index 04e189e6..484a3c75 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -36,14 +36,15 @@ "build/Release/*.node" ], "dependencies": { - "node-addon-api": "^8.0.0" + "node-addon-api": "^8.0.0", + "bindings": "^1.5.0", + "@google-cloud/spanner": "^7.13.0" }, "devDependencies": { "mocha": "^10.2.0", "typescript": "^5.4.0", "@types/node": "^20.11.0", "@types/mocha": "^10.0.6", - "@google-cloud/spanner": "^7.13.0", "@babel/core": "^7.24.0", "@babel/cli": "^7.23.9", "sinon": "^18.0.0", diff --git a/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs index dc5caa4b..89add729 100644 --- a/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs +++ b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs @@ -1,3 +1,17 @@ +// 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'); diff --git a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc index ae4e2743..e718616a 100644 --- a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc +++ b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc @@ -16,6 +16,13 @@ #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 // diff --git a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts index 1aa422ae..eb7e7957 100644 --- a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -15,7 +15,7 @@ import { createRequire } from 'module'; // @ts-ignore const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); -const addon = _require('../../../Release/spanner_napi.node'); +const addon = _require('bindings')('spanner_napi'); export const ENCODING_JSON = 0; export const ENCODING_PROTOBUF = 1; diff --git a/spannerlib/wrappers/spannerlib-node/src/index.ts b/spannerlib/wrappers/spannerlib-node/src/index.ts index 00d066ff..f8cad1af 100644 --- a/spannerlib/wrappers/spannerlib-node/src/index.ts +++ b/spannerlib/wrappers/spannerlib-node/src/index.ts @@ -18,6 +18,10 @@ 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(); } diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts index 5c63f357..0efa590b 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -19,14 +19,30 @@ import { Rows } from './rows.js'; import { createRequire } from 'module'; // @ts-ignore const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +// 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 const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +/** + * 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; @@ -50,6 +66,14 @@ export class Connection { 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"); @@ -71,6 +95,11 @@ export class Connection { return new Rows(this, rowsId); } + /** + * 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; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts index c1ffa713..381d64b9 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -16,6 +16,12 @@ 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; @@ -23,6 +29,13 @@ export class Pool { 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'; @@ -50,11 +63,22 @@ export class Pool { 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; diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts index f9028588..d98e9c28 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -18,11 +18,18 @@ import { Connection } from './connection.js'; import { createRequire } from 'module'; // @ts-ignore const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); +// 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 const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); 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; @@ -35,6 +42,13 @@ export class Rows { this.closed = false; } + /** + * 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"); @@ -56,6 +70,11 @@ export class Rows { 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; From 8a59d7d5c6d7e27a9a93a6f41dd9a698ba66fa72 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 28 Apr 2026 14:28:05 +0530 Subject: [PATCH 08/11] feat(spannerlib-node): add workflows for lint and tests, and full automation wrapper --- .../node-spanner-lib-wrapper-lint.yml | 38 +++++++++++++ .../node-spanner-lib-wrapper-unit-tests.yml | 54 ++++++++++++++++++ .../wrappers/spannerlib-node/.editorconfig | 8 +++ .../wrappers/spannerlib-node/.prettierrc.js | 3 + .../spannerlib-node/eslint.config.cjs | 15 +++++ .../spannerlib-node/eslint.ignores.cjs | 1 + .../wrappers/spannerlib-node/package.json | 17 ++++-- .../scripts/build-shared-lib.sh | 57 +++++++++++++++++++ .../spannerlib-node/tsconfig.cjs.json | 9 +-- .../wrappers/spannerlib-node/tsconfig.json | 8 +-- 10 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/node-spanner-lib-wrapper-lint.yml create mode 100644 .github/workflows/node-spanner-lib-wrapper-unit-tests.yml create mode 100644 spannerlib/wrappers/spannerlib-node/.editorconfig create mode 100644 spannerlib/wrappers/spannerlib-node/.prettierrc.js create mode 100644 spannerlib/wrappers/spannerlib-node/eslint.config.cjs create mode 100644 spannerlib/wrappers/spannerlib-node/eslint.ignores.cjs create mode 100755 spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh 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/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/.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/eslint.config.cjs b/spannerlib/wrappers/spannerlib-node/eslint.config.cjs new file mode 100644 index 00000000..79d9e553 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/eslint.config.cjs @@ -0,0 +1,15 @@ +let customConfig = []; +let hasIgnoresFile = false; +try { + require.resolve('./eslint.ignores.cjs'); + hasIgnoresFile = true; +} catch { + // eslint.ignores.js doesn't exist +} + +if (hasIgnoresFile) { + const ignores = require('./eslint.ignores.cjs'); + customConfig = [{ignores}]; +} + +module.exports = [...customConfig, ...require('gts')]; diff --git a/spannerlib/wrappers/spannerlib-node/eslint.ignores.cjs b/spannerlib/wrappers/spannerlib-node/eslint.ignores.cjs new file mode 100644 index 00000000..c134256d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-node/eslint.ignores.cjs @@ -0,0 +1 @@ +module.exports = ['build/'] diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index 484a3c75..d5be5c4d 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -21,14 +21,20 @@ "node": ">=20.0.0" }, "scripts": { - "build": "node-gyp rebuild && npm run compile", + "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" + "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", @@ -42,13 +48,14 @@ }, "devDependencies": { "mocha": "^10.2.0", - "typescript": "^5.4.0", - "@types/node": "^20.11.0", + "typescript": "^5.6.3", + "@types/node": "^22.7.5", "@types/mocha": "^10.0.6", "@babel/core": "^7.24.0", "@babel/cli": "^7.23.9", "sinon": "^18.0.0", - "@types/sinon": "^17.0.3" + "@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..73d71138 --- /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 spannerlib.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/tsconfig.cjs.json b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json index 8f34eb59..9126f3f5 100644 --- a/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.cjs.json @@ -1,14 +1,9 @@ { + "extends": "gts/tsconfig-google.json", "compilerOptions": { "target": "es2023", - "module": "commonjs", - "lib": ["es2023"], - "strict": true, - "noImplicitAny": false, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, "outDir": "build/cjs", + "esModuleInterop": true, "types": ["node", "mocha"] }, "include": ["src/**/*", "test/**/*"], diff --git a/spannerlib/wrappers/spannerlib-node/tsconfig.json b/spannerlib/wrappers/spannerlib-node/tsconfig.json index 21634c53..b3446bf4 100644 --- a/spannerlib/wrappers/spannerlib-node/tsconfig.json +++ b/spannerlib/wrappers/spannerlib-node/tsconfig.json @@ -1,15 +1,11 @@ { + "extends": "gts/tsconfig-google.json", "compilerOptions": { "target": "es2023", "module": "nodenext", "moduleResolution": "nodenext", - "lib": ["es2023"], - "strict": true, - "noImplicitAny": false, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, "outDir": "build/esm", + "esModuleInterop": true, "types": ["node", "mocha"] }, "include": ["src/**/*", "test/**/*"], From bd7b2f82b547627f338192d1d68d562cb8dadeac Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 28 Apr 2026 15:04:05 +0530 Subject: [PATCH 09/11] feat(spannerlib-node): resolve all lint errors and add documentation --- .../wrappers/spannerlib-node/package.json | 1 + .../wrappers/spannerlib-node/src/ffi/utils.ts | 93 +++++----- .../wrappers/spannerlib-node/src/index.ts | 9 +- .../spannerlib-node/src/lib/connection.ts | 162 +++++++++--------- .../wrappers/spannerlib-node/src/lib/pool.ts | 126 +++++++------- .../wrappers/spannerlib-node/src/lib/rows.ts | 117 +++++++------ .../spannerlib-node/src/lib/spannerlib.ts | 60 +++---- .../spannerlib-node/test/connection.test.ts | 97 ++++++----- .../spannerlib-node/test/index.test.ts | 26 +-- .../spannerlib-node/test/pool.test.ts | 86 ++++++---- .../spannerlib-node/test/rows.test.ts | 119 +++++++------ 11 files changed, 482 insertions(+), 414 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-node/package.json b/spannerlib/wrappers/spannerlib-node/package.json index d5be5c4d..55c22ccc 100644 --- a/spannerlib/wrappers/spannerlib-node/package.json +++ b/spannerlib/wrappers/spannerlib-node/package.json @@ -50,6 +50,7 @@ "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", diff --git a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts index eb7e7957..8cb72d40 100644 --- a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -12,57 +12,72 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createRequire } from 'module'; -// @ts-ignore -const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); -const addon = _require('bindings')('spanner_napi'); +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; + objectId: number; + pinnerId: number; + protobufBytes: Buffer | null; } -function invokeAsync(funcName: string, constructor1: any, constructor2: any, ...args: any[]): Promise { - return new Promise((resolve, reject) => { - const callback = (err: any, result: any) => { - if (err) { - return reject(err); - } - if (result.r1 !== 0) { - 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 (e) { - return reject(new Error(errorJson)); - } - } - return reject(new Error(`Native Spanner Error Code: ${result.r1}`)); - } +/** + * 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, + constructor1: unknown, + constructor2: unknown, + ...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.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 - }); - }; + resolve({ + objectId: result.r2, + pinnerId: result.r0, + protobufBytes: result.r4, + }); + }; - addon[funcName](...args, callback); - }); + addon[funcName](...args, callback); + }); } export const ffi = { - invokeAsync, - Release: addon.Release + invokeAsync, + Release: addon.Release, }; export class SpannerLibError extends Error { - constructor(message: string) { - super(message); - this.name = 'SpannerLibError'; - } + 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 index f8cad1af..3420ba7c 100644 --- a/spannerlib/wrappers/spannerlib-node/src/index.ts +++ b/spannerlib/wrappers/spannerlib-node/src/index.ts @@ -23,12 +23,7 @@ import { SpannerLibError } from './ffi/utils.js'; * 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(); + spannerLib.releaseAll(); } -export { - Pool, - Connection, - Rows, - SpannerLibError -}; +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 index 0efa590b..65495184 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -16,102 +16,106 @@ import { ffi } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Pool } from './pool.js'; import { Rows } from './rows.js'; -import { createRequire } from 'module'; -// @ts-ignore -const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); -// TODO: Avoid tight coupling to internal paths of full client libraries. -// Unlike other languages like Java, Python , Node client does not export its protos. +// 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 -const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +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; + 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; + /** + * 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 - ); + const handled = await ffi.invokeAsync( + 'CreateConnection', + c, + spannerLib, + pool.oid + ); - c.oid = handled.objectId; - c.pinnerId = handled.pinnerId; - return c; - } + c.oid = handled.objectId; + c.pinnerId = handled.pinnerId; + return c; + } - constructor() { - this.pool = null; - this.oid = null; - this.pinnerId = null; - this.closed = false; - } + 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"); + /** + * 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 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 rowsResult = await ffi.invokeAsync( + 'Execute', + null, + null, + this.pool.oid, + this.oid, + serializedPb + ); + const rowsId = rowsResult.objectId; - return new Rows(this, rowsId); - } + return new Rows(this, rowsId); + } - /** - * 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); - } - } + /** + * 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 index 381d64b9..09da195a 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -18,79 +18,79 @@ 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; + 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'; + /** + * 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 - ); + const p = new Pool(userAgentSuffix, connectionString); + const handled = await ffi.invokeAsync( + 'CreatePool', + p, + spannerLib, + userAgentSuffix, + connectionString + ); - p.oid = handled.objectId; - p.pinnerId = handled.pinnerId; - return p; - } + p.oid = handled.objectId; + p.pinnerId = handled.pinnerId; + return p; + } - constructor(userAgent: string, connectionString: string) { - this.oid = null; - this.pinnerId = null; - this.closed = false; - this.userAgent = userAgent; - this.connStr = connectionString; - } + 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); - } + /** + * 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); - } - } + /** + * 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 index d98e9c28..433541cf 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -15,76 +15,81 @@ import { ffi, ENCODING_PROTOBUF } from '../ffi/utils.js'; import { spannerLib } from './spannerlib.js'; import { Connection } from './connection.js'; -import { createRequire } from 'module'; -// @ts-ignore -const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); -// TODO: Avoid tight coupling to internal paths of full client libraries. -// Unlike other languages like Java, Python , Node client does not export its protos. +// 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 -const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +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) { - this.connection = connection; - this.oid = oid; - this.pinnerId = null; - this.closed = false; - } - - /** - * 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"); + public connection: Connection; + public oid: number; + public pinnerId: number | null; + public closed: boolean; + constructor(connection: Connection, oid: number) { + this.connection = connection; + this.oid = oid; + this.pinnerId = null; + this.closed = false; + } - const handled = await ffi.invokeAsync( - "Next", - null, - null, - this.connection.pool!.oid, - this.connection.oid, - this.oid, - 1, - ENCODING_PROTOBUF - ); + /** + * 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 (!handled.protobufBytes || handled.protobufBytes.length === 0) { - return null; - } + const handled = await ffi.invokeAsync( + 'Next', + null, + null, + this.connection.pool!.oid, + this.connection.oid, + this.oid, + 1, + ENCODING_PROTOBUF + ); - return ListValue.decode(handled.protobufBytes); + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; } - /** - * 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); - } - } + 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 index 922cd81b..c645aaa1 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/spannerlib.ts @@ -15,41 +15,41 @@ 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); - } - }); + 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); } + } - 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); } + } - 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(); + 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 index a064c9d0..61c51b8e 100644 --- a/spannerlib/wrappers/spannerlib-node/test/connection.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/connection.test.ts @@ -19,58 +19,69 @@ import { ffi } from '../src/ffi/utils.js'; import sinon from 'sinon'; describe('Connection', () => { - let stub: sinon.SinonStub; + let stub: sinon.SinonStub; - beforeEach(() => { - stub = sinon.stub(ffi, 'invokeAsync'); - }); + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); - afterEach(() => { - stub.restore(); - }); + 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; + 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; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; - // Mock invokeAsync response for Execute - stub.onFirstCall().resolves({ objectId: 3, pinnerId: 0, protobufBytes: null }); + // Mock invokeAsync response for Execute + stub + .onFirstCall() + .resolves({ objectId: 3, pinnerId: 0, protobufBytes: null }); - const rows = await connection.executeSql("SELECT 1"); + 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'); - }); + 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' + ); - it('should throw error if connection is closed', async () => { - const connection = new Connection(); - connection.closed = true; + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'Execute', + 'First call should be Execute' + ); + }); - await assert.rejects( - async () => { - await connection.executeSql("SELECT 1"); - }, - /Connection is already closed/ - ); - }); + it('should throw error if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; - 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 already closed/); + }); - await assert.rejects( - async () => { - await connection.executeSql("SELECT 1"); - }, - /Connection is not bound to a Pool/ - ); - }); + 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 index 000d6f37..5feb8ed9 100644 --- a/spannerlib/wrappers/spannerlib-node/test/index.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/index.test.ts @@ -16,15 +16,21 @@ 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 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'); - }); + 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 index c05f2ea5..db58b581 100644 --- a/spannerlib/wrappers/spannerlib-node/test/pool.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/pool.test.ts @@ -18,47 +18,61 @@ import { ffi } from '../src/ffi/utils.js'; import sinon from 'sinon'; describe('Pool', () => { - let stub: sinon.SinonStub; + let stub: sinon.SinonStub; - beforeEach(() => { - stub = sinon.stub(ffi, 'invokeAsync'); - }); + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); - afterEach(() => { - stub.restore(); - }); + 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 }); + it('should create a Pool successfully', async () => { + const connStr = 'projects/test/instances/test/databases/test'; - const pool = await Pool.create(connStr); + // Mock invokeAsync response for CreatePool + stub + .onFirstCall() + .resolves({ objectId: 1, pinnerId: 2, protobufBytes: null }); - 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}`); - }); + const pool = await Pool.create(connStr); - 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; + 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'); - await assert.rejects( - async () => { - await pool.createConnection(); - }, - /Pool is already closed/ - ); - }); + 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 index 95c691a7..e24bd3e6 100644 --- a/spannerlib/wrappers/spannerlib-node/test/rows.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/rows.test.ts @@ -18,67 +18,84 @@ 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 { createRequire } from 'module'; - -// @ts-ignore -const _require = typeof require !== 'undefined' ? require : createRequire(import.meta.url); -const { google } = _require('@google-cloud/spanner/build/protos/protos.js'); +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; + let stub: sinon.SinonStub; - beforeEach(() => { - stub = sinon.stub(ffi, 'invokeAsync'); - }); + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); - afterEach(() => { - stub.restore(); - }); + 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); - 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); - - // 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(); - - 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'); + // Create a dummy ListValue + const listValue = ListValue.create({ + values: [Value.create({ stringValue: '1' })], }); + const buffer = ListValue.encode(listValue).finish() as Buffer; - 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); + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); - stub.onFirstCall().resolves({ objectId: 0, pinnerId: 0, protobufBytes: null }); + const row = (await rows.next()) as unknown as MockListValue; - const row = await rows.next(); + 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(row, null, 'Row should be null'); - }); + 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); + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: null }); + + const row = await rows.next(); + + assert.strictEqual(row, null, 'Row should be null'); + }); }); From 76fda5844f1af0e34d82b4ce6ac0f3d22050bf1d Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 28 Apr 2026 15:09:19 +0530 Subject: [PATCH 10/11] feat(spannerlib-node): add Windows support to binding.gyp and build script --- spannerlib/wrappers/spannerlib-node/binding.gyp | 11 +++++++++++ .../spannerlib-node/scripts/build-shared-lib.sh | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-node/binding.gyp b/spannerlib/wrappers/spannerlib-node/binding.gyp index 8bd799d9..e2457df3 100644 --- a/spannerlib/wrappers/spannerlib-node/binding.gyp +++ b/spannerlib/wrappers/spannerlib-node/binding.gyp @@ -50,6 +50,17 @@ 'files': [ '<(module_root_dir)/../../shared/libspanner.so' ] } ] + }], + ['OS=="win"', { + 'libraries': [ + '<(module_root_dir)/../../shared/libspanner.dll' + ], + 'copies': [ + { + 'destination': '<(PRODUCT_DIR)', + 'files': [ '<(module_root_dir)/../../shared/libspanner.dll' ] + } + ] }] ] } diff --git a/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh index 73d71138..d8b5d027 100755 --- a/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-node/scripts/build-shared-lib.sh @@ -48,7 +48,7 @@ elif [ "$OS" == "Linux" ]; then 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 spannerlib.dll -buildmode=c-shared shared_lib.go + go build -C "$SHARED_LIB_DIR" -o libspanner.dll -buildmode=c-shared shared_lib.go else echo "Unsupported operating system: $OS" exit 1 From d393855d3eb1546d0f02b670fce91cda13291624 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 28 Apr 2026 18:04:19 +0530 Subject: [PATCH 11/11] gemini review comments fix --- .../scripts/fix-extensions.cjs | 6 +- .../wrappers/spannerlib-node/src/cpp/addon.cc | 105 ++++++++++++++++-- .../wrappers/spannerlib-node/src/ffi/utils.ts | 5 +- .../spannerlib-node/src/lib/connection.ts | 4 +- .../wrappers/spannerlib-node/src/lib/pool.ts | 1 + .../wrappers/spannerlib-node/src/lib/rows.ts | 11 +- .../spannerlib-node/test/rows.test.ts | 4 +- 7 files changed, 113 insertions(+), 23 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs index 89add729..9fd1428d 100644 --- a/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs +++ b/spannerlib/wrappers/spannerlib-node/scripts/fix-extensions.cjs @@ -24,10 +24,8 @@ function traverseDir(dir) { traverseDir(fullPath); } else if (fullPath.endsWith('.cjs')) { let content = fs.readFileSync(fullPath, 'utf8'); - // Replace require('./... .js') with require('./... .cjs') - content = content.replace(/require\(['"](\.\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); - // Also handle ../ - content = content.replace(/require\(['"](\.\.\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); + // 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); diff --git a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc index e718616a..6bb99767 100644 --- a/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc +++ b/spannerlib/wrappers/spannerlib-node/src/cpp/addon.cc @@ -61,6 +61,10 @@ class CreatePoolWorker : public Napi::AsyncWorker { 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(); @@ -79,6 +83,10 @@ class ClosePoolWorker : public Napi::AsyncWorker { 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 { @@ -94,6 +102,10 @@ class ClosePoolWorker : public Napi::AsyncWorker { 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); @@ -135,6 +147,10 @@ class CreateConnectionWorker : public Napi::AsyncWorker { 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); @@ -152,6 +168,10 @@ class CloseConnectionWorker : public Napi::AsyncWorker { 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 { @@ -167,6 +187,10 @@ class CloseConnectionWorker : public Napi::AsyncWorker { 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(); @@ -200,6 +224,10 @@ class ExecuteWorker : public Napi::AsyncWorker { } 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: @@ -210,6 +238,14 @@ class ExecuteWorker : public Napi::AsyncWorker { 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(); @@ -246,6 +282,10 @@ class NextWorker : public Napi::AsyncWorker { } 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: @@ -256,6 +296,10 @@ class NextWorker : public Napi::AsyncWorker { 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(); @@ -292,6 +336,10 @@ class MetadataWorker : public Napi::AsyncWorker { } 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: @@ -299,8 +347,47 @@ class MetadataWorker : public Napi::AsyncWorker { 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(); @@ -323,21 +410,17 @@ Napi::Value NativeRelease(const Napi::CallbackInfo& info) { // 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() < 3) return env.Null(); + 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(); - // N-API sync close implementation - CloseRows(pid, cid, rid); - - // invokeAsync appends a callback at the end of properties - if (info.Length() >= 4 && info[3].IsFunction()) { - Napi::Object obj = Napi::Object::New(env); - obj.Set("r1", Napi::Number::New(env, 0)); - Napi::Function cb = info[3].As(); - cb.Call({env.Null(), obj}); // Mock empty GoReturnTuple callback - } + CloseRowsWorker* worker = new CloseRowsWorker(cb, pid, cid, rid); + worker->Queue(); return env.Undefined(); } diff --git a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts index 8cb72d40..313b27f9 100644 --- a/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts +++ b/spannerlib/wrappers/spannerlib-node/src/ffi/utils.ts @@ -38,8 +38,6 @@ interface AddonResult { function invokeAsync( funcName: string, - constructor1: unknown, - constructor2: unknown, ...args: unknown[] ): Promise { return new Promise((resolve, reject) => { @@ -48,6 +46,9 @@ function invokeAsync( 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 { diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts index 65495184..da1f79d0 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/connection.ts @@ -54,6 +54,7 @@ export class Connection { c.oid = handled.objectId; c.pinnerId = handled.pinnerId; + spannerLib.register(c, handled.pinnerId); return c; } @@ -89,8 +90,9 @@ export class Connection { serializedPb ); const rowsId = rowsResult.objectId; + const rowsPinnerId = rowsResult.pinnerId; - return new Rows(this, rowsId); + return new Rows(this, rowsId, rowsPinnerId); } /** diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts index 09da195a..bcfb4425 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/pool.ts @@ -52,6 +52,7 @@ export class Pool { p.oid = handled.objectId; p.pinnerId = handled.pinnerId; + spannerLib.register(p, handled.pinnerId); return p; } diff --git a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts index 433541cf..5535f3a6 100644 --- a/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts +++ b/spannerlib/wrappers/spannerlib-node/src/lib/rows.ts @@ -33,11 +33,12 @@ export class Rows { public oid: number; public pinnerId: number | null; public closed: boolean; - constructor(connection: Connection, oid: number) { + constructor(connection: Connection, oid: number, pinnerId: number) { this.connection = connection; this.oid = oid; - this.pinnerId = null; + this.pinnerId = pinnerId; this.closed = false; + spannerLib.register(this, pinnerId); } /** @@ -50,11 +51,15 @@ export class Rows { 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.pool.oid, this.connection.oid, this.oid, 1, diff --git a/spannerlib/wrappers/spannerlib-node/test/rows.test.ts b/spannerlib/wrappers/spannerlib-node/test/rows.test.ts index e24bd3e6..a864ede8 100644 --- a/spannerlib/wrappers/spannerlib-node/test/rows.test.ts +++ b/spannerlib/wrappers/spannerlib-node/test/rows.test.ts @@ -48,7 +48,7 @@ describe('Rows', () => { connection.pool = pool; connection.oid = 2; - const rows = new Rows(connection, 3); + const rows = new Rows(connection, 3, 4); // Create a dummy ListValue const listValue = ListValue.create({ @@ -88,7 +88,7 @@ describe('Rows', () => { connection.pool = pool; connection.oid = 2; - const rows = new Rows(connection, 3); + const rows = new Rows(connection, 3, 4); stub .onFirstCall()