UNPKG

drizzle-edge-pg-proxy-client

Version:

PostgreSQL HTTP client compatible with Neon's interface for edge environments

369 lines (324 loc) 15.6 kB
/** * Creates a custom HTTP client for PostgreSQL that works in edge environments * This implementation mirrors Neon's HTTP client interface to ensure compatibility * with drizzle-orm, Auth.js and other tools that expect the Neon client. */ import { LogLevel } from './types'; // Import LogLevel as a value import type { ClientOptions, PgQueryResult, ParameterizedQuery, TransactionQuery, SQLTemplateTag // LogLevel removed from type-only import } from './types'; import { PgError, parsePostgresError, PG_ERROR_FIELDS } from './errors'; import { TypeParser, processQueryResult } from './parsing'; import { QueryPromise } from './query-promise'; // Import QueryPromise from its own file import { UnsafeRawSql, encodeBuffersAsBytea, generateUUID, toParameterizedQuery } from './utils'; // Re-export core types and classes for external use export { PgError } from './errors'; export { UnsafeRawSql } from './utils'; export { QueryPromise } from './query-promise'; export { TypeParser, PgTypeId } from './parsing'; export { LogLevel } from './types'; // Export LogLevel enum as value export type { PgQueryResult, PgField, ParameterizedQuery, TransactionQuery, ClientOptions, LoggerOptions, // Export LoggerOptions type SQLTemplateTag } from './types'; export function createPgHttpClient({ proxyUrl, authToken, fetch: customFetch, arrayMode = false, fullResults = false, typeParser: customTypeParser, sessionId, logger: loggerOptions // Destructure logger options }: ClientOptions) { // --- Logger Setup --- const configuredLevel = loggerOptions?.level ?? LogLevel.Warn; // Default to Warn const customLogFn = loggerOptions?.logFn; const log = (level: LogLevel, message: string, data?: any) => { if (level >= configuredLevel && configuredLevel !== LogLevel.None) { if (customLogFn) { customLogFn(level, message, data); } else { // Default console logging const logData = data ? { data } : {}; const logMessage = `[PG-HTTP-CLIENT][${LogLevel[level]}] ${message}`; switch (level) { case LogLevel.Debug: case LogLevel.Info: console.log(logMessage, logData); break; case LogLevel.Warn: console.warn(logMessage, logData); break; case LogLevel.Error: console.error(logMessage, logData); break; } } } }; // --- End Logger Setup --- // Use provided fetch or global fetch const fetchFn = customFetch || globalThis.fetch; // Format the proxy URL to ensure it's valid const formattedProxyUrl = proxyUrl.endsWith('/') ? proxyUrl.slice(0, -1) : proxyUrl; // Initialize type parser const typeParser = customTypeParser instanceof TypeParser ? customTypeParser : new TypeParser(customTypeParser); // Create or use provided session ID - this matches Neon's implementation const clientSessionId = sessionId || generateUUID(); // Check if fetch is available in the current environment if (!fetchFn) { throw new PgError('fetch is not available in the current environment. Please provide a fetch implementation.'); } // Direct query execution function - the core of the client const execute = async (queryText: string, params: any[] = []): Promise<PgQueryResult> => { log(LogLevel.Debug, 'Executing query', { query: queryText, paramsCount: params.length, sessionId: clientSessionId }); const startTime = Date.now(); const fetchOptions: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', // Add Neon-specific headers for compatibility 'Neon-Raw-Text-Output': 'true', 'Neon-Array-Mode': String(arrayMode), // Use default arrayMode for single queries unless overridden // Add session ID header for consistent session tracking (just like Neon does) 'X-Session-ID': clientSessionId, // IMPORTANT: Always include Authorization header first if authToken is provided // to ensure compatibility with all server implementations ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}), }, body: JSON.stringify({ query: queryText, // Use 'query' field to match Neon params: Array.isArray(params) ? params.map(param => encodeBuffersAsBytea(param)) : [], // method: 'all', // Neon doesn't seem to use 'method' for single queries }), }; try { const response = await fetchFn(`${formattedProxyUrl}/query`, fetchOptions); if (!response.ok) { let errorData: any = { error: response.statusText }; try { errorData = await response.json(); } catch (parseError) { errorData = { error: `Status ${response.status}: ${response.statusText}` }; } const pgError = parsePostgresError(errorData); // Use helper log(LogLevel.Error, `Query failed with status ${response.status}`, { error: pgError, query: queryText, sessionId: clientSessionId }); throw pgError; } const result = await response.json() as any; const duration = Date.now() - startTime; log(LogLevel.Info, `Query executed successfully`, { durationMs: duration, query: queryText, sessionId: clientSessionId }); // Process with type parsers - always return full structure internally const processedResult = processQueryResult(result, typeParser, arrayMode); // Return based on fullResults option (mimicking Neon's behavior) // Note: Neon's httpQuery returns rows directly if fullResults is false, // but the Pool query override returns the full result object. // We'll return the full object from execute for consistency within this client. return processedResult; } catch (error) { if (error instanceof PgError) { // Error already logged if it came from response handling throw error; } // Log connection or other unexpected errors const connError = new PgError(`Failed to execute query: ${error instanceof Error ? error.message : String(error)}`); connError.sourceError = error instanceof Error ? error : undefined; log(LogLevel.Error, `Query execution failed`, { error: connError, query: queryText, sessionId: clientSessionId }); throw connError; } }; // SQL tag template for handling raw SQL queries const sql = (strings: TemplateStringsArray, ...values: unknown[]): QueryPromise<PgQueryResult> => { const parameterizedQuery = toParameterizedQuery(strings, values); // Pass arrayMode and fullResults options to the QueryPromise if needed return new QueryPromise( (q: string, p: any[]) => execute(q, p), // Add types to lambda parameters parameterizedQuery, { arrayMode, fullResults } // Pass options if QueryPromise needs them ); }; // Transaction handling const transaction = async ( queries: (TransactionQuery | QueryPromise<PgQueryResult>)[], // Allow both raw objects and QueryPromises options?: { isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable'; readOnly?: boolean; deferrable?: boolean; arrayMode?: boolean; fullResults?: boolean; } ): Promise<any[]> => { // Return type depends on fullResults option log(LogLevel.Debug, 'Executing transaction', { queryCount: queries.length, options, sessionId: clientSessionId }); const startTime = Date.now(); try { if (!Array.isArray(queries)) { const err = new PgError('Input to transaction must be an array of queries.'); log(LogLevel.Error, 'Transaction failed: Invalid input', { error: err, sessionId: clientSessionId }); throw err; } // Format queries for the server const formattedQueries = queries.map((q) => { let queryText: string; let queryParams: any[]; if (q instanceof QueryPromise) { // Extract from QueryPromise if (!q.queryData) throw new PgError('Invalid QueryPromise passed to transaction.'); queryText = q.queryData.query; queryParams = q.queryData.params; } else if (typeof q === 'object' && q !== null && typeof q.text === 'string') { // Handle TransactionQuery object queryText = q.text; queryParams = Array.isArray(q.values) ? q.values : []; } else { throw new PgError('Invalid query type passed to transaction. Use sql`` or { text: string, values: any[] }.'); } return { query: queryText, params: queryParams.map(value => encodeBuffersAsBytea(value)), }; }); // Determine the array mode and full results settings for this transaction const txnArrayMode = options?.arrayMode ?? arrayMode; // Inherit from client options if not specified const txnFullResults = options?.fullResults ?? fullResults; // Inherit from client options // Prepare headers with transaction options const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Neon-Raw-Text-Output': 'true', 'Neon-Array-Mode': String(txnArrayMode), // Use transaction-specific array mode 'X-Session-ID': clientSessionId, ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}) }; if (options?.isolationLevel) headers['Neon-Batch-Isolation-Level'] = options.isolationLevel; if (options?.readOnly !== undefined) headers['Neon-Batch-Read-Only'] = String(options.readOnly); if (options?.deferrable !== undefined) headers['Neon-Batch-Deferrable'] = String(options.deferrable); // Send the transaction request const response = await fetchFn(`${formattedProxyUrl}/transaction`, { method: 'POST', headers, body: JSON.stringify({ queries: formattedQueries }), }); if (!response.ok) { let errorData: any = {}; try { errorData = await response.json(); } catch { errorData = { error: `Status ${response.status}: ${response.statusText}` }; } const pgError = parsePostgresError(errorData); // Use helper log(LogLevel.Error, `Transaction failed with status ${response.status}`, { error: pgError, queryCount: formattedQueries.length, sessionId: clientSessionId }); throw pgError; } // Parse results from response let results: any[] = []; try { const json = await response.json() as any; // Neon proxy returns { results: [...] } for batch results = json.results || (Array.isArray(json) ? json : []); } catch (error: any) { const parseError = new PgError(`Error parsing transaction response: ${error.message}`); log(LogLevel.Error, 'Transaction failed: Response parse error', { error: parseError, sessionId: clientSessionId }); throw parseError; } // Format each result const formattedResults = Array.isArray(results) ? results.map((result) => { // Process with type parsers const processed = processQueryResult(result, typeParser, txnArrayMode); // Return based on transaction's fullResults setting return txnFullResults ? processed : processed.rows; }) : []; const duration = Date.now() - startTime; log(LogLevel.Info, `Transaction executed successfully`, { durationMs: duration, queryCount: formattedQueries.length, sessionId: clientSessionId }); return formattedResults; } catch (error) { if (error instanceof PgError) { // Error should have been logged already if it came from response handling or input validation throw error; } // Log connection or other unexpected errors during transaction setup/execution const txError = new PgError(`Failed to execute transaction: ${error instanceof Error ? error.message : String(error)}`); txError.sourceError = error instanceof Error ? error : undefined; log(LogLevel.Error, `Transaction execution failed`, { error: txError, sessionId: clientSessionId }); throw txError; } }; // Direct query function (often used by libraries like Auth.js) // This needs to return the full PgQueryResult structure for compatibility const query = async ( queryText: string, params?: any[], options?: { // Allow overriding arrayMode/fullResults per query call arrayMode?: boolean; fullResults?: boolean; // Note: Even if false, we return the full object for consistency here } ): Promise<PgQueryResult> => { log(LogLevel.Debug, 'Executing direct query', { query: queryText, paramsCount: params?.length, options, sessionId: clientSessionId }); const startTime = Date.now(); // Use the core execute function, potentially overriding arrayMode for this call const callArrayMode = options?.arrayMode ?? arrayMode; // We'll call execute which always returns the full structure // The caller (e.g., Drizzle) might then extract rows based on its own logic const fetchOptions: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Neon-Raw-Text-Output': 'true', 'Neon-Array-Mode': String(callArrayMode), // Use specific mode for this call 'X-Session-ID': clientSessionId, ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}), }, body: JSON.stringify({ query: queryText, params: Array.isArray(params) ? params.map(param => encodeBuffersAsBytea(param)) : [], }), }; try { const response = await fetchFn(`${formattedProxyUrl}/query`, fetchOptions); if (!response.ok) { let errorData: any = { error: response.statusText }; try { errorData = await response.json(); } catch { errorData = { error: `Status ${response.status}: ${response.statusText}` }; } const pgError = parsePostgresError(errorData); log(LogLevel.Error, `Direct query failed with status ${response.status}`, { error: pgError, query: queryText, sessionId: clientSessionId }); throw pgError; } const result = await response.json() as any; const duration = Date.now() - startTime; log(LogLevel.Info, `Direct query executed successfully`, { durationMs: duration, query: queryText, sessionId: clientSessionId }); // Process result using the specific arrayMode for this call return processQueryResult(result, typeParser, callArrayMode); } catch (error) { if (error instanceof PgError) { // Error already logged if it came from response handling throw error; } const connError = new PgError(`Direct query failed: ${error instanceof Error ? error.message : String(error)}`); connError.sourceError = error instanceof Error ? error : undefined; log(LogLevel.Error, `Direct query execution failed`, { error: connError, query: queryText, sessionId: clientSessionId }); throw connError; } }; // Unsafe query builder const unsafe = (rawSql: string) => new UnsafeRawSql(rawSql); // Return the client interface matching Neon's http client return { execute, // Expose execute method query, // Direct query method sql, // SQL template tag unsafe, // For unsafe raw SQL transaction, // For transactions // Expose typeParser if users need to interact with it directly typeParser, }; }