Skip to content
UQL

Cloudflare D1

D1 is SQLite at the edge, so entities, queries and generated SQL are the ones you would run on SQLite. What changes is the runtime around it: the database arrives as a binding on env, and D1 has hard limits the dialect knows about.

wrangler.jsonc
{
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"d1_databases": [
{ "binding": "DB", "database_name": "my-app", "database_id": "<id>" },
],
}

The binding only exists inside a request, so build the pool there. It is a thin wrapper over env.DB: nothing to connect, and end() is a no-op.

src/models.ts
import { Entity, Id, Field } from 'uql-orm';
@Entity()
export class Todo {
@Id({ type: Number }) id?: number;
@Field({ type: String }) title?: string | null;
@Field({ type: Boolean }) completed?: boolean | null;
}
src/index.ts
import { D1QuerierPool } from 'uql-orm/d1';
import { Todo } from './models';
export default {
async fetch(request: Request, env: Env) {
const pool = new D1QuerierPool(env.DB);
const todos = await pool.findMany(Todo, {
$where: { completed: false },
$limit: 50,
});
return Response.json(todos);
},
};

Importing the entities module is what registers them, so keep that import even where a route does not name every entity.

A replicated database is read through D1’s Sessions API; without it every query goes to the primary. Hand the pool a session, and every query of the request reads data at least as new as the writes before it. A bookmark carries that across requests:

src/index.ts
export default {
async fetch(request: Request, env: Env) {
const session = env.DB.withSession(
request.headers.get('x-d1-bookmark') ?? 'first-unconstrained',
);
const pool = new D1QuerierPool(session);
const response = Response.json(await pool.findMany(Todo, { $limit: 50 }));
response.headers.set('x-d1-bookmark', session.getBookmark() ?? '');
return response;
},
};

D1 rejects BEGIN TRANSACTION with D1_ERROR: not authorized; a single statement is its only atomic unit. So pool.transaction(...) cannot work there, and UQL refuses one before sending anything, as it does the write routes of the HTTP core, which wrap every write in a transaction. Reads and single-statement writes work normally.

When several writes must land together, model them as one statement, make them idempotent, or move that workload to a Durable Object, whose storage API does have transactions.

Limit Value
Bound parameters per query 100 (insertMany chunks to fit)
Arguments per function call 32 (wide calls are split to fit)
Value or row size 2 MB, a populated relation included
SQL statement length 100 KB
Query duration 30 s
Database size 10 GB on the paid plan

The parameter cap is the one that surprises people: 655 times smaller than Postgres’, so a bulk insert that is one statement elsewhere becomes many here.

D1 also loads no extensions. FTS5 is available, so full-text search works through an FTS5 virtual table, but there are no vector functions and sqlite-vec cannot be loaded: a $vector sort throws, pointing at Vectorize.

The migrator needs a pool and a D1 binding only exists inside a Worker, so generate the DDL against a local SQLite pool and apply it with Wrangler. The SQL is identical; D1’s dialect differs only in the limits above.

Terminal window
npx uql-migrate sync --dry-run # prints the SQL for your entities
npx wrangler d1 migrations create my-app add_todos
npx wrangler d1 migrations apply my-app --remote

createFetchHandler mounts natively. The binding arrives with the request rather than at module scope, and its write routes cannot work on D1:

import { createFetchHandler } from 'uql-orm/http';
export default {
fetch(request: Request, env: Env) {
const handler = createFetchHandler({
pool: new D1QuerierPool(env.DB),
include: [Todo],
basePath: '/api',
});
return handler(request);
},
};

For full SQLite semantics from the same runtime, including transactions, use Turso; for Postgres, Hyperdrive.