zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Databases

Turso / libSQLSupported

Dialect: 'sqlite'. Turso is libSQL — SQLite with a server, replicas and an HTTP API — so the SQLite dialect applies, including its type conversions.

Setup#

import { createClient, type InValue } from '@libsql/client';
import { type Driver } from '@zmdb/orm';

const client = createClient({
  url: requireEnv('TURSO_DATABASE_URL'),
  authToken: process.env.TURSO_AUTH_TOKEN,
});

function toInValue(value: unknown): InValue {
  if (value === null || value === undefined) return null;
  if (typeof value === 'string' || typeof value === 'number') return value;
  if (typeof value === 'bigint' || typeof value === 'boolean') return value;
  if (value instanceof Date || value instanceof Uint8Array) return value;
  return JSON.stringify(value); // json columns arrive as objects
}

export const driver: Driver = {
  async execute(query) {
    const result = await client.execute({
      sql: query.text,
      args: query.parameters.map(toInValue),
    });
    return result.rows.map(row => Object.fromEntries(result.columns.map((name, index) => [name, row[index]])));
  },
};

requireEnv(name) is the three-line helper from Configuration — it throws on a missing or empty variable, so a misconfigured deployment fails at boot rather than on the first query.

Two boundary details, both of which other libSQL snippets paper over with a cast. query.parameters is readonly unknown[], and libSQL's InValue is a closed union — so the conversion is a real narrowing, and the JSON.stringify fallback is the decision about what happens to a JSON column value rather than a runtime surprise. And a libSQL Row is an array-like with numeric _and_ named access; rebuilding it against result.columns gives you a plain Record<string, unknown>, which is what Driver.execute promises to return.

Works in Node, Bun, Deno, Cloudflare Workers and Vercel Edge, because it is fetch underneath.

Embedded replicas#

This is Turso's distinguishing feature: a local SQLite file kept in sync with the remote, so reads are local-disk fast and writes go to the primary.

const client = createClient({
  url: 'file:local.db',
  syncUrl: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
  syncInterval: 60,
});

The consequence to internalise: reads are eventually consistent. A write followed immediately by a read may not see it, because the read went to the local replica. If a request writes and then reads back, sync explicitly:

await repo.create(dto);
await client.sync();
const row = await repo.findOne({ email: { eq: dto.email } });

Or use the RETURNING clause so the write itself gives you the row — libSQL supports it, so repo.create gets the row back in one statement and no read-after-write question arises. That is the better answer.

Type conversion#

libSQL returns SQLite's storage classes, so boolean, timestamp and json need hydrating exactly as on local SQLite:

const hydrate = (r: Record<string, unknown>) => ({
  ...r,
  active: r.active === undefined ? undefined : Boolean(r.active),
  createdAt: typeof r.createdAt === 'string' ? new Date(r.createdAt) : r.createdAt,
});

Note that libSQL may return bigint for large integers rather than number, which will fail a validator check against a number-typed field — correctly. See bigint keys.

Batches and transactions#

libSQL has a batch API that sends several statements in one round trip, which matters over HTTP:

await client.batch(
  [
    { sql: q1.text, args: q1.parameters.map(toInValue) },
    { sql: q2.text, args: q2.parameters.map(toInValue) },
  ],
  'write',
);

Compile the statements with the builder and hand over text/parameters. For interactive transactions, client.transaction() holds a session:

const tx = await client.transaction('write');
try {
  await tx.execute({ sql: q.text, args: q.parameters.map(toInValue) });
  await tx.commit();
} catch (e) {
  await tx.rollback();
  throw e;
}

Wrap that as a Driver and pass it to createTransactionalDb. See Transactions.

Multi-tenancy by database#

Turso's model makes a database-per-tenant genuinely practical, which sidesteps the entity-filter problem entirely:

const clientFor = (tenant: string) =>
  createClient({
    url: `libsql://${tenant}-myorg.turso.io`,
    authToken: process.env.TURSO_AUTH_TOKEN,
  });

const repo = defineRepository(users, driverFor(clientFor(tenant)));

The cost is that migrations must run against every tenant database. up takes a connection, so that is a loop — and a loop that must be resumable, because failing halfway through a thousand tenants is a state you have to recover from. Record progress.

---

See also: Dialect: SQLite · Connect: SQLite · Entity Filters