SQLite, libSQL and Cloudflare D1

Use NestJS with Drizzle on SQLite, libSQL (Turso) or Cloudflare D1. What works, what is a 400, and why D1 cannot run bulk writes or nested creates.

Since 3.7.0 the package runs on SQLite with dialect: "sqlite". You build the Drizzle instance with your SQLite driver and pass it as db; the module does not open SQLite connections itself. Reads, writes, filters, relations, upsert and keyset pages work as on PostgreSQL, with RETURNING. Full-text search, row locks and the PostgreSQL-only operators are a 400. Bulk writes need a driver with async transactions, such as libSQL.

Bulk writes need an async transaction

Mass writes, nested creates and attach / detach / sync run in an interactive transaction with savepoints. libSQL (@libsql/client) has one. Cloudflare D1 has none, and synchronous drivers (better-sqlite3, bun:sqlite, sql.js) cannot wait for async work inside theirs. On those, the calls throw a TransactionException (500) that names the reason instead of half working. The single-row methods are not affected.

How do I connect to SQLite or libSQL?

Install @libsql/client, build the Drizzle instance and pass it as db:

pnpm add @libsql/client
app.module.ts
import { Module } from '@nestjs/common';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { DrizzleCrudModule } from '@avytrix/nestjs-drizzle-crud';
import * as schema from './db/schema';

const client = createClient({
  url: process.env.DATABASE_URL!, // file:local.db, or libsql://... on Turso
  authToken: process.env.DATABASE_AUTH_TOKEN,
});

@Module({
  imports: [
    DrizzleCrudModule.forRoot({
      dialect: 'sqlite',
      db: drizzle(client, { schema }),
    }),
  ],
})
export class AppModule {}

A db you pass in is yours to close. connectionString is not accepted for "sqlite": it is a startup error that tells you to pass db.

How should I declare the SQLite tables?

Use sqliteTable from drizzle-orm/sqlite-core. Two details matter to the service:

  • Timestamps and soft delete need date columns. The service writes a JavaScript Date into created_at, updated_at and deleted_at, so declare them as integer('created_at', { mode: 'timestamp_ms' }) (or mode: 'timestamp'). A plain text column does not take a Date.
  • Foreign keys must be on. SQLite only enforces references with PRAGMA foreign_keys = ON. libSQL turns it on for its local databases; with another driver, run the pragma when you open the connection. Without it, a missing parent is not an error at all, so there is no 400 or 409 to map.
db/schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  authorId: integer('author_id').notNull().references(() => authors.id),
  createdAt: integer('created_at', { mode: 'timestamp_ms' }),
  updatedAt: integer('updated_at', { mode: 'timestamp_ms' }),
  deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }),
});

What works on SQLite, and what is a 400?

FeatureOn SQLite
find, findAll, count, countBy, findByCursorYes. Keyset pages follow SQLite's NULL order (first ascending, last descending)
Filters, relation filters, sort, select, relations, countsYes
Writes read back with RETURNINGYes, sql.useReturning defaults to true
search in ilike mode, ilike, iContains and friendsLIKE ... ESCAPE '\', so %, _ and \ in the term match literally
Case-sensitive like, contains, startsWithSQLite's LIKE ignores ASCII case, so these match case-insensitively for ASCII letters, as on MySQL's default collations
upsert, massUpsertON CONFLICT DO UPDATE, including the tenant scope guard
Nested creates, bulk writes, attach / detach / syncYes on libSQL; TransactionException on D1 and synchronous drivers
Optimistic locking, tenant scope, soft delete, eventsYes
fullTextSearch, search.mode: "fullText"400
lock, forNoKeyUpdate400. SQLite has no row locks
arrayContains, arrayOverlaps, jsonContains400

Driver errors map to HTTP the same way as on the other dialects: a UNIQUE or primary key violation is a 409 DuplicateEntityException, a FOREIGN KEY violation is a 400 on a write and a 409 on a delete, and NOT NULL or CHECK is a 400 ValidationFailedException. SQLite does not report the duplicate value, so the message names the column only.

How do I use Cloudflare D1?

Build the instance from the binding with drizzle-orm/d1 and pass it as db, with dialect: 'sqlite'. D1 rejects BEGIN and SAVEPOINT, so everything that needs a transaction throws a TransactionException before it sends a statement:

  • massCreate, massUpdate, massUpsert, massSoftDelete, massRestore, massDelete and massForceDelete
  • a create with nested relations
  • attach, detach and sync

The single-row methods (create, update, upsert, softDelete, restore, delete, every read) do not need one. For several writes that must succeed together on D1, call db.batch([...]) with Drizzle yourself.

What has been tested

The package's own suite runs every SQLite feature above against libSQL in memory. The D1 and synchronous driver refusals are tested against a stand-in object that reports itself the way those drivers do, not against D1 or better-sqlite3 themselves.

Common questions

Next steps

Last updated View change history

On this page