Turso/libSQL driver for ObjectStack — edge-first SQLite with embedded replicas and cloud-only remote mode.
TursoDriver implements a dual-transport architecture:
- Local/Replica modes: Extends
SqlDriverfrom@objectstack/driver-sql. All CRUD, schema, filtering, aggregation, window functions, introspection, and transactions are inherited via Knex + better-sqlite3. - Remote mode: Sends CRUD, bulk writes,
aggregate, rawexecute, schema sync anddropTabletoRemoteTransport, which uses the@libsql/clientSDK directly (HTTP/WebSocket). Transactions and the other calls listed under What remote mode refuses are refused instead.
TursoDriver extends SqlDriver (dual transport)
├── Transport: local/replica (via Knex + better-sqlite3)
│ ├── Inherited: find, findOne, create, update, delete, count, upsert
│ ├── Inherited: bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany
│ ├── Inherited: syncSchema, dropTable, introspectSchema
│ ├── Inherited: aggregate, distinct, findWithWindowFunctions
│ ├── Inherited: beginTransaction, commit, rollback
│ └── Inherited: applyFilters (MongoDB-style)
├── Transport: remote (via @libsql/client)
│ ├── RemoteTransport: find, findOne, create, update, delete, count, upsert
│ ├── RemoteTransport: bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany
│ ├── RemoteTransport: syncSchema, dropTable
│ ├── RemoteTransport: execute (raw SQL)
│ └── Refused, NOT_IMPLEMENTED / 501: beginTransaction, commit, rollback,
│ setDeferredDdl(true), detectManagedDrift, planMediaColumnMove
├── Override: name, version, supports (Turso-specific capabilities)
├── Override: connect / disconnect (transport-aware lifecycle)
├── Added: transportMode ('local' | 'replica' | 'remote')
├── Added: sync() — Embedded replica sync via @libsql/client
└── Added: TursoDriverConfig (url, authToken, syncUrl, mode, client)
pnpm add @objectstack/driver-tursoThe driver-turso package has different dependency requirements based on the connection mode:
| Mode | Required Dependencies | Notes |
|---|---|---|
| Remote | @libsql/client only |
✅ Vercel/Edge compatible — no native dependencies |
| Local | @libsql/client + better-sqlite3 |
Requires better-sqlite3 for local SQLite access |
| Replica | @libsql/client + better-sqlite3 |
Requires better-sqlite3 for local SQLite + sync |
For Vercel/Edge deployments (remote mode only):
pnpm add @objectstack/driver-turso
# better-sqlite3 is NOT requiredFor local/replica modes:
pnpm add @objectstack/driver-turso better-sqlite3The better-sqlite3 package is an optional peer dependency. If you're only using remote mode (e.g., on Vercel), you don't need to install it. npm/pnpm will show a warning that can be safely ignored.
import { TursoDriver } from '@objectstack/driver-turso';
const driver = new TursoDriver({
url: 'file:./data/app.db',
});
await driver.connect();const driver = new TursoDriver({
url: ':memory:',
});
await driver.connect();Local SQLite file + automatic sync from Turso cloud:
const driver = new TursoDriver({
url: 'file:./data/replica.db',
syncUrl: 'libsql://my-db-orgname.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
sync: {
intervalSeconds: 60, // sync every 60 seconds
onConnect: true, // sync on initial connect
},
});
await driver.connect();
// Manual sync
await driver.sync();Pure remote queries via @libsql/client — no local SQLite needed.
Ideal for Vercel, Cloudflare Workers, and other serverless/edge runtimes:
const driver = new TursoDriver({
url: 'libsql://my-db-orgname.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
});
await driver.connect();
// The CRUD methods are the same as in local mode; some calls are refused (below)
const users = await driver.find('users', { where: { active: true } });Each call below is refused in remote mode with code: 'NOT_IMPLEMENTED' and
status: 501: the call is valid, and the remote transport does not have the
capability. The check runs before the call reads or writes anything. Every other
public SqlDriver method is answered in remote mode; the section after the
table lists the ones that answer differently from local mode.
| Operation | Refused call | Use instead |
|---|---|---|
| Transactions | beginTransaction(), commit(), rollback() (and their deprecated aliases commitTransaction() and rollbackTransaction()), and options.transaction passed to any RemoteTransport method in the tree above, to aggregate() or to syncSchemasBatch() |
The local or embedded-replica transport, which run Knex transactions and honour options.transaction |
| Deferring schema DDL | setDeferredDdl(true), which os migrate plan calls. setDeferredDdl(false) is accepted |
Run the command against a local SQLite copy of the database (a file: URL) |
| Schema drift detection | detectManagedDrift() |
os migrate plan against a local SQLite copy of the database (a file: URL) |
| Planning the ADR-0104 media column move | planMediaColumnMove(), the column step of os migrate files-to-references |
The local or embedded-replica transport, which plan it |
| Applying drift entries | applyMigrationEntries(), with or without entries |
An ordinary boot against the datasource (os serve / os start) performs the additive schema sync |
| Schema introspection | introspectSchema(), which the datasource connection test and the federation validation sweep call |
A datasource pointed at a local SQLite copy of the database (a file: URL) or at an embedded replica |
| Window-function reads | findWithWindowFunctions() |
The local or embedded-replica transport, or the window statement sent through execute() |
| Query plans | explain(), analyzeQuery() |
The same query against the local or embedded-replica transport |
| Shard rotation (ADR-0057) | rotateShards() |
The local or embedded-replica transport. In remote mode the lifecycle service enforces the same window with an age-based reap (see below) |
| The Knex instance | getKnex(): remote mode builds Knex with no connection |
execute(), or the libSQL client from getLibsqlClient() |
| Tenant-scoped distinct values | distinct() with options.tenantId on an object that has a tenant column |
The same call without tenantId, or the local or embedded-replica transport |
- Transactions. Remote mode declares
supports.transactionsUnsupported: true. When the remote driver is the engine's default datasource,engine.transaction()reads that and does not callbeginTransaction(): it runs the callback with no transaction and logs a warning once per datasource, or, called withrequire: true, throwsTransactionUnsupportedErrorbefore the callback runs. - Aggregation.
aggregate()called on the driver directly also refuses, with the same code, agroupByentry that has adateGranularity, and anaggregationsentry with a non-emptyfilter.engine.aggregate()never sends either one to this driver: it fetches the rows and computes both in memory. The local transport also refuses a per-aggregationfilter.
An autonumber field a row leaves empty (undefined, null or '') is
filled in remote mode as it is in local and embedded-replica mode: create(),
bulkCreate() and upsert() issue the next value from the persistent
_objectstack_sequences counter, rendered with the field's autonumberFormat
(or format) and scoped per organization, per date token and per {field}
token exactly as SqlDriver scopes it. The counter moves in one statement over
the connection (UPDATE … RETURNING; on a cold counter, a bootstrap from the
table's highest existing value followed by INSERT … ON CONFLICT DO UPDATE … RETURNING), so two processes writing to one database never draw the same
number. A row that already carries a value keeps it (a seed replay or an
import); an upsert() that merges into an existing row keeps the number
already in the row. A _objectstack_sequences table in the legacy shape
(created by a local-mode driver older than the key_hash column) is refused
with code: 'DATABASE_ERROR' / status: 500 and the remedy in the message;
remote mode does not migrate it.
Answered in remote mode, differently from local mode:
distinct()runs aSELECT DISTINCTon the remote database and answers the values local mode answers for the same rows: the same filter, the same presentation (a declared boolean reads back astrue/false), and an unknown column refused withINVALID_FIELD/ 400. A tenant-scoped call is refused (table above), because no remote read applies the tenant scope.reclaimSpace()readsPRAGMA freelist_countfrom the remote database and, when there are free pages, runs the statement local mode issues,PRAGMA incremental_vacuum, to completion there (through the client'sexecuteMultiple()). It returns free pages only on a database whoseauto_vacuummode isINCREMENTAL: local mode sets that mode when it connects, and remote mode does not. A server that refuses either statement answersDATABASE_ERROR/ 500.supportsRotationisfalse. The lifecycle service reads it, and for an object that declareslifecycle.storage.strategy: 'rotation'it then takes the path it has for a driver that cannot shard: an age-based reap bounded by the sameshards×unitwindow, instead of rotating shard tables.setFileColumnsMovedResolver()returnsfalse("not taken"). Remote mode writes media columns in the JSON encoding and never asks the resolver.getSchemaSyncStats()answers{ created: 0, existing: 0 }, which theIDataDrivercontract reads as "cannot say": remote schema sync does not count the tables it creates.
Transport mode is automatically detected from the URL:
| URL Pattern | Mode | Engine |
|---|---|---|
file:./data/app.db |
local |
Knex + better-sqlite3 |
:memory: |
local |
Knex + better-sqlite3 |
file:... + syncUrl |
replica |
Knex + @libsql/client sync |
libsql://... |
remote |
@libsql/client only |
https://... |
remote |
@libsql/client only |
http://, wss:// and ws:// are remote too. A scheme matches in any letter
case, as it does in @libsql/client: LIBSQL://... is remote and FILE:... is
a local file. A path to a local database file needs the file: prefix. A bare
path such as ./data/app.db is not a url, and @libsql/client refuses it too.
An embedded replica is a local file kept in sync with a remote. The local and
replica modes run every read and write through the local SQLite engine, which
can open only a file: url or :memory:, and a replica needs a file for the
sync to land in. The constructor therefore refuses (VALIDATION_ERROR / 400):
- in a local or replica mode (auto-detected, with or without
syncUrl, or forced), aurlthat is none offile:,:memory:or a remote url, such as a bare path or an unsupported scheme. The fix for a local database file isurl: 'file:./data/app.db'; - a remote url (
libsql://,https://,http://,wss://,ws://, in any letter case) besidesyncUrl, or under a forcedmode: 'local'/'replica'; - a replica on an in-memory url (
:memory:,file::memory:). That covers a replica auto-detected from a:memory:orfile:url besidesyncUrl, and one forced withmode: 'replica'.
In each case the engine would otherwise run on a private in-memory database
whose writes read back and then vanish on restart, and @libsql/client builds
no embedded replica for a remote url anyway. For a remote database, drop
syncUrl and any forced mode. For an embedded replica, use
url: 'file:./data/replica.db' beside syncUrl. A forced mode: 'remote' runs
no local engine, so its url is not judged here: @libsql/client refuses a
url it cannot open when the driver connects.
The constructor also refuses (VALIDATION_ERROR / 400) four sync settings
that nothing would honour, each with the message @objectstack/spec's
TursoConfigSchema gives at authoring:
syncUrlunder a forcedmode: 'remote', where the remote client never receives it. For a remote database, dropsyncUrl(andsync);syncwith nosyncUrl(or an empty one), in any mode, where nothing reads it. SetsyncUrl, or removesync;- a forced
mode: 'replica'with nosyncUrl(or an empty one), which would never sync and would run as a plain local database. Name the remote insyncUrlbeside thefile:url, or dropmodefor a local database; - a forced
mode: 'local'beside a non-emptysyncUrl, which would still be synced with that remote as an embedded replica, so the declared local mode would be ignored. Dropmodefor an embedded replica, or dropsyncUrl(andsync) for a plain local database.
You can also force a specific mode:
const driver = new TursoDriver({
url: 'libsql://my-db.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
mode: 'remote', // Force remote mode
});Pass a pre-configured @libsql/client instance for advanced use cases
(custom caching, connection pooling, testing):
import { createClient } from '@libsql/client';
const client = createClient({
url: 'libsql://my-db.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
});
const driver = new TursoDriver({
url: 'libsql://my-db.turso.io',
client, // Inject pre-configured client
});
await driver.connect();In remote mode a pre-configured client may not be combined with a non-zero
timeout: the driver installs that window as the fetch it hands
@libsql/client while creating the client, so it has no way to apply it to one
you built yourself, and the constructor refuses the pair
(VALIDATION_ERROR / 400) instead of accepting a bound it cannot deliver. Build
the bound into your own client if you need both:
const client = createClient({
url: 'libsql://my-db.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
fetch(input, { ...init, signal: AbortSignal.timeout(30_000) }),
});Replica mode is unaffected — sync(), the one remote operation on that arm, is
bounded by timeout whatever client is in use.
Not shipped by this package. Database-per-tenant routing on top of
TursoDriver is a cloud product capability and lives in the closed
objectstack-ai/cloud repository (objectstack#4645 decision 2). This package
ships the driver; a router that maps a tenant to a TursoDriver instance is
layered above it and is not part of the open Apache-2.0 surface.
interface TursoDriverConfig {
/**
* Database URL.
* - file:./data/local.db → local mode
* - :memory: → local mode (ephemeral)
* - libsql://my-db.turso.io → remote mode
* - https://my-db.turso.io → remote mode
* Schemes match in any letter case. In a local or replica mode any other
* url (a bare path, an unsupported scheme) is refused at construction
* (VALIDATION_ERROR / 400): spell a local file as file:./data/app.db.
*/
url: string;
/** JWT auth token for the remote Turso database */
authToken?: string;
/**
* AES-256 encryption key for local database file.
* Only effective in local/replica modes.
*/
encryptionKey?: string;
/**
* Maximum concurrent requests to the remote database.
* Effective in replica and remote modes.
* Default: 20
*/
concurrency?: number;
/** Remote sync URL for embedded replica mode (libsql:// or https://); the replica is the local file: named by `url` */
syncUrl?: string;
/** Sync configuration (requires syncUrl) */
sync?: {
intervalSeconds?: number; // Default: 60
onConnect?: boolean; // Default: true
};
/**
* Operation timeout in milliseconds for remote operations.
* Effective in replica and remote modes; 0 or unset = no bound.
* - Remote mode over HTTP (libsql:// / https:// / http://): every request the
* client makes is aborted once the window elapses, and the operation fails
* as TIMEOUT / 504 instead of hanging — when THIS driver creates the
* client. Two remote compositions cannot carry the window, and both are
* REFUSED at construction (VALIDATION_ERROR / 400) rather than accepted and
* never delivered:
* - a wss:// or ws:// URL, which uses the WebSocket transport and has no
* such seam: drop the key, or use a libsql:// / https:// URL;
* - a pre-configured `client`, which arrives with its transport already
* built: drop `client`, or drop `timeout` and build the bound into that
* client yourself.
* - Replica mode: bounds sync(), the one remote operation on that arm. A
* sync still running when the window closes rejects with the same
* envelope; the native binding's own sync is not cancelled, only no longer
* awaited.
* Not the libSQL busy timeout (`Config.timeout`), which is a local-file
* lock-contention setting that remote clients ignore.
*/
timeout?: number;
/**
* Force a specific transport mode.
* If not set, mode is auto-detected from the URL.
*/
mode?: 'local' | 'replica' | 'remote';
/**
* Pre-configured @libsql/client instance.
* Useful for custom caching, connection pooling, or testing.
* In REMOTE mode it may not be combined with a non-zero `timeout` — the
* constructor refuses that pair (VALIDATION_ERROR / 400), because the window
* is installed while creating the client and a client the driver did not
* create cannot carry it. Replica mode is unaffected: sync() is bounded
* whatever client is in use.
*/
client?: Client;
}The capabilities TursoDriver declares are its supports getter, and it
declares only the flags the engine acts on: everything SqlDriver declares,
plus batchSchemaSync: true. Remote mode also declares
transactionsUnsupported: true and no native date bucketing
(queryDateGranularity: {}). It declares nothing about the SQLite features in
the table below. That table records what each mode was measured to do; the
engine does not read it.
| Feature | Local and embedded replica | Remote |
|---|---|---|
FTS5, JSON1 and common table expressions, through execute() |
✅ | ✅ |
Declared indexes (indexes, and a field's unique) |
✅ | ✅ |
| Savepoints inside a transaction | ✅ (Knex transactions) | ❌ (transactions are refused) |
Raw SAVEPOINT statements through execute() |
✅ | ✅ through a file: client; not measured against a server |
| Window functions | ✅ (findWithWindowFunctions(), or execute()) |
Through execute() only |
Schema introspection (introspectSchema()) |
✅ | ❌ (refused) |
| Embedded replica sync | ✅ (replica) | — |
| No native module (serverless / edge) | — | ✅ |
The remote column was measured through a libSQL file: client, which runs
the same SQLite engine as a Turso server but not its network protocol.
import tursoPlugin from '@objectstack/driver-turso';
// Via plugin system
await kernel.enablePlugin(tursoPlugin, {
url: 'file:./data/app.db',
});pnpm test # Run all testsTests run against in-memory SQLite (:memory:) — no external services required.
Apache-2.0. See LICENSING.md.