> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# Cloudflare D1

> Run UQL on Cloudflare Workers with D1, including its limits and the transaction it does not have.

Source: https://uql-orm.dev/cloudflare-d1

D1 is SQLite at the edge, so entities, queries and generated SQL are the ones you would run on [SQLite](https://uql-orm.dev/sqlite.md). What changes is the runtime around it: the database arrives as a binding on `env`, and D1 has hard limits the dialect knows about.

```jsonc title="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>" },
  ],
}
```

## Query from a Worker

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.

```ts title="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;
}
```

```ts title="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.

## Read replication

A replicated database is read through D1’s [Sessions API](https://developers.cloudflare.com/d1/best-practices/read-replication/); 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:

```ts title="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 has no transactions

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](https://uql-orm.dev/http.md), 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](https://developers.cloudflare.com/durable-objects/), whose storage API does have transactions.

## Limits

| 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](https://uql-orm.dev/querying/full-text.md) 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](https://developers.cloudflare.com/vectorize/).

## Schema changes

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.

```sh
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
```

## Serving reads over HTTP

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

```ts
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](https://uql-orm.dev/turso.md); for Postgres, [Hyperdrive](https://uql-orm.dev/postgres.md#cloudflare-hyperdrive).
