UNPKG

sfcc-cip-analytics-client

Version:

SFCC Commerce Intelligence Platform Analytics Client

421 lines (420 loc) 18.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.CIPClient = void 0; const protobuf = __importStar(require("protobufjs")); const uuid_1 = require("uuid"); const protocol_1 = require("./protocol"); const path = __importStar(require("path")); const logger_1 = require("./logger"); class CIPClient { constructor(clientId, clientSecret, instance, options = {}) { this.root = null; this.connectionId = null; this.tokenInfo = null; this.clientId = clientId; this.clientSecret = clientSecret; this.instance = instance; this.serverUrl = `https://jdbc.analytics.commercecloud.salesforce.com/${instance}`; this.logger = options.logger || logger_1.defaultLogger; } /** * Initializes the protobuf schemas. Called automatically on first request. */ async ensureInitialized() { if (!this.root) { // TODO: use static build const protoFiles = [ path.join(__dirname, "../proto/common.proto"), path.join(__dirname, "../proto/requests.proto"), path.join(__dirname, "../proto/responses.proto"), ]; this.root = await protobuf.load(protoFiles); } } /** * Ensures we have a valid access token, refreshing if necessary. */ async ensureValidToken() { const now = Date.now(); // Check if we need to get a new token if (!this.tokenInfo || now >= this.tokenInfo.expiresAt) { this.logger.debug("Refreshing access token..."); const tokenUrl = `https://account.demandware.com/dwsso/oauth2/access_token?scope=SALESFORCE_COMMERCE_API:${this.instance}`; const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`, }, body: "grant_type=client_credentials", }); if (!response.ok) { throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); } const data = (await response.json()); if (!data.access_token) { throw new Error("No access token in response"); } // Calculate expiration time (subtract 60 seconds as buffer) const expiresIn = data.expires_in || 3600; // Default to 1 hour if not provided const expiresAt = now + (expiresIn - 60) * 1000; this.tokenInfo = { accessToken: data.access_token, expiresAt, }; this.logger.debug("Access token refreshed, expires at:", new Date(expiresAt).toISOString()); } } /** * Opens a new connection to the Avatica server. * @param info Connection properties (e.g., user, password, schema). */ async openConnection(info = {}) { if (this.connectionId) { throw new Error("Connection already open. Close the existing connection first."); } const connectionId = (0, uuid_1.v4)(); // Use provided ID or generate a new one const requestPayload = { connectionId, info, }; const response = await this.sendRequest("OpenConnectionRequest", requestPayload); // The server returns its own connectionId in the response, which we should use. this.connectionId = response.connectionId || connectionId; } /** * Closes the existing connection. */ async closeConnection() { if (!this.connectionId) { throw new Error("No connection to close."); } await this.sendRequest("CloseConnectionRequest", { connectionId: this.connectionId, }); this.connectionId = null; } /** * Creates a new statement for the current connection. * @returns The ID of the newly created statement. */ async createStatement() { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } const response = await this.sendRequest("CreateStatementRequest", { connectionId: this.connectionId, }); return response.statementId; } /** * Closes an existing statement. * @param statementId The ID of the statement to close. */ async closeStatement(statementId) { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } await this.sendRequest("CloseStatementRequest", { connectionId: this.connectionId, statementId, }); } /** * Prepares and executes a SQL query in a single step. * @param statementId The ID of the statement. * @param sql The SQL query to execute. * @param maxRowCount The maximum number of rows to return in the first frame (-1 for all). * @returns The full execution response, including the first frame of results with normalized offset values. */ async execute(statementId, sql, maxRowCount = -1) { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } const requestPayload = { connectionId: this.connectionId, statementId, sql, maxRowCount, firstFrameMaxSize: maxRowCount, }; // The actual request type is PrepareAndExecuteRequest const response = await this.sendRequest("PrepareAndExecuteRequest", requestPayload); // Normalize frame data in all results if (response.results) { response.results.forEach((result) => { if (result.firstFrame) { result.firstFrame = this.normalizeFrame(result.firstFrame); } }); } return response; } /** * Fetches the next frame of results for a query. * @param statementId The statement ID. * @param offset The starting row offset for the new frame. * @param fetchMaxRowCount The maximum number of rows for this frame. * @returns The fetch response containing the next frame with normalized offset values. */ async fetch(statementId, offset, fetchMaxRowCount) { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } const requestPayload = { connectionId: this.connectionId, statementId, offset, fetchMaxRowCount, }; const response = await this.sendRequest("FetchRequest", requestPayload); // Normalize the frame data if (response.frame) { response.frame = this.normalizeFrame(response.frame); } return response; } /** * Prepares a SQL statement and returns metadata including parameter information. * @param sql The SQL query to prepare (with ? placeholders for parameters). * @param maxRowsTotal The maximum number of rows that will be allowed for this query (-1 for no limit). * @returns The prepare response containing statement metadata. */ async prepare(sql, maxRowsTotal = -1) { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } const requestPayload = { connectionId: this.connectionId, sql, maxRowsTotal, }; const response = await this.sendRequest("PrepareRequest", requestPayload); return response; } /** * Executes a prepared statement with parameters. * @param statementHandle The statement handle returned from prepare(). * @param parameters Array of parameter values to bind to the prepared statement. * @param firstFrameMaxSize The maximum number of rows to return in the first frame (-1 for no limit). * @returns The execution response containing results with normalized offset values. */ async executeWithParameters(statementHandle, parameters = [], firstFrameMaxSize = -1) { if (!this.connectionId) { throw new Error("No connection available. Call openConnection() first."); } // Convert JavaScript values to TypedValues var parameterValues = parameters.map((param) => this.createTypedValue(param)); const requestPayload = { statementHandle, parameterValues, hasParameterValues: parameterValues.length > 0, firstFrameMaxSize, }; const response = await this.sendRequest("ExecuteRequest", requestPayload); // Normalize frame data in all results if (response.results) { response.results.forEach((result) => { if (result.firstFrame) { result.firstFrame = this.normalizeFrame(result.firstFrame); } }); } return response; } /** * Convenience method to prepare and execute a statement with parameters in one call. * @param sql The SQL query with ? placeholders. * @param parameters Array of parameter values. * @param firstFrameMaxSize The maximum number of rows to return in the first frame. * @returns The execution response containing results. */ async prepareAndExecuteWithParameters(sql, parameters = [], firstFrameMaxSize = -1) { const prepareResponse = await this.prepare(sql); if (!prepareResponse.statement) { throw new Error("Failed to prepare statement - no statement handle returned"); } return this.executeWithParameters(prepareResponse.statement, parameters, firstFrameMaxSize); } /** * A generic method to serialize, send, and deserialize Avatica messages. * @param requestTypeName The short name of the request message type (e.g., "OpenConnectionRequest"). * @param payload The JavaScript object for the request payload. * @returns A promise that resolves to the deserialized response payload. */ async sendRequest(requestTypeName, payload) { // Ensure we're initialized and have a valid token await this.ensureInitialized(); await this.ensureValidToken(); // 1. Construct the fully-qualified Java class name for the request. // This is a convention of the Avatica Protobuf wire format. const fullRequestClassName = `org.apache.calcite.avatica.proto.Requests$${requestTypeName}`; // 2. Serialize the specific request message (e.g., OpenConnectionRequest). const RequestType = this.root.lookupType(requestTypeName); const requestMessage = RequestType.create(payload); const serializedRequest = RequestType.encode(requestMessage).finish(); // 3. Wrap the serialized request in a WireMessage. const WireMessage = this.root.lookupType("WireMessage"); const wirePayload = { name: fullRequestClassName, wrappedMessage: serializedRequest, }; const wireMessage = WireMessage.create(wirePayload); const serializedWireMessage = WireMessage.encode(wireMessage).finish(); this.logger.debug(`Sending request: ${requestTypeName}`, { serverUrl: this.serverUrl, instance: this.instance, requestPayload: payload, requestClassName: fullRequestClassName, serializedLength: serializedWireMessage.length, }); // 4. Send the request using fetch. const httpResponse = await fetch(this.serverUrl, { method: "POST", headers: { "Content-Type": "application/x-protobuf", "X-Client-Version": "2.11.0", InstanceId: this.instance, Authorization: `Bearer ${this.tokenInfo.accessToken}`, "x-session-id": this.sessionId || undefined, }, body: serializedWireMessage, }); // if the response has an x-session-id header, log it and store in this class const sessionId = httpResponse.headers.get("x-session-id"); if (sessionId) { this.logger.debug(`Session ID: ${sessionId}`); this.sessionId = sessionId; } if (!httpResponse.ok) { const errorText = await httpResponse.text(); throw new Error(`Avatica server error: ${httpResponse.status} ${httpResponse.statusText} - ${errorText}`); } // 5. Decode the response WireMessage. const responseBuffer = await httpResponse.arrayBuffer(); const responseWireMessage = WireMessage.decode(new Uint8Array(responseBuffer)); // 6. Find the corresponding response type and decode the wrapped message. // The response class name is derived from the response WireMessage's `name` field. const fullResponseClassName = responseWireMessage.name; const responseTypeName = fullResponseClassName.split("$").pop(); // Handle ErrorResponse specifically if (responseTypeName === "ErrorResponse") { const ErrorResponseType = this.root.lookupType(responseTypeName); const errorDetails = ErrorResponseType.decode(responseWireMessage.wrappedMessage); throw new Error(`Avatica Error: ${errorDetails.errorMessage} (SQLState: ${errorDetails.sqlState}, ErrorCode: ${errorDetails.errorCode})`); } const ResponseType = this.root.lookupType(responseTypeName); const decodedResponse = ResponseType.decode(responseWireMessage.wrappedMessage); this.logger.debug(`Received response: ${responseTypeName}`, { responseTypeName, responseClassName: fullResponseClassName, responsePayload: decodedResponse.toJSON(), responseLength: responseWireMessage.wrappedMessage.length, }); return decodedResponse; } /** * Normalizes protobuf Long values to JavaScript numbers. * @param value The value that might be a protobuf Long * @returns A JavaScript number */ normalizeLongValue(value) { if (value && typeof value === "object" && "toNumber" in value) { return value.toNumber(); } return Number(value || 0); } /** * Normalizes frame data to ensure offset and other numeric fields are plain numbers. * @param frame The frame object to normalize * @returns The frame with normalized numeric values */ normalizeFrame(frame) { if (!frame) return frame; return { ...frame, offset: this.normalizeLongValue(frame.offset), rows: frame.rows || [], }; } /** * Converts JavaScript values to Avatica TypedValue format for prepared statement parameters. * @param value The JavaScript value to convert * @returns A TypedValue object suitable for Avatica protocol */ createTypedValue(value) { if (value === null || value === undefined) { return { type: protocol_1.Rep.NULL, null: true }; } if (typeof value === "string") { return { type: protocol_1.Rep.STRING, stringValue: value }; } if (typeof value === "number") { if (Number.isInteger(value)) { return { type: protocol_1.Rep.LONG, numberValue: value }; } else { return { type: protocol_1.Rep.DOUBLE, doubleValue: value }; } } if (typeof value === "boolean") { return { type: protocol_1.Rep.BOOLEAN, boolValue: value }; } if (value instanceof Date) { // Convert Date to timestamp (milliseconds since epoch) return { type: protocol_1.Rep.JAVA_SQL_DATE, numberValue: value.getTime() }; } if (value instanceof Uint8Array || Buffer.isBuffer(value)) { return { type: protocol_1.Rep.BYTE_STRING, bytesValue: value instanceof Uint8Array ? value : new Uint8Array(value), }; } // For arrays if (Array.isArray(value)) { const arrayValues = value.map((v) => this.createTypedValue(v)); return { type: protocol_1.Rep.ARRAY, arrayValue: arrayValues, componentType: arrayValues.length > 0 ? arrayValues[0].type : protocol_1.Rep.OBJECT, }; } // Fallback to string representation for complex objects return { type: protocol_1.Rep.STRING, stringValue: String(value) }; } } exports.CIPClient = CIPClient;