@queryleaf/postgres-server
Version:
PostgreSQL wire-compatible server for QueryLeaf
1,147 lines (997 loc) • 34.7 kB
text/typescript
import { Socket } from 'net';
import { QueryLeaf } from '@queryleaf/lib';
import { Transform } from 'stream';
import debugLib from 'debug';
import { MongoClient, Document } from 'mongodb';
const debug = debugLib('queryleaf:pg-server:protocol');
// Simplified protocol implementation for demo purposes
interface BackendMessage {
// Buffer containing the formatted message ready to send
buffer: Buffer;
}
interface ClientMessage {
// Parsed client message
length: number;
type?: string;
string?: string;
parameters?: Record<string, string>;
name?: string;
query?: string;
dataTypeIDs?: any[];
portal?: string;
maxRows?: number;
}
// Simplified message types
type MessageName =
| 'startup'
| 'password'
| 'query'
| 'parse'
| 'bind'
| 'describe'
| 'execute'
| 'sync'
| 'flush'
| 'terminate';
interface PgUser {
username: string;
password: string;
}
interface ProtocolHandlerOptions {
authPassthrough?: boolean;
mongoClient?: MongoClient;
dbName?: string;
mongoUri?: string;
}
// Simplified serializer implementation
class Serializer {
// Create a Buffer with a message code and length prefix
private static createMessage(code: string, body: Buffer): Buffer {
const length = body.length + 4; // body + length field
const message = Buffer.alloc(length + 1); // + message code
message[0] = code.charCodeAt(0);
message.writeUInt32BE(length, 1);
body.copy(message, 5);
return message;
}
// Serialize a string into a buffer
private static encodeString(str: string): Buffer {
return Buffer.from(str + '\0', 'utf8');
}
// Authentication: OK (R)
authenticationOk(): BackendMessage {
const body = Buffer.alloc(4);
body.writeUInt32BE(0, 0); // Authentication type 0 = OK
return { buffer: Serializer.createMessage('R', body) };
}
// Authentication: Cleartext password (R)
authenticationCleartextPassword(): BackendMessage {
const body = Buffer.alloc(4);
body.writeUInt32BE(3, 0); // Authentication type 3 = CleartextPassword
return { buffer: Serializer.createMessage('R', body) };
}
// Parameter status (S)
parameterStatus(name: string, value: string): BackendMessage {
const nameBuffer = Serializer.encodeString(name);
const valueBuffer = Serializer.encodeString(value);
const body = Buffer.concat([nameBuffer, valueBuffer]);
return { buffer: Serializer.createMessage('S', body) };
}
// Backend key data (K)
backendKeyData(processId: number, secretKey: number): BackendMessage {
const body = Buffer.alloc(8);
body.writeUInt32BE(processId, 0);
body.writeUInt32BE(secretKey, 4);
return { buffer: Serializer.createMessage('K', body) };
}
// Ready for query (Z)
readyForQuery(status: string): BackendMessage {
const body = Buffer.alloc(1);
body.write(status, 0, 1, 'utf8');
return { buffer: Serializer.createMessage('Z', body) };
}
// Row description (T)
rowDescription(fields: any[]): BackendMessage {
// Calculate the total buffer size
let size = 2; // number of fields (2 bytes)
for (const field of fields) {
size += Serializer.encodeString(field.name).length;
size += 18; // tableID(4) + columnID(4) + dataTypeID(4) + dataTypeSize(2) + typeModifier(4)
}
const body = Buffer.alloc(size);
body.writeUInt16BE(fields.length, 0);
let offset = 2;
for (const field of fields) {
offset += Serializer.encodeString(field.name).copy(body, offset);
body.writeUInt32BE(field.tableID || 0, offset); // tableID
offset += 4;
body.writeUInt16BE(field.columnID || 0, offset); // columnID
offset += 2;
body.writeUInt32BE(field.dataTypeID || 25, offset); // dataTypeID (25 = TEXT)
offset += 4;
body.writeInt16BE(field.dataTypeSize || -1, offset); // dataTypeSize
offset += 2;
body.writeInt32BE(field.dataTypeModifier || -1, offset); // typeModifier
offset += 4;
body.writeInt16BE(field.format || 0, offset); // format code (0 = text)
offset += 2;
}
return { buffer: Serializer.createMessage('T', body) };
}
// Data row (D)
dataRow(values: (string | null)[]): BackendMessage {
// Calculate the total buffer size
let size = 2; // number of columns (2 bytes)
for (const value of values) {
if (value === null) {
size += 4; // null length (-1 as 4 bytes)
} else {
size += 4 + Buffer.byteLength(value, 'utf8'); // length + value
}
}
const body = Buffer.alloc(size);
body.writeUInt16BE(values.length, 0);
let offset = 2;
for (const value of values) {
if (value === null) {
body.writeInt32BE(-1, offset); // null value
offset += 4;
} else {
const valueBuffer = Buffer.from(value, 'utf8');
body.writeInt32BE(valueBuffer.length, offset);
offset += 4;
valueBuffer.copy(body, offset);
offset += valueBuffer.length;
}
}
return { buffer: Serializer.createMessage('D', body) };
}
// Command complete (C)
commandComplete(tag: string): BackendMessage {
const body = Serializer.encodeString(tag);
return { buffer: Serializer.createMessage('C', body) };
}
// Parse complete (1)
parseComplete(): BackendMessage {
return { buffer: Serializer.createMessage('1', Buffer.alloc(0)) };
}
// Bind complete (2)
bindComplete(): BackendMessage {
return { buffer: Serializer.createMessage('2', Buffer.alloc(0)) };
}
// Error (E)
error(fields: Record<string, any>): BackendMessage {
// Build error fields - each field has a type (1 byte) and value string (null-terminated)
const fieldBuffers: Buffer[] = [];
// Add required fields
if (fields.severity) {
fieldBuffers.push(Buffer.from('S' + fields.severity + '\0', 'utf8'));
}
if (fields.code) {
fieldBuffers.push(Buffer.from('C' + fields.code + '\0', 'utf8'));
}
if (fields.message) {
fieldBuffers.push(Buffer.from('M' + fields.message + '\0', 'utf8'));
}
// Add optional fields if present
if (fields.detail) {
fieldBuffers.push(Buffer.from('D' + fields.detail + '\0', 'utf8'));
}
if (fields.hint) {
fieldBuffers.push(Buffer.from('H' + fields.hint + '\0', 'utf8'));
}
if (fields.position) {
fieldBuffers.push(Buffer.from('P' + fields.position + '\0', 'utf8'));
}
// Add null terminator
fieldBuffers.push(Buffer.from([0]));
const body = Buffer.concat(fieldBuffers);
return { buffer: Serializer.createMessage('E', body) };
}
}
// Simplified message parser
function parseMessage(type: string, buffer: Buffer): ClientMessage | null {
debug(`Parsing message of type '${type}', buffer length: ${buffer.length}`);
switch (type) {
case 'Q': {
// Simple query
// Format: 'Q' + int32 length + string (null-terminated)
try {
debug(`Query buffer: ${buffer.slice(0, Math.min(buffer.length, 20)).toString('hex')}`);
if (buffer.length < 6) {
debug('Query buffer too short, waiting for more data');
return null; // Need more data
}
const messageLength = buffer.readUInt32BE(1);
debug(`Message length from packet: ${messageLength}`);
if (buffer.length < messageLength + 1) {
debug(
`Buffer length ${buffer.length} less than required ${messageLength + 1}, waiting for more data`
);
return null; // Need more data
}
// For query message 'Q', the query string is right after the length field (4 bytes)
// and extends to the end of the message, minus the null terminator
let queryString = '';
try {
// Accounting for null byte at the end
queryString = buffer.toString('utf8', 5, 1 + messageLength - 1);
debug(`Extracted query string: ${queryString}`);
} catch (error) {
debug(`Error extracting query string: ${error}`);
return null;
}
return {
length: 1 + messageLength, // msgType + full message with length
type: 'query',
string: queryString,
};
} catch (err) {
debug(`Error parsing query message: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
case '\0': {
// Startup message
// Format: int32 protocol version + key-value pairs (null-terminated strings)
const parameters: Record<string, string> = {};
let offset = 4; // Skip protocol version
try {
if (buffer.length <= 8) {
// Need at least protocol version + one key-value pair
debug('Startup message buffer too short, waiting for more data');
return null;
}
const messageLength = buffer.readUInt32BE(0);
debug(`Startup message length: ${messageLength}`);
if (buffer.length < messageLength) {
debug(
`Buffer length ${buffer.length} less than required ${messageLength}, waiting for more data`
);
return null;
}
// Trying to read key-value pairs with error handling
while (offset < Math.min(buffer.length - 1, messageLength)) {
// Check for end of parameters
if (buffer[offset] === 0) {
offset++;
break;
}
const keyStart = offset;
while (offset < buffer.length && buffer[offset] !== 0) {
offset++;
}
if (offset >= buffer.length) {
debug('Unexpected end of buffer while reading key');
break;
}
const key = buffer.toString('utf8', keyStart, offset);
offset++; // Skip null terminator
if (offset >= buffer.length) {
debug('Unexpected end of buffer after key');
break;
}
const valueStart = offset;
while (offset < buffer.length && buffer[offset] !== 0) {
offset++;
}
if (offset >= buffer.length) {
debug('Unexpected end of buffer while reading value');
break;
}
const value = buffer.toString('utf8', valueStart, offset);
offset++; // Skip null terminator
debug(`Startup parameter: ${key}=${value}`);
parameters[key] = value;
}
debug(`Extracted startup parameters: ${JSON.stringify(parameters)}`);
return {
length: messageLength,
type: 'startup',
parameters,
};
} catch (err) {
debug(`Error parsing startup message: ${err instanceof Error ? err.message : String(err)}`);
// Fall back to simple parameter parsing for compatibility
return {
length: buffer.length,
type: 'startup',
parameters,
};
}
}
case 'p': {
// Password message
const passwordStr = buffer.toString('utf8', 4, buffer.length - 1); // Skip length and remove null terminator
return {
length: 1 + 4 + passwordStr.length + 1,
type: 'password',
string: passwordStr,
};
}
case 'P': {
// Parse
let offset = 4; // Skip length
// Read statement name (null-terminated)
const nameStart = offset;
while (buffer[offset] !== 0) offset++;
const name = buffer.toString('utf8', nameStart, offset);
offset++; // Skip null terminator
// Read query string (null-terminated)
const queryStart = offset;
while (buffer[offset] !== 0) offset++;
const query = buffer.toString('utf8', queryStart, offset);
offset++; // Skip null terminator
// Read parameter data types (if any)
const numParams = buffer.readUInt16BE(offset);
offset += 2;
const dataTypeIDs = [];
for (let i = 0; i < numParams; i++) {
dataTypeIDs.push(buffer.readUInt32BE(offset));
offset += 4;
}
return {
length: offset,
type: 'parse',
name,
query,
dataTypeIDs,
};
}
case 'B': {
// Bind
// Simplified - we don't parse all fields
return {
length: buffer.length,
type: 'bind',
};
}
case 'D': {
// Describe
const objectType = buffer[4]; // 'S' for prepared statement, 'P' for portal
const nameStart = 5;
let offset = nameStart;
while (buffer[offset] !== 0) offset++;
const name = buffer.toString('utf8', nameStart, offset);
return {
length: offset + 1,
type: 'describe',
string: objectType === 83 ? 'S' : 'P', // 83 is ASCII for 'S'
name,
};
}
case 'E': {
// Execute
let offset = 4; // Skip length
// Read portal name (null-terminated)
const portalStart = offset;
while (buffer[offset] !== 0) offset++;
const portal = buffer.toString('utf8', portalStart, offset);
offset++; // Skip null terminator
// Read max rows
const maxRows = buffer.readUInt32BE(offset);
offset += 4;
return {
length: offset,
type: 'execute',
portal,
maxRows,
};
}
case 'S': {
// Sync
return {
length: 5, // 1 + 4 (msgType + length)
type: 'sync',
};
}
case 'H': {
// Flush
return {
length: 5, // 1 + 4 (msgType + length)
type: 'flush',
};
}
case 'X': {
// Terminate
return {
length: 5, // 1 + 4 (msgType + length)
type: 'terminate',
};
}
default:
// Unknown message type
return null;
}
}
function readMessageType(buffer: Buffer): string {
if (buffer.length < 1) return '';
debug(
`Reading message type from buffer: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`
);
// Special case for startup message (no message type code)
if (buffer.length >= 4) {
const possibleVersion = buffer.readUInt32BE(0);
if (possibleVersion === 196608 || possibleVersion === 196612) {
debug(`Detected startup message based on protocol version: ${possibleVersion}`);
return '\0'; // Use null char as special indicator
}
}
// For query messages - if starts with Q (ascii 81)
if (buffer.length >= 1 && buffer[0] === 81) {
debug('Detected query message (Q)');
return 'Q';
}
debug(`First byte is ${buffer[0]} (${String.fromCharCode(buffer[0])})`);
return buffer.toString('utf8', 0, 1);
}
/**
* Handles the PostgreSQL wire protocol for a connection
*/
export class ProtocolHandler {
private socket: Socket;
private queryLeaf: QueryLeaf;
private user: PgUser | null = null;
private database: string | null = null;
private serializer: Serializer;
private buffer: Buffer = Buffer.alloc(0);
private authenticated = false;
private inTransaction = false;
private preparedStatements: Map<string, string> = new Map();
private authPassthrough: boolean;
private mongoClient: MongoClient | null = null;
private dbName: string | null = null;
private mongoUri: string | null = null;
constructor(socket: Socket, queryLeaf: QueryLeaf, options: ProtocolHandlerOptions = {}) {
this.socket = socket;
this.queryLeaf = queryLeaf;
this.serializer = new Serializer();
this.authPassthrough = options.authPassthrough || false;
this.mongoClient = options.mongoClient || null;
this.dbName = options.dbName || null;
this.mongoUri = options.mongoUri || null;
debug(
`New protocol handler created for socket from ${socket.remoteAddress}:${socket.remotePort}`
);
this.socket.on('data', (data) => this.handleData(data));
this.socket.on('error', (err) => this.handleError(err));
this.socket.on('close', () => this.handleClose());
this.socket.on('end', () => debug('Socket end event received'));
this.socket.on('timeout', () => debug('Socket timeout event received'));
this.socket.on('drain', () => debug('Socket drain event received'));
// Set a longer timeout for testing
this.socket.setTimeout(60000);
}
/**
* Handle incoming data from the client
*/
private handleData(data: Buffer): void {
debug(
`Received data of length ${data.length}, first few bytes: ${data.slice(0, Math.min(10, data.length)).toString('hex')}`
);
this.buffer = Buffer.concat([this.buffer, data]);
debug(`Buffer length after concat: ${this.buffer.length}`);
// Process all complete messages in the buffer
while (this.buffer.length > 5) {
try {
const messageType = readMessageType(this.buffer);
debug(
`Message type identified: '${messageType}' (code ${messageType.charCodeAt(0)}), buffer length: ${this.buffer.length}`
);
const message = parseMessage(messageType, this.buffer);
if (!message) {
debug(
`No complete message found for type '${messageType}' in buffer of length ${this.buffer.length}`
);
// Not a complete message yet
break;
}
// Remove the processed message from the buffer
this.buffer = this.buffer.subarray(message.length);
debug(`Message processed, remaining buffer length: ${this.buffer.length}`);
debug('Received message type:', messageType, message);
// Handle the message
this.handleMessage(message.type as MessageName, message);
} catch (err) {
debug('Error parsing message:', err);
// Dump the buffer for debugging
debug(
`Buffer content at error (hex): ${this.buffer.slice(0, Math.min(50, this.buffer.length)).toString('hex')}`
);
debug(
`Buffer content at error (ascii): ${this.buffer
.slice(0, Math.min(50, this.buffer.length))
.toString('ascii')
.replace(/[^\x20-\x7E]/g, '.')}`
);
// Keep processing remaining buffer
break;
}
}
}
/**
* Handle a client message
*/
private handleMessage(type: MessageName, message: ClientMessage): void {
debug('Handling message type:', type);
switch (type) {
case 'startup':
this.handleStartup(message);
break;
case 'password':
this.handlePassword(message);
break;
case 'query':
this.handleQuery(message.string || '');
break;
case 'parse':
this.handleParse(message);
break;
case 'bind':
this.handleBind(message);
break;
case 'describe':
this.handleDescribe(message);
break;
case 'execute':
this.handleExecute(message);
break;
case 'sync':
this.handleSync();
break;
case 'flush':
this.handleFlush();
break;
case 'terminate':
this.handleTerminate();
break;
default:
debug('Unhandled message type:', type);
// Send ErrorResponse for unsupported message types
this.sendErrorResponse(`Unsupported message type: ${type}`);
}
}
/**
* Handle a startup message
*/
private handleStartup(message: ClientMessage): void {
debug('Startup message:', message);
// Extract user and database from parameters
if (message.parameters) {
const user = message.parameters.user;
const database = message.parameters.database;
debug(`Startup parameters: user=${user}, database=${database}`);
if (user) {
this.user = {
username: user,
password: '',
};
}
if (database) {
this.database = database;
}
} else {
debug('Warning: No parameters in startup message');
}
// For testing: if no parameters provided, auto-authenticate
if (!message.parameters || Object.keys(message.parameters).length === 0) {
debug('Auto-authenticating (test mode)');
this.authenticated = true;
this.sendAuthenticationOk();
this.sendParameterStatus();
this.sendBackendKeyData();
this.sendReadyForQuery();
return;
}
// In a real implementation, you would check authentication requirements
// For this simple implementation, we'll request password authentication
debug('Requesting password authentication');
this.sendAuthenticationRequest();
}
/**
* Handle a password message
*/
private async handlePassword(message: ClientMessage): Promise<void> {
debug('Password message received');
if (!this.user || !message.string) {
debug('Missing user or password');
this.sendErrorResponse('Authentication failed: missing credentials');
this.socket.end();
return;
}
const password = message.string;
this.user.password = password;
// If auth passthrough is enabled, try to authenticate with MongoDB
if (this.authPassthrough && this.mongoUri && this.dbName) {
try {
debug(`Authenticating with MongoDB using passthrough credentials: ${this.user.username}`);
// Construct a new MongoDB URI with the provided credentials
let uri = this.mongoUri;
// Parse the original URI to preserve all parts except credentials
const parsedUri = new URL(this.mongoUri);
// Replace auth part with provided credentials
parsedUri.username = encodeURIComponent(this.user.username);
parsedUri.password = encodeURIComponent(this.user.password);
// Generate new URI string
uri = parsedUri.toString();
debug(
`Attempting MongoDB connection with auth: ${uri.replace(/\/\/[^@]*@/, '//***:***@')}`
);
// Try to connect with the new credentials
const newClient = new MongoClient(uri);
await newClient.connect();
// If we got here, authentication was successful
debug('MongoDB authentication successful');
// Create a new QueryLeaf instance with the authenticated client
this.queryLeaf = new QueryLeaf(newClient, this.dbName);
this.authenticated = true;
// Send authentication successful and ready for query
this.sendAuthenticationOk();
this.sendParameterStatus();
this.sendBackendKeyData();
this.sendReadyForQuery();
} catch (err) {
debug('MongoDB authentication failed:', err);
this.sendErrorResponse(
`Authentication failed: ${err instanceof Error ? err.message : 'Invalid credentials'}`
);
this.socket.end();
}
} else {
// If auth passthrough is disabled, accept any password
debug('Auth passthrough disabled, accepting any credentials');
this.authenticated = true;
// Send authentication successful and ready for query
this.sendAuthenticationOk();
this.sendParameterStatus();
this.sendBackendKeyData();
this.sendReadyForQuery();
}
}
/**
* Flatten a nested object into a flat structure with keys like "parent_child"
* This converts nested objects and arrays into flat structures for PostgreSQL compatibility
*/
private flattenObject(obj: any, prefix = ''): Record<string, any> {
const result: Record<string, any> = {};
// If obj is null or a primitive, return directly
if (obj === null || obj === undefined || typeof obj !== 'object') {
return { [prefix]: obj };
}
// Handle arrays by converting them to JSON strings
if (Array.isArray(obj)) {
return { [prefix]: JSON.stringify(obj) };
}
// Recursively flatten object properties
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
const newKey = prefix ? `${prefix}_${key}` : key;
if (value !== null && typeof value === 'object') {
// For nested objects, flatten them with the parent key as prefix
if (Array.isArray(value)) {
// Arrays are converted to JSON strings
result[newKey] = JSON.stringify(value);
} else {
// Regular objects are flattened recursively
const flatObj = this.flattenObject(value, newKey);
Object.assign(result, flatObj);
}
} else {
// For primitive values, use the flattened key
result[newKey] = value;
}
}
}
return result;
}
/**
* Handle a query message
*/
private async handleQuery(queryString: string): Promise<void> {
debug('Query message:', queryString);
if (!this.authenticated) {
debug('Not authenticated, rejecting query');
this.sendErrorResponse('Not authenticated');
return;
}
try {
// Handle transaction control statements
if (queryString.trim().toUpperCase() === 'BEGIN') {
debug('Starting transaction');
this.inTransaction = true;
this.sendCommandComplete('BEGIN');
this.sendReadyForQuery();
return;
} else if (queryString.trim().toUpperCase() === 'COMMIT') {
debug('Committing transaction');
this.inTransaction = false;
this.sendCommandComplete('COMMIT');
this.sendReadyForQuery();
return;
} else if (queryString.trim().toUpperCase() === 'ROLLBACK') {
debug('Rolling back transaction');
this.inTransaction = false;
this.sendCommandComplete('ROLLBACK');
this.sendReadyForQuery();
return;
}
// For testing: if query is 'SELECT test', return a simple test result
if (queryString.trim().toLowerCase() === 'select test') {
debug('Handling test query');
this.sendRowDescription(['test']);
this.sendDataRow(['success']);
this.sendCommandComplete('SELECT 1');
this.sendReadyForQuery();
return;
}
// Execute the query through QueryLeaf
debug('Executing query through QueryLeaf...');
const result = await this.queryLeaf.execute(queryString);
debug(
'Query execution complete, result:',
Array.isArray(result) ? `Array[${result.length}]` : typeof result
);
// Determine command tag for the operation
let commandTag = 'SELECT';
if (queryString.trim().toUpperCase().startsWith('INSERT')) {
const count = Array.isArray(result) ? result.length : 1;
commandTag = `INSERT 0 ${count}`;
} else if (queryString.trim().toUpperCase().startsWith('UPDATE')) {
// For UPDATE commands, result should be a Document with modifiedCount
const count =
typeof result === 'object' && result !== null && 'modifiedCount' in result
? (result as Document).modifiedCount || 0
: 0;
commandTag = `UPDATE ${count}`;
} else if (queryString.trim().toUpperCase().startsWith('DELETE')) {
// For DELETE commands, result should be a Document with deletedCount
const count =
typeof result === 'object' && result !== null && 'deletedCount' in result
? (result as Document).deletedCount || 0
: 0;
commandTag = `DELETE ${count}`;
}
// Send the result rows
if (Array.isArray(result)) {
debug(`Sending ${result.length} rows`);
if (result.length > 0) {
// Flatten all objects in the result to avoid nested fields
const flattenedResults = result.map((row) => this.flattenObject(row));
debug(`Flattened results: first row sample: ${JSON.stringify(flattenedResults[0])}`);
// Get column names from the first flattened result
const columnNames = Object.keys(flattenedResults[0]);
debug(`Flattened column names: ${columnNames.join(', ')}`);
// Send row description
this.sendRowDescription(columnNames);
// Send data rows
for (const row of flattenedResults) {
this.sendDataRow(columnNames.map((col) => row[col]));
}
} else {
debug('Empty result set');
// Empty result set
this.sendRowDescription([]);
}
} else {
debug('Non-array result, sending empty result set');
// Command didn't return rows (e.g., UPDATE, DELETE)
// We still need to send an empty result set
this.sendRowDescription([]);
}
// Send command complete
debug(`Sending command complete: ${commandTag}`);
this.sendCommandComplete(commandTag);
this.sendReadyForQuery();
} catch (err) {
debug('Query error:', err);
this.sendErrorResponse(`Query error: ${err instanceof Error ? err.message : String(err)}`);
this.sendReadyForQuery();
}
}
/**
* Handle a parse message (for prepared statements)
*/
private handleParse(message: ClientMessage): void {
const { name, query } = message;
debug('Parse message:', name, query);
try {
// Store the prepared statement for later
if (name && query) {
this.preparedStatements.set(name, query);
}
// Send parse complete
this.sendMessage(this.serializer.parseComplete());
} catch (err) {
this.sendErrorResponse(`Parse error: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Handle a bind message
*/
private handleBind(message: ClientMessage): void {
debug('Bind message:', message);
// In a real implementation, you would bind parameters to a prepared statement
// For now, just acknowledge the bind
this.sendMessage(this.serializer.bindComplete());
}
/**
* Handle a describe message
*/
private handleDescribe(message: ClientMessage): void {
debug('Describe message:', message);
const type = message.string;
const name = message.name;
if (type === 'S' && name) {
// Describe prepared statement
const query = this.preparedStatements.get(name);
if (!query) {
this.sendErrorResponse(`Unknown prepared statement: ${name}`);
return;
}
// For now, we'll send an empty row description
this.sendRowDescription([]);
} else if (type === 'P') {
// Describe portal
// For now, send an empty row description
this.sendRowDescription([]);
}
}
/**
* Handle an execute message
*/
private async handleExecute(message: ClientMessage): Promise<void> {
debug('Execute message:', message);
const { portal, maxRows } = message;
try {
// In a real implementation, you would execute the prepared statement
// For now, just send an empty result and completion
this.sendCommandComplete('SELECT 0');
} catch (err) {
this.sendErrorResponse(`Execute error: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Handle a sync message
*/
private handleSync(): void {
debug('Sync message');
this.sendReadyForQuery();
}
/**
* Handle a flush message
*/
private handleFlush(): void {
debug('Flush message');
// No specific action needed for flush in this implementation
}
/**
* Handle a terminate message
*/
private handleTerminate(): void {
debug('Terminate message');
this.socket.end();
}
/**
* Handle socket errors
*/
private handleError(err: Error): void {
debug('Socket error:', err);
}
/**
* Handle socket close
*/
private handleClose(): void {
debug('Socket closed');
}
/**
* Send a message to the client
*/
private sendMessage(message: BackendMessage): void {
if (this.socket.writable) {
this.socket.write(message.buffer);
}
}
/**
* Send authentication request (MD5 password)
*/
private sendAuthenticationRequest(): void {
// For simplicity, we're using password authentication
// In a real implementation, you would use a more secure method
this.sendMessage(this.serializer.authenticationCleartextPassword());
}
/**
* Send authentication OK message
*/
private sendAuthenticationOk(): void {
this.sendMessage(this.serializer.authenticationOk());
}
/**
* Send parameter status messages
*/
private sendParameterStatus(): void {
// Send parameters to appear as PostgreSQL
this.sendMessage(this.serializer.parameterStatus('server_version', '14.0'));
this.sendMessage(this.serializer.parameterStatus('client_encoding', 'UTF8'));
this.sendMessage(this.serializer.parameterStatus('DateStyle', 'ISO, MDY'));
this.sendMessage(this.serializer.parameterStatus('TimeZone', 'UTC'));
}
/**
* Send backend key data
*/
private sendBackendKeyData(): void {
// Generate random process ID and key
const processId = Math.floor(Math.random() * 10000);
const secretKey = Math.floor(Math.random() * 1000000);
this.sendMessage(this.serializer.backendKeyData(processId, secretKey));
}
/**
* Send ready for query status message
*/
private sendReadyForQuery(): void {
// Transaction status:
// 'I' = idle (not in a transaction)
// 'T' = in a transaction
// 'E' = in a failed transaction
const status = this.inTransaction ? 'T' : 'I';
this.sendMessage(this.serializer.readyForQuery(status));
}
/**
* Send row description message
*/
private sendRowDescription(columns: string[]): void {
const fields = columns.map((name, i) => ({
name,
tableID: 0,
columnID: i,
dataTypeID: 25, // TEXT data type
dataTypeSize: -1,
dataTypeModifier: -1,
format: 0, // Text format
}));
this.sendMessage(this.serializer.rowDescription(fields));
}
/**
* Send data row message
*/
private sendDataRow(values: any[]): void {
// Convert all values to strings
const stringValues = values.map((v) => {
if (v === null || v === undefined) return null;
return String(v);
});
this.sendMessage(this.serializer.dataRow(stringValues));
}
/**
* Send command complete message
*/
private sendCommandComplete(tag: string): void {
this.sendMessage(this.serializer.commandComplete(tag));
}
/**
* Send error response message
*/
private sendErrorResponse(message: string, code = '42601'): void {
this.sendMessage(
this.serializer.error({
message,
severity: 'ERROR',
code,
detail: null,
hint: null,
position: null,
internalPosition: null,
internalQuery: null,
where: null,
schema: null,
table: null,
column: null,
dataType: null,
constraint: null,
file: 'protocol-handler.ts',
line: null,
routine: null,
})
);
}
}