Native bindings for Tursodb — a pure-Rust SQLite-compatible engine — for the Perry TypeScript-to-native compiler.
Closes PerryTS/perry#424.
A Perry "native library" package: a Rust crate exporting extern "C" symbols that the Perry compiler links into your TypeScript program. From your TypeScript code you import tursodb like any npm package; under the hood every method call resolves to a direct call into the bundled staticlib — no Node addon, no IPC, no JSON marshalling.
This package contains:
src/lib.rs— the Rust crate that wrapstursoand exposesjs_turso_*extern "C"symbolssrc/index.d.ts— the TypeScript surface (tursodbmodule declaration) Perry resolves at compile timeCargo.toml— staticlib build config consumed by the Perry linkerpackage.json— includes theperry.nativeLibrarymanifest block
bun add @perryts/tursodb
# or
npm install @perryts/tursodbThe package's package.json declares a perry.nativeLibrary block (see the manifest spec) which Perry's compiler reads at link time to discover the staticlib + extern "C" symbols. No post-install build step — Perry compiles the Rust crate as part of your project's build.
import * as tursodb from "tursodb";
const db = await tursodb.open(":memory:");
await tursodb.execBatch(db, `
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com');
`);
const rows = await tursodb.queryAll(db, "SELECT * FROM users ORDER BY id");
for (const row of rows) {
console.log(row.id, row.name, row.email);
}
await tursodb.close(db);function open(path: string): Promise<Database>Open a database connection. Use ":memory:" for an in-memory database, otherwise pass a filesystem path (created if missing). Resolves with an opaque Database handle. Reject reasons surface the underlying turso error.
const memory = await tursodb.open(":memory:");
const onDisk = await tursodb.open("./app.db");function exec(db: Database, sql: string): Promise<number>Execute a single non-query statement. Resolves with the number of rows affected. Use this for INSERT / UPDATE / DELETE and DDL.
const inserted = await tursodb.exec(db, "INSERT INTO users (name) VALUES ('Carol')");
const removed = await tursodb.exec(db, "DELETE FROM users WHERE id = 2");function execBatch(db: Database, sql: string): Promise<void>Execute multiple ;-separated statements in one round-trip. Useful for migrations, schema bootstrap, and seed scripts. Does not return row counts.
await tursodb.execBatch(db, `
CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT);
CREATE INDEX idx_posts_title ON posts(title);
`);function queryAll(db: Database, sql: string): Promise<Row[]>Run a SELECT and resolve with every row as an object keyed by column name. Equivalent to better-sqlite3's stmt.all().
const rows = await tursodb.queryAll(db, "SELECT id, name FROM users ORDER BY id");
// rows: [{ id: 1, name: "Alice" }, { id: 3, name: "Carol" }]function queryOne(db: Database, sql: string): Promise<Row | null>Run a query and resolve with the first row, or null if the result set is empty. Equivalent to better-sqlite3's stmt.get().
const alice = await tursodb.queryOne(db, "SELECT * FROM users WHERE name = 'Alice'");
console.log(alice?.email);Use ? placeholders in the SQL and bind from a params array. Use these whenever any value comes from untrusted input — the unparameterized variants only accept static SQL.
function execWith(db: Database, sql: string, params: Param[]): Promise<number>;
function queryAllWith(db: Database, sql: string, params: Param[]): Promise<Row[]>;
function queryOneWith(db: Database, sql: string, params: Param[]): Promise<Row | null>;
// `Param` types: string | number | boolean | null | number[] (Uint8 → Blob)
type Param = string | number | boolean | null | number[];const rows = await tursodb.execWith(
db,
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
["Eve", "eve@example.com", 31],
);
const user = await tursodb.queryOneWith(
db,
"SELECT * FROM users WHERE email = ? AND active = ?",
["eve@example.com", true],
);
// Blob: pass a Uint8Array as a number[] (each element 0..=255)
await tursodb.execWith(
db,
"INSERT INTO files (sha256, body) VALUES (?, ?)",
["abc123", [0x89, 0x50, 0x4e, 0x47]], // PNG header
);Object params (e.g. { foo: "bar" }) are rejected at runtime since SQLite has no native JSON column type — JSON.stringify first if you want to store a structured value.
function lastInsertRowid(db: Database): numberSynchronous accessor for the rowid of the most recent successful INSERT on this connection. Returns 0 if no insert has happened yet.
await tursodb.exec(db, "INSERT INTO users (name) VALUES ('Dan')");
const newId = tursodb.lastInsertRowid(db);function isAutocommit(db: Database): booleanSynchronous. Returns true when the connection is outside an explicit transaction, false while a BEGIN block is open.
await tursodb.exec(db, "BEGIN");
tursodb.isAutocommit(db); // false
await tursodb.exec(db, "COMMIT");
tursodb.isAutocommit(db); // truefunction close(db: Database): booleanSynchronous. Drops the connection and frees the underlying handle. Returns true on success, false if the handle was already closed or invalid. Calling any other function on a closed handle rejects with "tursodb: invalid handle".
tursodb.close(db);Exported from the tursodb module declaration in src/index.d.ts:
type Database = number & { readonly __tursodb: unique symbol };
type Value = string | number | null;
type Row = Record<string, Value>;Column-value mapping (from the Rust turso_value_to_js):
| SQLite type | TypeScript value |
|---|---|
NULL |
null |
INTEGER |
number |
REAL |
number |
TEXT |
string |
BLOB |
string (lowercase hex) |
BLOB columns are rendered as a lowercase hex string to keep the wrapper dependency-free; decode them in user code if you need raw bytes.
Use BEGIN / COMMIT / ROLLBACK via exec. There is no high-level transaction wrapper yet.
await tursodb.exec(db, "BEGIN");
try {
await tursodb.exec(db, "INSERT INTO users (name) VALUES ('Eve')");
await tursodb.exec(db, "INSERT INTO users (name) VALUES ('Frank')");
await tursodb.exec(db, "COMMIT");
} catch (err) {
await tursodb.exec(db, "ROLLBACK");
throw err;
}Async functions reject with an Error whose message is prefixed by the operation, e.g. tursodb queryAll: <upstream message>. An invalid or already-closed handle rejects with "tursodb: invalid handle". Sync functions (lastInsertRowid, isAutocommit, close) do not throw — lastInsertRowid returns 0 on a bad handle, isAutocommit returns false, close returns false.
MVP. What's there:
open/closeexec/execBatchqueryAll/queryOne(rows materialized as objects keyed by column name)lastInsertRowid/isAutocommit
Known gaps, tracked in PerryTS/perry:
- Parameter binding (
?placeholders) — needs a JSArray<JsValue>reader on the perry-ffi side that maps onto turso'sIntoParams. Until then, embed values directly in SQL — but only for trusted input. - Prepared-statement objects (
stmt.all()/stmt.get()/stmt.run()style) - Streaming / iterator-based row reads
- Native
BLOB(Uint8Array) values instead of hex strings - High-level transaction helper
Pre-1.0. The perry.nativeLibrary.abiVersion (currently 0.5) is a hard pin against Perry's perry-ffi ABI — bump it in lockstep with the Perry release that the bindings target.
MIT — see LICENSE.