Overview#
Turso is a hosted database service built on
libSQL, an open-source fork of
SQLite. libSQL keeps SQLite's storage format and SQL grammar but adds the one
thing SQLite lacks natively: a network protocol, so the same database can be a
local file, an in-memory instance, or a remote server reached over the wire.| Property | Value |
|---|
| Dialect name | turso |
| Extends | SQLiteDialect (sqlite) |
| Driver | @libsql/client |
| Cloud SDK | none |
| Default port | none — url |
| Local runtime | a local libSQL file or :memory: through @libsql/client |
Because libSQL's SQL is SQLite's SQL, this ORM's TursoDialect
extends SQLiteDialect and inherits every pure SQL-generation method
verbatim — DDL, JSON1 helpers, upsert syntax, LIMIT/OFFSET pagination, and
identifier quoting are all produced by the SQLite code paths. Only the pieces
that genuinely differ — the driver, connection lifecycle, query execution,
streaming, and transactions — are overridden.Key facts (from src/dialects/turso/index.ts):Dialect name is 'turso'; the driver library is '@libsql/client'
(not better-sqlite3, which the base SQLite dialect uses).
The whole reason this dialect exists separately from SQLiteDialect is the
driver. @libsql/client works identically for a local embedded replica
(file: / :memory: URLs) and a remote hosted Turso database
(libsql: / https: URLs plus an authToken). better-sqlite3 is
local-only.
Because it reuses SQLiteDialect's SQL generation, identifiers are
double-quoted (escapeId('my_col') → "my_col"), exactly like SQLite —
not backtick-quoted like MySQL.
The dialect keeps entirely separate connection/transaction state from its
parent, since SQLiteDialect reaches its own connection through private
fields a subclass can't touch.
Quick start#
Install#
$ npm install ts-prorm-orm @libsql/client
$ pnpm add ts-prorm-orm @libsql/client
$ yarn add ts-prorm-orm @libsql/client
$ bun add ts-prorm-orm @libsql/client
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'turso',
dialectOptions: {
url: 'libsql://my-db-myorg.turso.io', // or 'file:./local.db'
authToken: process.env.TURSO_AUTH_TOKEN,
},
});
Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
name: { type: DataTypes.STRING(100) },
});
await db.sync(); // CREATE TABLE — see Generated SQL below
Query#
import { Op } from 'ts-prorm-orm';
await User.create({ email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Transaction with a savepoint#
await db.transaction(async (t) => {
await User.create({ email: 'grace@example.com', name: 'Grace' }, { transaction: t });
// A nested transaction is a SAVEPOINT; its failure undoes only its own work.
await db
.transaction(async (sp) => {
await User.destroy({ where: { id: 1 }, transaction: sp });
throw new Error('keep the row');
}, { transaction: t })
.catch(() => {});
});
Type mapping for the portable descriptors: STRING → VARCHAR(255), auto-increment INTEGER → INTEGER, BOOLEAN → INTEGER, DATE → DATETIME, UUID → TEXT, JSON → TEXT. The type comparison lines the major engines up side by side.Connection#
The dialect is created with a url (and, for hosted databases, an
authToken), not the host/port/username used by server dialects.import { Prorm } from 'ts-prorm-orm';
// Hosted Turso database
const db = new Prorm({
dialect: 'turso',
dialectOptions: {
url: 'libsql://my-db.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
},
});
await db.connect();
For a local libSQL file, or an ephemeral in-memory database, drop the
authToken and point url at a file: or :memory: target:// Local file — no auth token needed
const local = new Prorm({ dialect: 'turso', url: 'file:./local.db' });
// In-memory (the default if `url` is omitted)
const mem = new Prorm({ dialect: 'turso', url: ':memory:' });
TursoDialectOptions (all optional):| Option | Purpose |
|---|
url | libsql://… / https://… (hosted), file:./… (local), :memory:. |
authToken | Auth token for a hosted Turso database. Unused for file:/:memory:. |
encryptionKey | Encryption key for an encrypted local database file. |
syncUrl | Remote database to sync an embedded replica against (see below). |
syncInterval | Sync interval in seconds when syncUrl is set. |
If url is omitted, it defaults to :memory:.
connect() eagerly verifies the connection by running SELECT 1 and then
PRAGMA foreign_keys = ON, so a bad URL or token fails at connect time
rather than on your first query.
A failed connection is wrapped as
Failed to connect to Turso/libSQL database: <error>.
What differs from SQLite#
TursoDialect extends SQLiteDialect (sqlite) and
overrides 18 of its methods (Object.getOwnPropertyNames(TursoDialect.prototype),
computed by the scratch test); everything else is inherited unchanged.| Area | Overridden |
|---|
| Connection and execution | commitTransaction, connect, disconnect, getConnection, isConnected, query, queryStream, rollbackTransaction, startTransaction |
| Types | mapNativeType, renderSqliteType |
| DDL and schema | attachDatabase, detachDatabase |
| Introspection | getDatabaseVersion |
| Other | rowToObject, setPragma, sync, toLibsqlArgs |
SQLite compatibility#
Everything the ORM generates for Turso is SQLite SQL, inherited unchanged from
SQLiteDialect. Standard CRUD, table creation, and JSON1 helpers all behave
exactly as documented for the SQLite dialect.await db.connect();
await db.dialect.createTable('widgets', {
id: { type: 'INTEGER', primaryKey: true, autoIncrement: true },
name: { type: 'TEXT', allowNull: false },
price: { type: 'REAL' },
} as any);
Query execution accepts positional or named parameters. The dialect reads
options.replacements (falling back to options.bind), so both work:// Positional
await db.dialect.query('INSERT INTO widgets (name, price) VALUES (?, ?)', {
replacements: ['gadget', 9.99],
} as any);
// Named
await db.dialect.query('INSERT INTO widgets (name, price) VALUES (:name, :price)', {
replacements: { name: 'widget', price: 3 },
} as any);
For a SELECT (or PRAGMA), query() returns { rows, rowCount, fields },
where each row is a plain object keyed by column name. For a write it returns
{ rows: [], rowCount, lastInsertRowid }, where rowCount is the number of
rows affected and lastInsertRowid is the numeric rowid of the last insert:const res = await db.dialect.query('INSERT INTO widgets (name, price) VALUES (?, ?)', {
replacements: ['thing', 1.5],
} as any);
res.rowCount; // 1
res.lastInsertRowid; // 1
Upserts and RETURNING#
Because upsert generation is inherited from SQLite, Turso uses SQLite's
INSERT … ON CONFLICT (…) DO UPDATE form. libSQL, like modern SQLite,
supports RETURNING, so the SQLite dialect's RETURNING support applies here
too — this is a real difference from the MySQL-family dialects (TiDB, MySQL)
which have no RETURNING.Edge and embedded replicas#
The headline libSQL feature is the embedded replica: a local libSQL file
that a background process keeps in sync with a remote Turso database. Reads hit
the local copy at SQLite speed; writes and periodic pulls go to the remote.Configure it by giving both a local url and a syncUrl pointing at the
remote:const db = new Prorm({
dialect: 'turso',
dialectOptions: {
url: 'file:./replica.db', // local embedded replica
syncUrl: 'libsql://my-db.turso.io', // remote it syncs against
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // auto-pull every 60s
},
});
await db.connect();
To force an immediate pull of the latest remote changes into the local
replica, call sync():sync() requires a syncUrl to have been configured at connect time —
otherwise it throws
sync() requires a syncUrl to have been configured on connect. It also throws
Not connected to database if called before connect().For an encrypted local database, pass encryptionKey:const db = new Prorm({
dialect: 'turso',
dialectOptions: {
url: 'file:./secure.db',
encryptionKey: process.env.DB_KEY,
},
});
Data types#
Type mapping is inherited from SQLiteDialect (getDataTypeSql). SQLite uses
dynamic typing, so these declared types are affinity hints rather than strict
constraints. Notable mappings:| ORM type descriptor | Generated SQL |
|---|
{ key: 'STRING' } | VARCHAR(255) |
{ key: 'STRING', length: 100 } | VARCHAR(100) |
{ key: 'CHAR' } | CHAR(1) |
{ key: 'TEXT' } | TEXT |
{ key: 'INTEGER' } | INTEGER |
{ key: 'BIGINT' } | BIGINT |
{ key: 'DECIMAL', precision: 12, scale: 4 } | DECIMAL(12,4) |
{ key: 'BOOLEAN' } | INTEGER (SQLite has no boolean) |
{ key: 'DATE' } | DATETIME |
{ key: 'DATEONLY' } | DATE |
{ key: 'BLOB' } | BLOB |
{ key: 'JSON' } / { key: 'JSONB' } | TEXT (JSON stored as text) |
{ key: 'UUID' } | TEXT |
{ key: 'ENUM', values: ['a', 'b'] } | TEXT CHECK("col" IN ('a','b')) |
{ key: 'GEOMETRY' } | BLOB |
ENUM has no native SQLite type; it is emulated as TEXT with a CHECK
constraint referencing the actual column name. JSON/JSONB are stored as
TEXT and manipulated through SQLite's JSON1 functions (json_extract,
json_group_array, json_patch, json_each, etc.), all inherited from the
SQLite dialect.Transactions#
Transactions are driven directly against the @libsql/client connection. The
first startTransaction() issues BEGIN TRANSACTION; nested calls open
SAVEPOINTs, so nested transactions map onto savepoint semantics:const outer = await db.dialect.startTransaction();
await db.dialect.query("INSERT INTO widgets (name) VALUES ('outer')");
const inner = await db.dialect.startTransaction(); // SAVEPOINT
await db.dialect.query("INSERT INTO widgets (name) VALUES ('inner')");
await inner.rollback(); // ROLLBACK TO SAVEPOINT — keeps 'outer'
await outer.commit(); // COMMIT
Committing the outermost transaction emits COMMIT; committing a nested one
emits RELEASE SAVEPOINT. Rollback follows the same split (ROLLBACK vs
ROLLBACK TO SAVEPOINT).Quirks and limitations#
No .advanced extension API. SQLiteDialect exposes an .advanced
getter backed by better-sqlite3's synchronous Database handle. Turso wraps
an async @libsql/client connection with no such handle, so accessing
db.dialect.advanced throws:TursoDialect does not support the .advanced SQLite extension API (it depends on a synchronous better-sqlite3 connection). Use the standard Dialect methods instead.
Use the standard Dialect methods (query, createTable, etc.) instead. No native server-side cursor. @libsql/client's execute() always
returns the full result set, so queryStream() falls back to the generic
LIMIT/OFFSET-paginated stream helper (the pagination SQL is inherited
from SQLite unchanged). It still streams row-by-row across multiple
round-trips, but the batching happens client-driven rather than through a
real cursor. Provide a batchSize to tune the page size:const stream = db.dialect.queryStream('SELECT * FROM widgets ORDER BY id', {
batchSize: 100,
});
for await (const row of stream) {
// ...
}
getDatabaseVersion() reports the SQLite version, via
SELECT sqlite_version() — it reflects the SQLite grammar level libSQL
implements, not a Turso server build number.
foreign_keys is enabled on connect. Unlike bare SQLite (which defaults
foreign-key enforcement off), connect() runs PRAGMA foreign_keys = ON, so
foreign-key constraints are enforced by default.
ATTACH/DETACH are available via attachDatabase(path, alias) and
detachDatabase(alias), though attaching across databases is subject to
libSQL's own restrictions on a hosted deployment.
Verification status#
The behavior above is exercised in tests/dialects/turso.test.ts, which runs
against a real :memory: libSQL instance (there is no separate driver to
mock — @libsql/client is identical for local and remote). Connection,
DDL, CRUD with positional and named parameters, rowCount/lastInsertRowid,
commit/rollback/savepoint transactions, the paginated queryStream fallback,
and the .advanced guard are all verified end-to-end locally.What is not exercised in the suite is behavior that requires a live remote
Turso deployment: hosted authToken auth, embedded-replica syncUrl syncing,
and encryptionKey handling are wired through to @libsql/client but would
need a real Turso database (or a running libSQL server) to confirm
end-to-end.Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | turso |
|---|
| Transactions atomic | yes |
| Savepoints | yes |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only no; deferrable no; constraint deferral no; SQLite locking type |
| RETURNING | not emitted; returning is accepted and ignored |
| Upsert | INSERT … ON CONFLICT (…) DO UPDATE |
| JSON column | TEXT (JSONB → TEXT) |
| Vector column | F32_BLOB(3) |
| Array column | TEXT |
| WHERE builder | 32 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family sqlite: triggers yes; procedures no; functions driverUdf; materialized views emulated (app); sequences no; enums check; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Testing#
tests/dialects/turso.test.ts exercises the dialect with a mocked driver, so nothing touches a server.Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16