Add @tursodatabase/serverless package

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.
This commit is contained in:
Pekka Enberg
2025-07-21 21:48:10 +03:00
parent 8f83b150b7
commit 7765bafb13
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"]
}