HTTP (any framework)
uql-orm/http turns your entities into a REST API without tying you to a web framework. It owns the route table, the request/response envelopes, query (de)serialization, querier lifecycle, transactions, and authorization hooks. Adapters are thin bindings on top:
createFetchHandlerreturns a web-standard(request: Request) => Promise<Response>.uql-orm/expressbinds the same core to Express 5.createRequestHandlertakes a normalized request object, for frameworks that are neither (see Fastify).
This whole layer is optional; UQL works as a standalone ORM without it. The query you serve here is the one you write on the server and send from the browser: one query, every transport.
Mounting
Section titled “Mounting”import { createFetchHandler } from 'uql-orm/http';import { pool } from './uql.config.js';import { Post, User } from './shared/models/index.js';
const handler = createFetchHandler({ pool, include: [User, Post] });| Runtime | Mount | base |
|---|---|---|
| Hono, Elysia | app. |
no, mount strips the prefix |
Bun. |
{ fetch: handler }, or { routes: { '/ under a prefix |
only for the wildcard form |
Deno., Cloudflare Workers |
Deno. / export default { fetch: handler } |
no, it serves the root |
| Next.js, Astro, React Router, TanStack Start | one catch-all route, per recipe | yes |
| Nitro / h3 v1 | from in a catch-all |
yes |
Where the table says basePath is required, pass the prefix you mounted at: createFetchHandler({ pool, include: [User, Post], basePath: '/api/uql' }). File-based routers match a prefix without rewriting the URL, so the handler has to be told to ignore it. On h3 v2 the bridge becomes defineEventHandler((event) => handler(event.req)), since event.req is a Request there.
The pool
Section titled “The pool”The handler runs on the pool you give it. One module usually builds it, registers the entities, and is what uql-migrate reads:
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;Pass a function to pick one per request, which is how a single deployment serves a database per tenant. It runs after getContext, with the adapter’s request and the context that resolved from it, and only for requests that reach a statement: a pre hook that throws never triggers the lookup.
const handler = createFetchHandler({ include: [User, Post], getContext: (request) => ({ tenantId: tenantOf(request) }), pool: (_request, { tenantId }) => poolFor(tenantId as string),});Wire protocol
Section titled “Wire protocol”For an entity named User (paths derive from the kebab-cased class name):
| Operation | Method | Endpoint | Body | Description |
|---|---|---|---|---|
find |
GET |
/ |
List records; add ?count=true for the total count. |
|
find |
GET |
/ |
First record matching the query. | |
count |
GET |
/ |
Count matching records. | |
find |
GET |
/ |
One record by primary key. | |
insert |
POST |
/ |
object | Insert a record. |
insert |
POST |
/ |
array | Insert many records. |
save |
PUT |
/ |
object | Insert or update (upsert). |
save |
PUT |
/ |
array | Insert or update many. |
update |
PATCH |
/ |
object | Bulk partial update of records matching $where. |
update |
PATCH |
/ |
object | Partial update by primary key. |
delete |
DELETE |
/ |
Delete by primary key. | |
delete |
DELETE |
/ |
Bulk delete of records matching the query. |
Delete routes soft-delete by default where the entity has the field; ?hardDelete=true overrides. GET endpoints take the serializable query as JSON strings in the query string ($skip and $limit as numbers). Writes run in a transaction, reads acquire and release a querier, HEAD mirrors GET, and malformed JSON is a 400, as is a $where that is not an object ($where=[1,2]; name the key instead, {"id":[1,2]}).
Responses use one envelope everywhere:
// success{ "data": ..., "count": 3 }
// error (status mirrors `code`){ "error": { "message": "forbidden", "code": 403 } }A database constraint failure gets its own status and a generic message, never the driver’s: 409 Conflict for a duplicate or a missing referenced row, 400 Bad Request for a not-null or check violation (error kinds). An update against a stale version is a 409 too, with a message naming the entity and both versions; a payload carrying no version at all, or a save or upsert of a versioned entity, is a 400. Everything else is a 500.
The route table is exported as CRUD_ROUTES, its keys compile-time constrained to UniversalQuerier method names, so the adapters, the browser client, and your own tooling share one source of truth.
HTTP QUERY (RFC 10008)
Section titled “HTTP QUERY (RFC 10008)”QUERY is an alternate transport for the three read routes (/user, /user/one, /user/count): same semantics as GET, but the query travels in the body, so large $where/$populate never hit URL-length limits.
The core, the Express adapter, Node and Bun all support it. The host framework has to route it too: mounts and wildcards that forward the raw request do (Hono, Elysia, Bun.serve, an Astro ALL export or src/fetch.ts), routers keyed to named verbs do not (Next.js route handlers, React Router’s loader/action split, fastify.all). It stays opt-in in the browser client because a cross-origin QUERY needs a CORS preflight and some proxies still drop unknown methods.
Authorization hooks
Section titled “Authorization hooks”Hooks run before the querier is touched, can be async, receive the adapter’s native request as context, and abort by throwing (a numeric status becomes the HTTP 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() }; },});| Hook | Lifecycle | Use case |
|---|---|---|
pre |
Before every operation. | Logging, auditing, global validation. |
pre |
Before POST, PUT, PATCH. |
Injecting creator, sanitization. |
pre |
Before GET, DELETE. |
Query shaping, forcing soft-delete. Not for tenant isolation. |
post |
After the operation (post-commit). | Response shaping: strip secrets, derive presentation fields. |
The hook context also carries meta, op and method, so one hook can branch per entity or operation. post receives the mutable success envelope, which covers sanitization a forced $select/$exclude cannot express:
const handler = createFetchHandler({ pool, include: [User], post({ meta }, envelope) { if (meta.entity === User) { envelope.data = (envelope.data as User[]).map( ({ password, ...rest }) => ({ ...rest, hasPassword: !!password, }), ); } },});Tenant scoping
Section titled “Tenant scoping”Folding a tenant id into $where from preFilter is not isolation: it is not AND-merged, does not reach joined relations, and does not fail closed. Pass getContext instead and declare a security filter. getContext runs the whole request inside withContext, so every query it makes is scoped and a client cannot opt out of it:
const handler = createFetchHandler({ pool, include: [Invoice], getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // verified session / JWT});import { Entity, Filter } from 'uql-orm';
@Filter('tenant', { where: (ctx) => ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined, security: true,})@Entity()export class Invoice {}See Multi-tenancy.
Composing with custom routes
Section titled “Composing with custom routes”The handlers cover single-entity CRUD only: anything else 404s from createFetchHandler and falls through via next() in the Express adapter, so both styles share one prefix. Read-modify-write logic, multi-entity transactions, aggregations, raw SQL, file uploads, streaming and third-party side effects stay in routes you write.