Files
turso/packages/turso-serverless/src/session.ts
2025-08-19 16:35:44 +03:00

363 lines
10 KiB
TypeScript

import {
executeCursor,
executePipeline,
encodeValue,
decodeValue,
type CursorRequest,
type CursorResponse,
type CursorEntry,
type PipelineRequest,
type SequenceRequest,
type CloseRequest,
type DescribeRequest,
type DescribeResult,
type NamedArg,
type Value
} from './protocol.js';
import { DatabaseError } from './error.js';
/**
* Configuration options for a session.
*/
export interface SessionConfig {
/** Database URL */
url: string;
/** Authentication token */
authToken: string;
}
function normalizeUrl(url: string): string {
return url.replace(/^libsql:\/\//, 'https://');
}
function isValidIdentifier(str: string): boolean {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
}
/**
* A database session that manages the connection state and baton.
*
* Each session maintains its own connection state and can execute SQL statements
* independently without interfering with other sessions.
*/
export class Session {
private config: SessionConfig;
private baton: string | null = null;
private baseUrl: string;
constructor(config: SessionConfig) {
this.config = config;
this.baseUrl = normalizeUrl(config.url);
}
/**
* Describe a SQL statement to get its column metadata.
*
* @param sql - The SQL statement to describe
* @returns Promise resolving to the statement description
*/
async describe(sql: string): Promise<DescribeResult> {
const request: PipelineRequest = {
baton: this.baton,
requests: [{
type: "describe",
sql: sql
} as DescribeRequest]
};
const response = await executePipeline(this.baseUrl, this.config.authToken, request);
this.baton = response.baton;
if (response.base_url) {
this.baseUrl = response.base_url;
}
// Check for errors in the response
if (response.results && response.results[0]) {
const result = response.results[0];
if (result.type === "error") {
throw new DatabaseError(result.error?.message || 'Describe execution failed');
}
if (result.response?.type === "describe" && result.response.result) {
return result.response.result as DescribeResult;
}
}
throw new DatabaseError('Unexpected describe response');
}
/**
* Execute a SQL statement and return all results.
*
* @param sql - The SQL statement to execute
* @param args - Optional array of parameter values or object with named parameters
* @param safeIntegers - Whether to return integers as BigInt
* @returns Promise resolving to the complete result set
*/
async execute(sql: string, args: any[] | Record<string, any> = [], safeIntegers: boolean = false): Promise<any> {
const { response, entries } = await this.executeRaw(sql, args);
const result = await this.processCursorEntries(entries, safeIntegers);
return result;
}
/**
* Execute a SQL statement and return the raw response and entries.
*
* @param sql - The SQL statement to execute
* @param args - Optional array of parameter values or object with named parameters
* @returns Promise resolving to the raw response and cursor entries
*/
async executeRaw(sql: string, args: any[] | Record<string, any> = []): Promise<{ response: CursorResponse; entries: AsyncGenerator<CursorEntry> }> {
let positionalArgs: Value[] = [];
let namedArgs: NamedArg[] = [];
if (Array.isArray(args)) {
positionalArgs = args.map(encodeValue);
} else {
// Check if this is an object with numeric keys (for ?1, ?2 style parameters)
const keys = Object.keys(args);
const isNumericKeys = keys.length > 0 && keys.every(key => /^\d+$/.test(key));
if (isNumericKeys) {
// Convert numeric-keyed object to positional args
// Sort keys numerically to ensure correct order
const sortedKeys = keys.sort((a, b) => parseInt(a) - parseInt(b));
const maxIndex = parseInt(sortedKeys[sortedKeys.length - 1]);
// Create array with undefined for missing indices
positionalArgs = new Array(maxIndex);
for (const key of sortedKeys) {
const index = parseInt(key) - 1; // Convert to 0-based index
positionalArgs[index] = encodeValue(args[key]);
}
// Fill any undefined values with null
for (let i = 0; i < positionalArgs.length; i++) {
if (positionalArgs[i] === undefined) {
positionalArgs[i] = { type: 'null' };
}
}
} else {
// Convert object with named parameters to NamedArg array
namedArgs = Object.entries(args).map(([name, value]) => ({
name,
value: encodeValue(value)
}));
}
}
const request: CursorRequest = {
baton: this.baton,
batch: {
steps: [{
stmt: {
sql,
args: positionalArgs,
named_args: namedArgs,
want_rows: true
}
}]
}
};
const { response, entries } = await executeCursor(this.baseUrl, this.config.authToken, request);
this.baton = response.baton;
if (response.base_url) {
this.baseUrl = response.base_url;
}
return { response, entries };
}
/**
* Process cursor entries into a structured result.
*
* @param entries - Async generator of cursor entries
* @returns Promise resolving to the processed result
*/
async processCursorEntries(entries: AsyncGenerator<CursorEntry>, safeIntegers: boolean = false): Promise<any> {
let columns: string[] = [];
let columnTypes: string[] = [];
let rows: any[] = [];
let rowsAffected = 0;
let lastInsertRowid: number | undefined;
for await (const entry of entries) {
switch (entry.type) {
case 'step_begin':
if (entry.cols) {
columns = entry.cols.map(col => col.name);
columnTypes = entry.cols.map(col => col.decltype || '');
}
break;
case 'row':
if (entry.row) {
const decodedRow = entry.row.map(value => decodeValue(value, safeIntegers));
const rowObject = this.createRowObject(decodedRow, columns);
rows.push(rowObject);
}
break;
case 'step_end':
if (entry.affected_row_count !== undefined) {
rowsAffected = entry.affected_row_count;
}
if (entry.last_insert_rowid) {
lastInsertRowid = parseInt(entry.last_insert_rowid, 10);
}
break;
case 'step_error':
case 'error':
throw new DatabaseError(entry.error?.message || 'SQL execution failed');
}
}
return {
columns,
columnTypes,
rows,
rowsAffected,
lastInsertRowid
};
}
/**
* Create a row object with both array and named property access.
*
* @param values - Array of column values
* @param columns - Array of column names
* @returns Row object with dual access patterns
*/
createRowObject(values: any[], columns: string[]): any {
const row = [...values];
// Add column name properties to the array as non-enumerable
// Only add valid identifier names to avoid conflicts
columns.forEach((column, index) => {
if (column && isValidIdentifier(column)) {
Object.defineProperty(row, column, {
value: values[index],
enumerable: false,
writable: false,
configurable: true
});
}
});
return row;
}
/**
* Execute multiple SQL statements in a batch.
*
* @param statements - Array of SQL statements to execute
* @returns Promise resolving to batch execution results
*/
async batch(statements: string[]): Promise<any> {
const request: CursorRequest = {
baton: this.baton,
batch: {
steps: statements.map(sql => ({
stmt: {
sql,
args: [],
named_args: [],
want_rows: false
}
}))
}
};
const { response, entries } = await executeCursor(this.baseUrl, this.config.authToken, request);
this.baton = response.baton;
if (response.base_url) {
this.baseUrl = response.base_url;
}
let totalRowsAffected = 0;
let lastInsertRowid: number | undefined;
for await (const entry of entries) {
switch (entry.type) {
case 'step_end':
if (entry.affected_row_count !== undefined) {
totalRowsAffected += entry.affected_row_count;
}
if (entry.last_insert_rowid) {
lastInsertRowid = parseInt(entry.last_insert_rowid, 10);
}
break;
case 'step_error':
case 'error':
throw new DatabaseError(entry.error?.message || 'Batch execution failed');
}
}
return {
rowsAffected: totalRowsAffected,
lastInsertRowid
};
}
/**
* Execute a sequence of SQL statements separated by semicolons.
*
* @param sql - SQL string containing multiple statements separated by semicolons
* @returns Promise resolving when all statements are executed
*/
async sequence(sql: string): Promise<void> {
const request: PipelineRequest = {
baton: this.baton,
requests: [{
type: "sequence",
sql: sql
} as SequenceRequest]
};
const response = await executePipeline(this.baseUrl, this.config.authToken, request);
this.baton = response.baton;
if (response.base_url) {
this.baseUrl = response.base_url;
}
// Check for errors in the response
if (response.results && response.results[0]) {
const result = response.results[0];
if (result.type === "error") {
throw new DatabaseError(result.error?.message || 'Sequence execution failed');
}
}
}
/**
* Close the session.
*
* This sends a close request to the server to properly clean up the stream
* before resetting the local state.
*/
async close(): Promise<void> {
// Only send close request if we have an active baton
if (this.baton) {
try {
const request: PipelineRequest = {
baton: this.baton,
requests: [{
type: "close"
} as CloseRequest]
};
await executePipeline(this.baseUrl, this.config.authToken, request);
} catch (error) {
// Ignore errors during close, as the connection might already be closed
console.error('Error closing session:', error);
}
}
// Reset local state
this.baton = null;
this.baseUrl = '';
}
}