mirror of
https://github.com/aljazceru/turso.git
synced 2026-01-27 11:54:30 +01:00
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:
192
packages/turso-serverless/AGENT.md
Normal file
192
packages/turso-serverless/AGENT.md
Normal 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.
|
||||
78
packages/turso-serverless/README.md
Normal file
78
packages/turso-serverless/README.md
Normal 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
|
||||
19
packages/turso-serverless/examples/remote-compat/README.md
Normal file
19
packages/turso-serverless/examples/remote-compat/README.md
Normal 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.
|
||||
20
packages/turso-serverless/examples/remote-compat/index.mjs
Normal file
20
packages/turso-serverless/examples/remote-compat/index.mjs
Normal 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);
|
||||
30
packages/turso-serverless/examples/remote-compat/package-lock.json
generated
Normal file
30
packages/turso-serverless/examples/remote-compat/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "batch",
|
||||
"version": "1.0.0",
|
||||
"main": "index.mjs",
|
||||
"author": "Giovanni Benussi",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tursodatabase/serverless": "../.."
|
||||
}
|
||||
}
|
||||
19
packages/turso-serverless/examples/remote/README.md
Normal file
19
packages/turso-serverless/examples/remote/README.md
Normal 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.
|
||||
36
packages/turso-serverless/examples/remote/index.mjs
Normal file
36
packages/turso-serverless/examples/remote/index.mjs
Normal 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]);
|
||||
}
|
||||
29
packages/turso-serverless/examples/remote/package-lock.json
generated
Normal file
29
packages/turso-serverless/examples/remote/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/turso-serverless/examples/remote/package.json
Normal file
10
packages/turso-serverless/examples/remote/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "remote",
|
||||
"version": "1.0.0",
|
||||
"main": "index.mjs",
|
||||
"author": "Giovanni Benussi",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tursodatabase/serverless": "../.."
|
||||
}
|
||||
}
|
||||
54
packages/turso-serverless/integration-tests/compat.test.mjs
Normal file
54
packages/turso-serverless/integration-tests/compat.test.mjs
Normal 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);
|
||||
});
|
||||
119
packages/turso-serverless/integration-tests/serverless.test.mjs
Normal file
119
packages/turso-serverless/integration-tests/serverless.test.mjs
Normal 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
2394
packages/turso-serverless/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
packages/turso-serverless/package.json
Normal file
35
packages/turso-serverless/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
322
packages/turso-serverless/src/compat.ts
Normal file
322
packages/turso-serverless/src/compat.ts
Normal 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);
|
||||
}
|
||||
1
packages/turso-serverless/src/compat/index.ts
Normal file
1
packages/turso-serverless/src/compat/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../compat.js';
|
||||
100
packages/turso-serverless/src/connection.ts
Normal file
100
packages/turso-serverless/src/connection.ts
Normal 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);
|
||||
}
|
||||
3
packages/turso-serverless/src/index.ts
Normal file
3
packages/turso-serverless/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Turso serverless driver entry point
|
||||
export { Connection, connect, type Config } from './connection.js';
|
||||
export { Statement } from './statement.js';
|
||||
245
packages/turso-serverless/src/protocol.ts
Normal file
245
packages/turso-serverless/src/protocol.ts
Normal 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();
|
||||
}
|
||||
216
packages/turso-serverless/src/session.ts
Normal file
216
packages/turso-serverless/src/session.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
107
packages/turso-serverless/src/statement.ts
Normal file
107
packages/turso-serverless/src/statement.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
19
packages/turso-serverless/tsconfig.json
Normal file
19
packages/turso-serverless/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user