Merge 'Add @tursodatabase/serverless package' from Pekka Enberg

This package is for serverless access to the Turso Cloud using SQL over
HTTP protocol. The purpose of this package is to provide the same
interface as `@tursodatabase/turso`, but for serverless environments
that cannot host the database engine.
The package also provides a `@libsql/client` compatibility layer in the
`@tursodatabase/serverless/compat` module for drop-in replacement for
existing clients.

Closes #2209
This commit is contained in:
Pekka Enberg
2025-07-22 08:56:22 +03:00
22 changed files with 4058 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
# Agent Development Guide
This document provides guidance for LLMs working on the `@tursodatabase/serverless` TypeScript driver.
## Project Overview
This is a **fetch() API-compatible serverless database driver** for Turso Cloud that implements the SQL over HTTP protocol (internally called "hrana"). It's designed for serverless and edge compute environments like Cloudflare Workers and Vercel Edge Functions.
### Key Features
- **HTTP-based SQL execution** using the v3 cursor endpoint for streaming
- **Native streaming API** with Connection/Statement pattern
- **LibSQL compatibility layer** for drop-in replacement
- **TypeScript-first** with full type safety
- **Edge-optimized** using only `fetch()` API
## Architecture
### Core Files Structure
```
src/
├── connection.ts # Connection class and connect() function
├── statement.ts # Statement class with get()/all()/iterate() methods
├── protocol.ts # Low-level SQL over HTTP protocol implementation
├── compat.ts # LibSQL API compatibility layer
├── compat/index.ts # Compatibility layer exports
└── index.ts # Main package exports
```
### Package Exports
- **Main API**: `@tursodatabase/serverless` - Native streaming API
- **Compatibility**: `@tursodatabase/serverless/compat` - LibSQL-compatible API
## Native API Design
### Connection/Statement Pattern
```typescript
import { connect } from "@tursodatabase/serverless";
const client = connect({ url, authToken });
const stmt = client.prepare("SELECT * FROM users WHERE id = ?", [123]);
// Three execution modes:
const row = await stmt.get(); // First row or null
const rows = await stmt.all(); // All rows as array
for await (const row of stmt.iterate()) { ... } // Streaming iterator
```
### Key Classes
#### Connection
- **Purpose**: Database connection and session management
- **Methods**: `prepare()`, `execute()`, `batch()`, `executeRaw()`
- **Internal**: Manages baton tokens, base URL updates, cursor streaming
#### Statement
- **Purpose**: Prepared statement execution with multiple access patterns
- **Methods**: `get()`, `all()`, `iterate()`
- **Streaming**: `iterate()` provides row-by-row streaming via AsyncGenerator
#### Protocol Layer
- **Purpose**: HTTP cursor endpoint communication
- **Key Function**: `executeCursor()` returns streaming cursor entries
- **Protocol**: Uses v3 cursor endpoint (`/v3/cursor`) with newline-delimited JSON
## LibSQL Compatibility Layer
### Purpose
Provides drop-in compatibility with the standard libSQL client API for existing applications.
### Key Differences
- **Entry Point**: `createClient()` instead of `connect()`
- **Import Path**: `@tursodatabase/serverless/compat`
- **API Surface**: Matches libSQL client interface exactly
- **Config Validation**: Only supports `url` and `authToken`, validates against unsupported options
### Supported vs Unsupported
```typescript
// ✅ Supported
const client = createClient({ url, authToken });
await client.execute(sql, args);
await client.batch(statements);
// ❌ Unsupported (throws LibsqlError)
createClient({ url, authToken, encryptionKey: "..." }); // Validation error
await client.transaction(); // Not implemented
await client.sync(); // Not supported for remote
```
## Protocol Implementation
### SQL over HTTP (v3 Cursor)
- **Endpoint**: `POST /v3/cursor`
- **Request**: JSON with baton, batch steps
- **Response**: Streaming newline-delimited JSON entries
- **Entry Types**: `step_begin`, `row`, `step_end`, `step_error`, `error`
### Session Management
- **Baton Tokens**: Maintain session continuity across requests
- **Base URL Updates**: Handle server-side redirects/load balancing
- **URL Normalization**: Convert `libsql://` to `https://` automatically
## Testing Strategy
### Integration Tests
```
integration-tests/
├── serverless.test.mjs # Native API tests
└── compat.test.mjs # Compatibility layer tests
```
### Test Requirements
- **Environment Variables**: `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`
- **Serial Execution**: All tests use `test.serial()` to avoid conflicts
- **Real Database**: Tests run against actual Turso instance
### Running Tests
```bash
npm test # Runs all integration tests
npm run build # TypeScript compilation
```
## Development Guidelines
### Code Organization
- **Single Responsibility**: Each file has a clear, focused purpose
- **Type Safety**: Full TypeScript coverage with proper imports
- **Error Handling**: Use proper error classes (`LibsqlError` for compat)
- **Streaming First**: Leverage AsyncGenerator for memory efficiency
### Key Patterns
- **Protocol Abstraction**: Keep protocol details in `protocol.ts`
- **Compatibility Isolation**: LibSQL compatibility in separate module
- **Row Objects**: Arrays with column name properties (non-enumerable)
- **Config Validation**: Explicit validation with helpful error messages
### Performance Considerations
- **Streaming**: Use `iterate()` for large result sets
- **Memory**: Cursor endpoint provides constant memory usage
- **Latency**: First results available immediately with streaming
## Common Tasks
### Adding New Features
1. **Protocol**: Add to `protocol.ts` if it requires HTTP changes
2. **Connection**: Add to `connection.ts` for connection-level features
3. **Statement**: Add to `statement.ts` for statement-level features
4. **Compatibility**: Update `compat.ts` if LibSQL compatibility needed
5. **Tests**: Add integration tests for new functionality
### Debugging Issues
1. **Check Protocol**: Use `executeRaw()` to inspect cursor entries
2. **Validate Config**: Ensure URL/auth token are correct
3. **Test Streaming**: Compare `all()` vs `iterate()` behavior
4. **Review Errors**: Check for `LibsqlError` vs generic errors
### Extending Compatibility
1. **Research LibSQL**: Check `resources/libsql-client-ts` for API patterns
2. **Validate Config**: Add validation for unsupported options
3. **Map Interfaces**: Convert between LibSQL and native formats
4. **Test Coverage**: Ensure compatibility tests cover new features
## Important Notes
### Security
- **No Secret Logging**: Never log auth tokens or sensitive data
- **Validation**: Always validate inputs, especially in compatibility layer
- **Error Messages**: Don't expose internal implementation details
### Compatibility
- **Breaking Changes**: Avoid breaking the native API
- **LibSQL Parity**: Match LibSQL behavior exactly in compatibility layer
- **Version Support**: Document which libSQL features are supported
### Edge Cases
- **Large Results**: Test with large datasets to verify streaming
- **Network Issues**: Handle connection failures gracefully
- **Protocol Evolution**: Be prepared for protocol version updates
## Future Considerations
### Potential Enhancements
- **Transaction Support**: Interactive transactions in compatibility layer
- **Prepared Statement Caching**: Cache prepared statements
- **Connection Pooling**: Multiple concurrent connections
- **Protocol Negotiation**: Support multiple protocol versions
### Monitoring
- **Performance Metrics**: Track query latency and throughput
- **Error Rates**: Monitor protocol and application errors
- **Resource Usage**: Memory and CPU usage in serverless environments
This guide should help future contributors understand the architecture and maintain consistency across the codebase.

View File

@@ -0,0 +1,78 @@
# Turso serverless JavaScript driver
A serverless database driver for Turso Cloud, using only `fetch()`. Connect to your database from serverless and edge functions, such as Cloudflare Workers and Vercel.
> [!NOTE]
> This driver is experimental and, therefore, subject to change at any time.
## Installation
```bash
npm install @tursodatabase/serverless
```
## Usage
```javascript
import { connect } from "@tursodatabase/serverless";
const conn = connect({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
// Prepare a statement
const stmt = conn.prepare("SELECT * FROM users WHERE id = ?");
// Get first row
const row = await stmt.get([123]);
console.log(row);
// Get all rows
const rows = await stmt.all([123]);
console.log(rows);
// Iterate through rows (streaming)
for await (const row of stmt.iterate([123])) {
console.log(row);
}
// Execute multiple statements in a batch
await conn.batch([
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)",
"INSERT INTO users (email) VALUES ('user@example.com')",
"INSERT INTO users (email) VALUES ('admin@example.com')",
]);
```
### Compatibility layer for libSQL API
This driver supports the libSQL API as a compatibility layer.
```javascript
import { createClient } from "@tursodatabase/serverless/compat";
const client = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
// Execute a single SQL statement
const result = await client.execute("SELECT * FROM users WHERE id = ?", [123]);
console.log(result.rows);
// Execute multiple statements in a batch
await client.batch([
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)",
"INSERT INTO users (email) VALUES ('user@example.com')",
"INSERT INTO users (email) VALUES ('admin@example.com')",
]);
```
## Examples
Check out the `examples/` directory for complete usage examples.
## License
MIT

View File

@@ -0,0 +1,19 @@
# Remote
This example demonstrates how to use Turso Cloud.
## Install Dependencies
```bash
npm i
```
## Running
Execute the example:
```bash
TURSO_DATABASE_URL="..." TURSO_AUTH_TOKEN="..." node index.mjs
```
This will connect to a remote SQLite database, insert some data, and then query the results.

View File

@@ -0,0 +1,20 @@
import { createClient } from "@tursodatabase/serverless/compat";
const client = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
await client.batch(
[
"CREATE TABLE IF NOT EXISTS users (email TEXT)",
"INSERT INTO users VALUES ('first@example.com')",
"INSERT INTO users VALUES ('second@example.com')",
"INSERT INTO users VALUES ('third@example.com')",
],
"write",
);
const result = await client.execute("SELECT * FROM users");
console.log("Users:", result.rows);

View File

@@ -0,0 +1,30 @@
{
"name": "batch",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "batch",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@tursodatabase/serverless": "../.."
}
},
"../..": {
"name": "@tursodatabase/serverless",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^24.0.13",
"ava": "^6.4.1",
"typescript": "^5.8.3"
}
},
"node_modules/@tursodatabase/serverless": {
"resolved": "../..",
"link": true
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "batch",
"version": "1.0.0",
"main": "index.mjs",
"author": "Giovanni Benussi",
"license": "MIT",
"dependencies": {
"@tursodatabase/serverless": "../.."
}
}

View File

@@ -0,0 +1,19 @@
# Remote
This example demonstrates how to use Turso Cloud.
## Install Dependencies
```bash
npm i
```
## Running
Execute the example:
```bash
TURSO_DATABASE_URL="..." TURSO_AUTH_TOKEN="..." node index.mjs
```
This will connect to a remote SQLite database, insert some data, and then query the results.

View File

@@ -0,0 +1,36 @@
import { connect } from "@tursodatabase/serverless";
const client = connect({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
await client.batch(
[
"CREATE TABLE IF NOT EXISTS users (email TEXT)",
"INSERT INTO users VALUES ('first@example.com')",
"INSERT INTO users VALUES ('second@example.com')",
"INSERT INTO users VALUES ('third@example.com')",
],
"write",
);
// Using execute method
const result = await client.execute("SELECT * FROM users");
console.log("Users (execute):", result.rows);
// Using prepare and get method
const stmt = client.prepare("SELECT * FROM users LIMIT 1");
const firstUser = await stmt.get();
console.log("First user:", firstUser);
// Using prepare and all method
const allUsers = await stmt.all();
console.log("All users (all):", allUsers);
// Using prepare and iterate method
console.log("Users (iterate):");
const iterateStmt = client.prepare("SELECT * FROM users");
for await (const user of iterateStmt.iterate()) {
console.log(" -", user[0]);
}

View File

@@ -0,0 +1,29 @@
{
"name": "remote",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remote",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@tursodatabase/serverless": "../.."
}
},
"../..": {
"name": "@tursodatabase/serverless",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@types/node": "^24.0.13",
"typescript": "^5.8.3"
}
},
"node_modules/@tursodatabase/serverless": {
"resolved": "../..",
"link": true
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "remote",
"version": "1.0.0",
"main": "index.mjs",
"author": "Giovanni Benussi",
"license": "MIT",
"dependencies": {
"@tursodatabase/serverless": "../.."
}
}

View File

@@ -0,0 +1,54 @@
import test from 'ava';
import { createClient, LibsqlError } from '../dist/compat/index.js';
test.serial('createClient validates supported config options', async t => {
// Valid config should work
t.notThrows(() => {
const client = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
client.close();
});
});
test.serial('createClient rejects unsupported config options', async t => {
const error = t.throws(() => {
createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
encryptionKey: 'some-key',
syncUrl: 'https://sync.example.com',
});
}, { instanceOf: LibsqlError });
t.is(error.code, 'UNSUPPORTED_CONFIG');
t.regex(error.message, /encryptionKey.*syncUrl/);
t.regex(error.message, /Only 'url' and 'authToken' are supported/);
});
test.serial('createClient requires url config option', async t => {
const error = t.throws(() => {
createClient({
authToken: process.env.TURSO_AUTH_TOKEN,
});
}, { instanceOf: LibsqlError });
t.is(error.code, 'MISSING_URL');
t.regex(error.message, /Missing required 'url'/);
});
test.serial('createClient works with basic libSQL API', async t => {
const client = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
// Test basic functionality
const result = await client.execute('SELECT 42 as answer');
t.is(result.rows[0][0], 42);
t.is(result.columns[0], 'answer');
client.close();
t.true(client.closed);
});

View File

@@ -0,0 +1,119 @@
import test from 'ava';
import { connect } from '../dist/index.js';
const client = connect({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
test.serial('execute() method creates table and inserts data', async t => {
await client.execute('DROP TABLE IF EXISTS test_users');
await client.execute('CREATE TABLE test_users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)');
const insertResult = await client.execute(
'INSERT INTO test_users (name, email) VALUES (?, ?)',
['John Doe', 'john@example.com']
);
t.is(insertResult.rowsAffected, 1);
t.is(typeof insertResult.lastInsertRowid, 'number');
});
test.serial('execute() method queries data correctly', async t => {
const queryResult = await client.execute('SELECT * FROM test_users WHERE name = ?', ['John Doe']);
t.is(queryResult.columns.length, 3);
t.true(queryResult.columns.includes('id'));
t.true(queryResult.columns.includes('name'));
t.true(queryResult.columns.includes('email'));
t.is(queryResult.rows.length, 1);
t.is(queryResult.rows[0][1], 'John Doe');
t.is(queryResult.rows[0][2], 'john@example.com');
});
test.serial('prepare() method creates statement', async t => {
const stmt = client.prepare('SELECT * FROM test_users WHERE name = ?');
const row = await stmt.get(['John Doe']);
t.is(row[1], 'John Doe');
t.is(row[2], 'john@example.com');
const rows = await stmt.all(['John Doe']);
t.is(rows.length, 1);
t.is(rows[0][1], 'John Doe');
});
test.serial('statement iterate() method works', async t => {
// Ensure test data exists
await client.execute('CREATE TABLE IF NOT EXISTS test_users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)');
await client.execute('INSERT OR IGNORE INTO test_users (name, email) VALUES (?, ?)', ['John Doe', 'john@example.com']);
const stmt = client.prepare('SELECT * FROM test_users');
const rows = [];
for await (const row of stmt.iterate()) {
rows.push(row);
}
t.true(rows.length >= 1);
t.is(rows[0][1], 'John Doe');
});
test.serial('batch() method executes multiple statements', async t => {
await client.execute('DROP TABLE IF EXISTS test_products');
const batchResult = await client.batch([
'CREATE TABLE test_products (id INTEGER PRIMARY KEY, name TEXT, price REAL)',
'INSERT INTO test_products (name, price) VALUES ("Widget", 9.99)',
'INSERT INTO test_products (name, price) VALUES ("Gadget", 19.99)',
'INSERT INTO test_products (name, price) VALUES ("Tool", 29.99)'
]);
t.is(batchResult.rowsAffected, 3);
const queryResult = await client.execute('SELECT COUNT(*) as count FROM test_products');
t.is(queryResult.rows[0][0], 3);
});
test.serial('execute() method queries a single value', async t => {
const rs = await client.execute('SELECT 42');
t.is(rs.columns.length, 1);
t.is(rs.columnTypes.length, 1);
t.is(rs.rows.length, 1);
t.is(rs.rows[0].length, 1);
t.is(rs.rows[0][0], 42);
});
test.serial('execute() method queries a single row', async t => {
const rs = await client.execute(
"SELECT 1 AS one, 'two' AS two, 0.5 AS three"
);
t.deepEqual(rs.columns, ["one", "two", "three"]);
t.deepEqual(rs.columnTypes, ["", "", ""]);
t.is(rs.rows.length, 1);
const r = rs.rows[0];
t.is(r.length, 3);
t.deepEqual(Array.from(r), [1, "two", 0.5]);
t.deepEqual(Object.entries(r), [
["0", 1],
["1", "two"],
["2", 0.5],
]);
// Test column name access
t.is(r.one, 1);
t.is(r.two, "two");
t.is(r.three, 0.5);
});
test.serial('error handling works correctly', async t => {
const error = await t.throwsAsync(
() => client.execute('SELECT * FROM nonexistent_table')
);
t.regex(error.message, /SQLite error.*no such table|no such table|HTTP error/);
});

2394
packages/turso-serverless/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "@tursodatabase/serverless",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./compat": {
"import": "./dist/compat/index.js",
"types": "./dist/compat/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "ava integration-tests/*.test.mjs"
},
"keywords": [],
"author": "",
"license": "MIT",
"description": "",
"devDependencies": {
"@types/node": "^24.0.13",
"ava": "^6.4.1",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,322 @@
import { Connection, connect, type Config as TursoConfig } from './connection.js';
/**
* Configuration options for creating a libSQL-compatible client.
*
* @remarks
* This interface matches the libSQL client configuration but only `url` and `authToken`
* are supported in the serverless compatibility layer. Other options will throw validation errors.
*/
export interface Config {
/** Database URL (required) */
url: string;
/** Authentication token for the database */
authToken?: string;
/** @deprecated Database encryption key - not supported in serverless mode */
encryptionKey?: string;
/** @deprecated Sync server URL - not supported in serverless mode */
syncUrl?: string;
/** @deprecated Sync frequency in seconds - not supported in serverless mode */
syncInterval?: number;
/** @deprecated Consistency mode - not supported in serverless mode */
readYourWrites?: boolean;
/** @deprecated Offline mode support - not supported in serverless mode */
offline?: boolean;
/** @deprecated TLS settings - not supported in serverless mode */
tls?: boolean;
/** @deprecated Integer handling mode - not supported in serverless mode */
intMode?: "number" | "bigint" | "string";
/** @deprecated Custom fetch implementation - not supported in serverless mode */
fetch?: Function;
/** @deprecated Concurrent request limit - not supported in serverless mode */
concurrency?: number;
}
/** Input value types accepted by libSQL statements */
export type InValue = null | string | number | bigint | ArrayBuffer | boolean | Uint8Array | Date;
/** Input arguments - either positional array or named object */
export type InArgs = Array<InValue> | Record<string, InValue>;
/** Input statement - either SQL string or object with sql and args */
export type InStatement = { sql: string; args?: InArgs } | string;
/** Transaction execution modes */
export type TransactionMode = "write" | "read" | "deferred";
/**
* A result row that can be accessed both as an array and as an object.
* Supports both numeric indexing (row[0]) and column name access (row.column_name).
*/
export interface Row {
length: number;
[index: number]: InValue;
[name: string]: InValue;
}
/**
* Result set returned from SQL statement execution.
*/
export interface ResultSet {
/** Column names in the result set */
columns: Array<string>;
/** Column type information */
columnTypes: Array<string>;
/** Result rows */
rows: Array<Row>;
/** Number of rows affected by the statement */
rowsAffected: number;
/** ID of the last inserted row (for INSERT statements) */
lastInsertRowid: bigint | undefined;
/** Convert result set to JSON */
toJSON(): any;
}
/**
* libSQL-compatible error class with error codes.
*/
export class LibsqlError extends Error {
/** Machine-readable error code */
code: string;
/** Raw numeric error code (if available) */
rawCode?: number;
constructor(message: string, code: string, rawCode?: number) {
super(message);
this.name = 'LibsqlError';
this.code = code;
this.rawCode = rawCode;
}
}
/**
* Interactive transaction interface (not implemented in serverless mode).
*
* @remarks
* Transactions are not supported in the serverless compatibility layer.
* Calling transaction() will throw a LibsqlError.
*/
export interface Transaction {
execute(stmt: InStatement): Promise<ResultSet>;
batch(stmts: Array<InStatement>): Promise<Array<ResultSet>>;
executeMultiple(sql: string): Promise<void>;
commit(): Promise<void>;
rollback(): Promise<void>;
close(): void;
closed: boolean;
}
/**
* libSQL-compatible client interface.
*
* This interface matches the standard libSQL client API for drop-in compatibility.
* Some methods are not implemented in the serverless compatibility layer.
*/
export interface Client {
execute(stmt: InStatement): Promise<ResultSet>;
execute(sql: string, args?: InArgs): Promise<ResultSet>;
batch(stmts: Array<InStatement>, mode?: TransactionMode): Promise<Array<ResultSet>>;
migrate(stmts: Array<InStatement>): Promise<Array<ResultSet>>;
transaction(mode?: TransactionMode): Promise<Transaction>;
executeMultiple(sql: string): Promise<void>;
sync(): Promise<any>;
close(): void;
closed: boolean;
protocol: string;
}
class LibSQLClient implements Client {
private connection: Connection;
private _closed = false;
constructor(config: Config) {
this.validateConfig(config);
const tursoConfig: TursoConfig = {
url: config.url,
authToken: config.authToken || ''
};
this.connection = connect(tursoConfig);
}
private validateConfig(config: Config): void {
// Check for unsupported config options
const unsupportedOptions: Array<{ key: keyof Config; value: any }> = [];
if (config.encryptionKey !== undefined) {
unsupportedOptions.push({ key: 'encryptionKey', value: config.encryptionKey });
}
if (config.syncUrl !== undefined) {
unsupportedOptions.push({ key: 'syncUrl', value: config.syncUrl });
}
if (config.syncInterval !== undefined) {
unsupportedOptions.push({ key: 'syncInterval', value: config.syncInterval });
}
if (config.readYourWrites !== undefined) {
unsupportedOptions.push({ key: 'readYourWrites', value: config.readYourWrites });
}
if (config.offline !== undefined) {
unsupportedOptions.push({ key: 'offline', value: config.offline });
}
if (config.tls !== undefined) {
unsupportedOptions.push({ key: 'tls', value: config.tls });
}
if (config.intMode !== undefined) {
unsupportedOptions.push({ key: 'intMode', value: config.intMode });
}
if (config.fetch !== undefined) {
unsupportedOptions.push({ key: 'fetch', value: config.fetch });
}
if (config.concurrency !== undefined) {
unsupportedOptions.push({ key: 'concurrency', value: config.concurrency });
}
if (unsupportedOptions.length > 0) {
const optionsList = unsupportedOptions.map(opt => `'${opt.key}'`).join(', ');
throw new LibsqlError(
`Unsupported configuration options: ${optionsList}. Only 'url' and 'authToken' are supported in the serverless compatibility layer.`,
"UNSUPPORTED_CONFIG"
);
}
// Validate required options
if (!config.url) {
throw new LibsqlError("Missing required 'url' configuration option", "MISSING_URL");
}
}
get closed(): boolean {
return this._closed;
}
get protocol(): string {
return "http";
}
private normalizeStatement(stmt: InStatement): { sql: string; args: any[] } {
if (typeof stmt === 'string') {
return { sql: stmt, args: [] };
}
const args = stmt.args || [];
if (Array.isArray(args)) {
return { sql: stmt.sql, args };
}
// Convert named args to positional args (simplified)
return { sql: stmt.sql, args: Object.values(args) };
}
private convertResult(result: any): ResultSet {
const resultSet: ResultSet = {
columns: result.columns || [],
columnTypes: result.columnTypes || [],
rows: result.rows || [],
rowsAffected: result.rowsAffected || 0,
lastInsertRowid: result.lastInsertRowid ? BigInt(result.lastInsertRowid) : undefined,
toJSON() {
return {
columns: this.columns,
columnTypes: this.columnTypes,
rows: this.rows,
rowsAffected: this.rowsAffected,
lastInsertRowid: this.lastInsertRowid?.toString()
};
}
};
return resultSet;
}
async execute(stmt: InStatement): Promise<ResultSet>;
async execute(sql: string, args?: InArgs): Promise<ResultSet>;
async execute(stmtOrSql: InStatement | string, args?: InArgs): Promise<ResultSet> {
try {
if (this._closed) {
throw new LibsqlError("Client is closed", "CLIENT_CLOSED");
}
let normalizedStmt: { sql: string; args: any[] };
if (typeof stmtOrSql === 'string') {
const normalizedArgs = args ? (Array.isArray(args) ? args : Object.values(args)) : [];
normalizedStmt = { sql: stmtOrSql, args: normalizedArgs };
} else {
normalizedStmt = this.normalizeStatement(stmtOrSql);
}
const result = await this.connection.execute(normalizedStmt.sql, normalizedStmt.args);
return this.convertResult(result);
} catch (error: any) {
throw new LibsqlError(error.message, "EXECUTE_ERROR");
}
}
async batch(stmts: Array<InStatement>, mode?: TransactionMode): Promise<Array<ResultSet>> {
try {
if (this._closed) {
throw new LibsqlError("Client is closed", "CLIENT_CLOSED");
}
const sqlStatements = stmts.map(stmt => {
const normalized = this.normalizeStatement(stmt);
return normalized.sql; // For now, ignore args in batch
});
const result = await this.connection.batch(sqlStatements, mode);
// Return array of result sets (simplified - actual implementation would be more complex)
return [this.convertResult(result)];
} catch (error: any) {
throw new LibsqlError(error.message, "BATCH_ERROR");
}
}
async migrate(stmts: Array<InStatement>): Promise<Array<ResultSet>> {
// For now, just call batch - in a real implementation this would disable foreign keys
return this.batch(stmts, "write");
}
async transaction(mode?: TransactionMode): Promise<Transaction> {
throw new LibsqlError("Transactions not implemented", "NOT_IMPLEMENTED");
}
async executeMultiple(sql: string): Promise<void> {
throw new LibsqlError("Execute multiple not implemented", "NOT_IMPLEMENTED");
}
async sync(): Promise<any> {
throw new LibsqlError("Sync not supported for remote databases", "NOT_SUPPORTED");
}
close(): void {
this._closed = true;
}
}
/**
* Create a libSQL-compatible client for Turso database access.
*
* This function provides compatibility with the standard libSQL client API
* while using the Turso serverless driver under the hood.
*
* @param config - Configuration object (only url and authToken are supported)
* @returns A Client instance compatible with libSQL API
* @throws LibsqlError if unsupported configuration options are provided
*
* @example
* ```typescript
* import { createClient } from "@tursodatabase/serverless/compat";
*
* const client = createClient({
* url: process.env.TURSO_DATABASE_URL,
* authToken: process.env.TURSO_AUTH_TOKEN
* });
*
* const result = await client.execute("SELECT * FROM users");
* console.log(result.rows);
* ```
*/
export function createClient(config: Config): Client {
return new LibSQLClient(config);
}

View File

@@ -0,0 +1 @@
export * from '../compat.js';

View File

@@ -0,0 +1,100 @@
import { Session, type SessionConfig } from './session.js';
import { Statement } from './statement.js';
/**
* Configuration options for connecting to a Turso database.
*/
export interface Config extends SessionConfig {}
/**
* A connection to a Turso database.
*
* Provides methods for executing SQL statements and managing prepared statements.
* Uses the SQL over HTTP protocol with streaming cursor support for optimal performance.
*/
export class Connection {
private config: Config;
private session: Session;
constructor(config: Config) {
this.config = config;
this.session = new Session(config);
}
/**
* Prepare a SQL statement for execution.
*
* Each prepared statement gets its own session to avoid conflicts during concurrent execution.
*
* @param sql - The SQL statement to prepare
* @returns A Statement object that can be executed multiple ways
*
* @example
* ```typescript
* const stmt = client.prepare("SELECT * FROM users WHERE id = ?");
* const user = await stmt.get([123]);
* const allUsers = await stmt.all();
* ```
*/
prepare(sql: string): Statement {
return new Statement(this.config, sql);
}
/**
* Execute a SQL statement and return all results.
*
* @param sql - The SQL statement to execute
* @param args - Optional array of parameter values
* @returns Promise resolving to the complete result set
*
* @example
* ```typescript
* const result = await client.execute("SELECT * FROM users");
* console.log(result.rows);
* ```
*/
async execute(sql: string, args: any[] = []): Promise<any> {
return this.session.execute(sql, args);
}
/**
* Execute multiple SQL statements in a batch.
*
* @param statements - Array of SQL statements to execute
* @param mode - Optional transaction mode (currently unused)
* @returns Promise resolving to batch execution results
*
* @example
* ```typescript
* await client.batch([
* "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
* "INSERT INTO users (name) VALUES ('Alice')",
* "INSERT INTO users (name) VALUES ('Bob')"
* ]);
* ```
*/
async batch(statements: string[], mode?: string): Promise<any> {
return this.session.batch(statements);
}
}
/**
* Create a new connection to a Turso database.
*
* @param config - Configuration object with database URL and auth token
* @returns A new Connection instance
*
* @example
* ```typescript
* import { connect } from "@tursodatabase/serverless";
*
* const client = connect({
* url: process.env.TURSO_DATABASE_URL,
* authToken: process.env.TURSO_AUTH_TOKEN
* });
* ```
*/
export function connect(config: Config): Connection {
return new Connection(config);
}

View File

@@ -0,0 +1,3 @@
// Turso serverless driver entry point
export { Connection, connect, type Config } from './connection.js';
export { Statement } from './statement.js';

View File

@@ -0,0 +1,245 @@
export interface Value {
type: 'null' | 'integer' | 'float' | 'text' | 'blob';
value?: string | number;
base64?: string;
}
export interface Column {
name: string;
decltype: string;
}
export interface ExecuteResult {
cols: Column[];
rows: Value[][];
affected_row_count: number;
last_insert_rowid?: string;
}
export interface ExecuteRequest {
type: 'execute';
stmt: {
sql: string;
args: Value[];
named_args: Value[];
want_rows: boolean;
};
}
export interface BatchStep {
stmt: {
sql: string;
args: Value[];
want_rows: boolean;
};
condition?: {
type: 'ok';
step: number;
};
}
export interface BatchRequest {
type: 'batch';
batch: {
steps: BatchStep[];
};
}
export interface PipelineRequest {
baton: string | null;
requests: (ExecuteRequest | BatchRequest)[];
}
export interface PipelineResponse {
baton: string | null;
base_url: string | null;
results: Array<{
type: 'ok' | 'error';
response?: {
type: 'execute' | 'batch';
result: ExecuteResult;
};
error?: {
message: string;
code: string;
};
}>;
}
export function encodeValue(value: any): Value {
if (value === null || value === undefined) {
return { type: 'null' };
}
if (typeof value === 'number') {
if (Number.isInteger(value)) {
return { type: 'integer', value: value.toString() };
}
return { type: 'float', value };
}
if (typeof value === 'string') {
return { type: 'text', value };
}
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
const base64 = btoa(String.fromCharCode(...new Uint8Array(value)));
return { type: 'blob', base64 };
}
return { type: 'text', value: String(value) };
}
export function decodeValue(value: Value): any {
switch (value.type) {
case 'null':
return null;
case 'integer':
return parseInt(value.value as string, 10);
case 'float':
return value.value as number;
case 'text':
return value.value as string;
case 'blob':
if (value.base64) {
const binaryString = atob(value.base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
return null;
default:
return null;
}
}
export interface CursorRequest {
baton: string | null;
batch: {
steps: BatchStep[];
};
}
export interface CursorResponse {
baton: string | null;
base_url: string | null;
}
export interface CursorEntry {
type: 'step_begin' | 'step_end' | 'step_error' | 'row' | 'error';
step?: number;
cols?: Column[];
row?: Value[];
affected_row_count?: number;
last_insert_rowid?: string;
error?: {
message: string;
code: string;
};
}
export async function executeCursor(
url: string,
authToken: string,
request: CursorRequest
): Promise<{ response: CursorResponse; entries: AsyncGenerator<CursorEntry> }> {
const response = await fetch(`${url}/v3/cursor`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify(request),
});
if (!response.ok) {
let errorMessage = `HTTP error! status: ${response.status}`;
try {
const errorBody = await response.text();
const errorData = JSON.parse(errorBody);
if (errorData.message) {
errorMessage = errorData.message;
}
} catch {
// If we can't parse the error body, use the default HTTP error message
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
let isFirstLine = true;
let cursorResponse: CursorResponse;
async function* parseEntries(): AsyncGenerator<CursorEntry> {
try {
while (true) {
const { done, value } = await reader!.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
if (isFirstLine) {
cursorResponse = JSON.parse(line);
isFirstLine = false;
} else {
yield JSON.parse(line) as CursorEntry;
}
}
}
}
} finally {
reader!.releaseLock();
}
}
const entries = parseEntries();
// Get the first entry to parse the cursor response
const firstEntry = await entries.next();
if (!firstEntry.done) {
// Put the first entry back
const generator = (async function* () {
yield firstEntry.value;
yield* entries;
})();
return { response: cursorResponse!, entries: generator };
}
return { response: cursorResponse!, entries };
}
export async function executePipeline(
url: string,
authToken: string,
request: PipelineRequest
): Promise<PipelineResponse> {
const response = await fetch(`${url}/v3/pipeline`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

View File

@@ -0,0 +1,216 @@
import {
executeCursor,
encodeValue,
decodeValue,
type CursorRequest,
type CursorResponse,
type CursorEntry
} from './protocol.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);
}
/**
* Execute a SQL statement and return all results.
*
* @param sql - The SQL statement to execute
* @param args - Optional array of parameter values
* @returns Promise resolving to the complete result set
*/
async execute(sql: string, args: any[] = []): Promise<any> {
const { response, entries } = await this.executeRaw(sql, args);
const result = await this.processCursorEntries(entries);
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
* @returns Promise resolving to the raw response and cursor entries
*/
async executeRaw(sql: string, args: any[] = []): Promise<{ response: CursorResponse; entries: AsyncGenerator<CursorEntry> }> {
const request: CursorRequest = {
baton: this.baton,
batch: {
steps: [{
stmt: {
sql,
args: args.map(encodeValue),
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>): 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(decodeValue);
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 Error(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: [],
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 Error(entry.error?.message || 'Batch execution failed');
}
}
return {
rowsAffected: totalRowsAffected,
lastInsertRowid
};
}
}

View File

@@ -0,0 +1,107 @@
import {
decodeValue,
type CursorEntry
} from './protocol.js';
import { Session, type SessionConfig } from './session.js';
/**
* A prepared SQL statement that can be executed in multiple ways.
*
* Each statement has its own session to avoid conflicts during concurrent execution.
* Provides three execution modes:
* - `get(args?)`: Returns the first row or null
* - `all(args?)`: Returns all rows as an array
* - `iterate(args?)`: Returns an async iterator for streaming results
*/
export class Statement {
private session: Session;
private sql: string;
constructor(sessionConfig: SessionConfig, sql: string) {
this.session = new Session(sessionConfig);
this.sql = sql;
}
/**
* Execute the statement and return the first row.
*
* @param args - Optional array of parameter values for the SQL statement
* @returns Promise resolving to the first row or null if no results
*
* @example
* ```typescript
* const stmt = client.prepare("SELECT * FROM users WHERE id = ?");
* const user = await stmt.get([123]);
* if (user) {
* console.log(user.name);
* }
* ```
*/
async get(args: any[] = []): Promise<any> {
const result = await this.session.execute(this.sql, args);
return result.rows[0] || null;
}
/**
* Execute the statement and return all rows.
*
* @param args - Optional array of parameter values for the SQL statement
* @returns Promise resolving to an array of all result rows
*
* @example
* ```typescript
* const stmt = client.prepare("SELECT * FROM users WHERE active = ?");
* const activeUsers = await stmt.all([true]);
* console.log(`Found ${activeUsers.length} active users`);
* ```
*/
async all(args: any[] = []): Promise<any[]> {
const result = await this.session.execute(this.sql, args);
return result.rows;
}
/**
* Execute the statement and return an async iterator for streaming results.
*
* This method provides memory-efficient processing of large result sets
* by streaming rows one at a time instead of loading everything into memory.
*
* @param args - Optional array of parameter values for the SQL statement
* @returns AsyncGenerator that yields individual rows
*
* @example
* ```typescript
* const stmt = client.prepare("SELECT * FROM large_table WHERE category = ?");
* for await (const row of stmt.iterate(['electronics'])) {
* // Process each row individually
* console.log(row.id, row.name);
* }
* ```
*/
async *iterate(args: any[] = []): AsyncGenerator<any> {
const { response, entries } = await this.session.executeRaw(this.sql, args);
let columns: string[] = [];
for await (const entry of entries) {
switch (entry.type) {
case 'step_begin':
if (entry.cols) {
columns = entry.cols.map(col => col.name);
}
break;
case 'row':
if (entry.row) {
const decodedRow = entry.row.map(decodeValue);
const rowObject = this.session.createRowObject(decodedRow, columns);
yield rowObject;
}
break;
case 'step_error':
case 'error':
throw new Error(entry.error?.message || 'SQL execution failed');
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}