Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

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.

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 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:

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);
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() ?? '');

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.

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

D1 loads no extensions. Full-text search works through FTS5, but a $vector sort throws; use Vectorize for vectors.

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:

uql.config.ts
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):

Terminal window
npx wrangler d1 migrations create my-app add_todos
npx uql-migrate sync --dry-run >> migrations/0001_add_todos.sql
npx wrangler d1 migrations apply my-app --local # then --remote

sync --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.

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.