Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@objectstack/driver-turso

Turso/libSQL driver for ObjectStack — edge-first SQLite with embedded replicas and cloud-only remote mode.

Architecture

TursoDriver implements a dual-transport architecture:

  • Local/Replica modes: Extends SqlDriver from @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, raw execute, schema sync and dropTable to RemoteTransport, which uses the @libsql/client SDK 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)

Installation

pnpm add @objectstack/driver-turso

Dependencies by Mode

The 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 required

For local/replica modes:

pnpm add @objectstack/driver-turso better-sqlite3

The 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.

Connection Modes

Local File (Embedded SQLite)

import { TursoDriver } from '@objectstack/driver-turso';

const driver = new TursoDriver({
  url: 'file:./data/app.db',
});
await driver.connect();

In-Memory (Testing)

const driver = new TursoDriver({
  url: ':memory:',
});
await driver.connect();

Embedded Replica (Hybrid)

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();

Remote (Cloud-Only)

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 } });

What remote mode refuses

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 call beginTransaction(): it runs the callback with no transaction and logs a warning once per datasource, or, called with require: true, throws TransactionUnsupportedError before the callback runs.
  • Aggregation. aggregate() called on the driver directly also refuses, with the same code, a groupBy entry that has a dateGranularity, and an aggregations entry with a non-empty filter. 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-aggregation filter.

Record numbers in remote mode

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 a SELECT DISTINCT on 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 as true / false), and an unknown column refused with INVALID_FIELD / 400. A tenant-scoped call is refused (table above), because no remote read applies the tenant scope.
  • reclaimSpace() reads PRAGMA freelist_count from the remote database and, when there are free pages, runs the statement local mode issues, PRAGMA incremental_vacuum, to completion there (through the client's executeMultiple()). It returns free pages only on a database whose auto_vacuum mode is INCREMENTAL: local mode sets that mode when it connects, and remote mode does not. A server that refuses either statement answers DATABASE_ERROR / 500.
  • supportsRotation is false. The lifecycle service reads it, and for an object that declares lifecycle.storage.strategy: 'rotation' it then takes the path it has for a driver that cannot shard: an age-based reap bounded by the same shards × unit window, instead of rotating shard tables.
  • setFileColumnsMovedResolver() returns false ("not taken"). Remote mode writes media columns in the JSON encoding and never asks the resolver.
  • getSchemaSyncStats() answers { created: 0, existing: 0 }, which the IDataDriver contract reads as "cannot say": remote schema sync does not count the tables it creates.

Auto-Detection

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), a url that is none of file:, :memory: or a remote url, such as a bare path or an unsupported scheme. The fix for a local database file is url: 'file:./data/app.db';
  • a remote url (libsql://, https://, http://, wss://, ws://, in any letter case) beside syncUrl, or under a forced mode: 'local' / 'replica';
  • a replica on an in-memory url (:memory:, file::memory:). That covers a replica auto-detected from a :memory: or file: url beside syncUrl, and one forced with mode: '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:

  • syncUrl under a forced mode: 'remote', where the remote client never receives it. For a remote database, drop syncUrl (and sync);
  • sync with no syncUrl (or an empty one), in any mode, where nothing reads it. Set syncUrl, or remove sync;
  • a forced mode: 'replica' with no syncUrl (or an empty one), which would never sync and would run as a plain local database. Name the remote in syncUrl beside the file: url, or drop mode for a local database;
  • a forced mode: 'local' beside a non-empty syncUrl, which would still be synced with that remote as an embedded replica, so the declared local mode would be ignored. Drop mode for an embedded replica, or drop syncUrl (and sync) 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
});

Custom Client

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.

Multi-Tenant Routing

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.

Configuration

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;
}

Capabilities

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.

Plugin Registration

import tursoPlugin from '@objectstack/driver-turso';

// Via plugin system
await kernel.enablePlugin(tursoPlugin, {
  url: 'file:./data/app.db',
});

Testing

pnpm test        # Run all tests

Tests run against in-memory SQLite (:memory:) — no external services required.

License

Apache-2.0. See LICENSING.md.