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

HTTP (any framework)

uql-orm/http serves your entities as a REST API, with routing, query parsing, transactions and authorization hooks built in. Your framework only forwards the request. The browser client calls it with the same query you write on the server: one query, every transport.

import { createFetchHandler } from 'uql-orm/http';
import { Post, User } from './shared/models/index.js';
import { pool } from './uql.config.js';
const handler = createFetchHandler({ pool, include: [User, Post] });

createFetchHandler returns a web-standard (request: Request) => Promise<Response>. Express takes querierMiddleware instead, and any other framework createRequestHandler, which takes a plain request object (see Fastify). All three take the same options; only the fetch handler adds basePath.

include is required and lists the only entities a request can reach. A $populate, filter, sort or write that leads through a relation to an entity outside it is a 400, so an entity holding secrets stays off the wire unless you list it. Relations a hook adds are allowed.

Runtime Mount basePath
Hono, Elysia app.mount('/api', handler) no
Bun.serve { fetch: handler } or { routes: { '/api/*': handler } } for the wildcard only
Cloudflare Workers export default { fetch: handler } no
Next.js, Astro, React Router, TanStack Start a catch-all route yes
Nitro / h3 fromWebHandler(handler); on h3 v2, (event) => handler(event.req) yes

basePath is the prefix to strip, for routers that keep it in the URL: createFetchHandler({ pool, include: [User], basePath: '/api/uql' }).

The handler runs on the pool you pass. Usually one module builds it and is also what uql-migrate reads:

uql.config.ts
import type { Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import { Post, User } from './shared/models/index.js';
export const pool = new PgQuerierPool({
connectionString: process.env.DATABASE_URL,
});
export default { pool, entities: [User, Post] } satisfies Config;

For a database per tenant, pass a function instead. It gets the request and the context getContext resolved, and runs only after the hooks pass:

const handler = createFetchHandler({
include: [User, Post],
getContext: (request) => ({ tenantId: tenantOf(request) }),
pool: (_request, { tenantId }) => poolFor(tenantId as string),
});

Each entity is served at its kebab-cased class name, so UserProfile lives at /user-profile:

Operation Method Endpoint Body
findMany GET /user
findManyPage GET /user/page
findOne GET /user/one
count GET /user/count
findOneById GET /user/:id
insertOne POST /user object
insertMany POST /user/many array
saveOne PUT /user object
saveMany PUT /user/many array
updateMany PATCH /user object
updateOneById PATCH /user/:id object
deleteOneById DELETE /user/:id
deleteMany DELETE /user

Reads take the query in the query string, one JSON value per clause: ?$where={"status":"active"}&$limit=20. Add count=true to findMany for the total, and give a page a $limit. HEAD answers like GET, and :id matches whichever field carries .id().

JSON holds no Date or bigint, so a bigint travels as its digits and a Date as its ISO text. The handler reads a write’s body and a query’s filters back by the entity, and the browser client reads responses the same way, so a date stays a date and a wide integer exact on every engine.

Each write runs in a transaction. Updates and deletes answer how many rows they changed. A delete soft-deletes where the entity can, unless you add hardDelete=true, and a delete that names no rows is refused. $lock is refused too, since each request commits on its own.

Every response is one envelope, and an error’s code mirrors the HTTP status:

{ "data": ..., "count": 3 }
{ "error": { "message": "forbidden", "code": 403 } }

A malformed query or body is a 400, and so is a $ key the query does not take, such as a misspelt clause or $lock, rather than being dropped and answering another read. So is a write optimistic locking refuses. A security filter refusing is a 403, and a stale version a 409. A duplicate or a missing referenced row answers 409 Conflict, and a not-null or check violation 400 Bad Request, never with the driver’s message, which would leak table names and values (error kinds). An error with a numeric status, such as one a hook throws, keeps it. Anything else is a 500.

The route table is exported as CRUD_ROUTES, and the browser client builds its URLs from it.

QUERY is a second transport for the four reads without an :id. It works like GET, but the query travels in a JSON body, so a large $where never hits a URL-length limit.

The handler accepts it, but your framework must route it. Hono and Elysia .mount(), Bun.serve, Express’s querierMiddleware, fastify.all from Fastify 5.11, a TanStack Start ANY handler, and an Astro ALL export or src/fetch.ts all do. Next.js route handlers and React Router resource routes take the standard verbs only. The browser client sends GET unless you opt in.

Hooks run before the database is touched and can be async. Each receives the framework’s request as context. Throw to abort; a numeric status on the error becomes the response status.

const handler = createFetchHandler({
pool,
include: [User],
async pre({ context }) {
if (!(await authenticate(context.headers.get('authorization')))) {
throw Object.assign(new Error('unauthorized'), { status: 401 });
}
},
preSave(ctx) {
ctx.body = { ...(ctx.body as object), updatedAt: Date.now() };
},
post(_ctx, envelope) {
envelope.data = (envelope.data as User[]).map(
({ password, ...rest }) => rest,
);
},
});

pre runs before every operation, preSave before POST, PUT and PATCH, and preFilter before GET and DELETE. Their context (RequestHookContext) carries meta, op, method, the parsed query and a reassignable body. Type a hook declared on its own as RequestHook<Request>, with your framework’s request type.

post runs after the operation commits and receives the success envelope, so it can strip what a $select cannot. The handler above removes each user’s password from the response.

Don’t scope tenants by adding to $where in preFilter: that misses joined relations and does not fail closed. Pass getContext and declare a security filter instead. The whole request then runs inside withContext, so every query in it is scoped and the client cannot opt out:

const handler = createFetchHandler({
pool,
include: [Invoice],
getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // from a verified session or JWT
});

The filter guards writes as well: a POST for another tenant is a 403, and a PUT onto another tenant’s row a 409. See Multi-tenancy for the filter itself.

The handler serves single-entity CRUD. Other paths are a 404 from createFetchHandler and fall through to next() in Express, so your own routes can share the prefix. Write those for multi-entity transactions, aggregations, raw SQL, uploads and streaming.