HTTP (any framework)
The HTTP transport core
Section titled “The HTTP transport core”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 './uql.config.js'; // setQuerierPool + entity importsimport { Post, User } from './shared/models/index.js';
const handler = createFetchHandler({ include: [User, Post] });| Runtime | Mount | basePath |
|---|---|---|
| Hono, Elysia | app.mount('/api', handler) |
no, mount strips the prefix |
Bun.serve |
{ fetch: handler }, or { routes: { '/api/*': handler } } under a prefix |
only for the wildcard form |
Deno.serve, Cloudflare Workers |
Deno.serve(handler) / 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 | fromWebHandler(handler) in a catch-all |
yes |
Where the table says basePath is required, pass the prefix you mounted at: createFetchHandler({ 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.
Wire protocol
Section titled “Wire protocol”For an entity named User (paths derive from the kebab-cased class name):
| Operation | Method | Endpoint | Body | Description |
|---|---|---|---|---|
findMany |
GET |
/user |
List records; add ?count=true for the total count. |
|
findOne |
GET |
/user/one |
First record matching the query. | |
count |
GET |
/user/count |
Count matching records. | |
findOneById |
GET |
/user/:id |
One record by primary key. | |
insertOne |
POST |
/user |
object | Insert a record. |
insertMany |
POST |
/user/many |
array | Insert many records. |
saveOne |
PUT |
/user |
object | Insert or update (upsert). |
saveMany |
PUT |
/user/many |
array | Insert or update many. |
updateMany |
PATCH |
/user |
object | Bulk partial update of records matching $where. |
updateOneById |
PATCH |
/user/:id |
object | Partial update by primary key. |
deleteOneById |
DELETE |
/user/:id |
Delete by primary key. | |
deleteMany |
DELETE |
/user |
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.
Responses use one envelope everywhere:
// success{ "data": ..., "count": 3 }
// error (status mirrors `code`){ "error": { "message": "forbidden", "code": 403 } }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), 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({ include: [Resource], 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. |
preSave |
Before POST, PUT, PATCH. |
Injecting creatorId, sanitization. |
preFilter |
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:
post({ meta }, envelope) { if (meta.entity === Integration) { envelope.data = (envelope.data as Integration[]).map(({ accessToken, ...rest }) => ({ ...rest, hasAccessToken: !!accessToken, })); }}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({ include: [Invoice], getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // verified session / JWT});import { Entity, Filter } from 'uql-orm';
@Filter('tenant', { condition: (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.