mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-17 00:24:21 +01:00
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:
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