Cloudflare D1
D1 is SQLite at the edge, so entities and queries are the ones you would run on SQLite. What changes is the runtime: the database arrives as a binding on env, and D1 has hard limits. A runnable Worker with everything on this page is in examples/cloudflare-d1.
Query from a Worker
Section titled “Query from a Worker”{ "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 exists only inside a request, so build the pool there. It wraps env.DB, with nothing to connect, and end() does nothing. Import your entities module so the entities are registered:
import { D1QuerierPool } from 'uql-orm/d1';import { Todo } from './models';
export default { async fetch(request: Request, env: Env) { const pool = new D1QuerierPool(env.DB); return Response.json( await pool.findMany(Todo, { $where: { completed: false }, $limit: 50 }), ); },};For a read-replicated database, pass a session instead of env.DB; otherwise every query goes to the primary. A bookmark carries consistency across requests:
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() ?? '');No transactions
Section titled “No transactions”D1 rejects BEGIN, so UQL refuses pool.transaction(...) before sending anything, and so do the write routes of the HTTP core. A write UQL splits on its own, such as an insertMany past the 100-parameter cap or an upsertMany of rows of different shapes, goes as one batch(), which D1 runs as one transaction: it lands whole or not at all. When writes of your own must land together, make them one statement, make them idempotent, or move that work to a Durable Object.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Bound parameters per query | 100 (insert 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 |
D1 loads no extensions. Full-text search works through FTS5, but a $vector sort throws; use Vectorize for vectors.
Schema changes
Section titled “Schema changes”UQL writes the SQL and wrangler applies it, since the binding exists only in a Worker and D1 refuses the transaction uql-migrate up opens. Point the CLI at wrangler’s local copy of the database:
import { existsSync, readdirSync } from 'node:fs';import { join } from 'node:path';import type { Config } from 'uql-orm';import { NodeSqliteQuerierPool } from 'uql-orm/sqlite';import { Todo } from './src/models';
function localD1File(): string { const dir = '.wrangler/state/v3/d1/miniflare-D1DatabaseObject'; const files = existsSync(dir) ? readdirSync(dir).filter( (name) => name.endsWith('.sqlite') && name !== 'metadata.sqlite', ) : []; if (files.length > 1) throw new Error( `Expected one local D1 database in ${dir}: delete the stale ones.`, ); return files[0] ? join(dir, files[0]) : ':memory:';}
export default { pool: new NodeSqliteQuerierPool(localD1File()), entities: [Todo],} satisfies Config;Then, for each entity change (with tsx installed, as the CLI needs on Node):
npx wrangler d1 migrations create my-app add_todosnpx uql-migrate sync --dry-run >> migrations/0001_add_todos.sqlnpx wrangler d1 migrations apply my-app --local # then --remotesync --dry-run prints only SQL on stdout, with notes on stderr, so the file gets only SQL.
D1 keeps foreign keys on. A change SQLite makes by rebuilding the table fails when another table references it, so write that migration by hand, rebuilding the referencing tables too.
Serving reads over HTTP
Section titled “Serving reads over HTTP”createFetchHandler mounts in a Worker. Build it per request, since the binding arrives with the request; its write routes cannot work on D1:
import { createFetchHandler } from 'uql-orm/http';
export default { fetch(request: Request, env: Env) { return createFetchHandler({ pool: new D1QuerierPool(env.DB), include: [Todo], basePath: '/api', })(request); },};For SQLite with transactions on the same runtime, use Turso; for Postgres, Hyperdrive.