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 contains the whole transport: the route table, the request/response envelopes, query (de)serialization, querier lifecycle, transactions, and authorization hooks. Framework adapters are thin bindings on top:
createFetchHandlerreturns a web-standard(request: Request) => Promise<Response>that mounts natively on Hono, Elysia, Next.js route handlers, Bun.serve, Deno.serve, Cloudflare Workers, SvelteKit, and Astro.uql-orm/expressbinds the same core to Express.createRequestHandleris the underlying normalized-request handler if you need to bind a framework that is neither fetch-native nor Express (see the Fastify bridge below).
Quick start (any fetch-native runtime)
Section titled “Quick start (any fetch-native runtime)”import { createFetchHandler } from 'uql-orm/http';import './uql.config.js'; // setQuerierPool + entity importsimport { User, Post } from './shared/models/index.js';
const handler = createFetchHandler({ include: [User, Post] });Mount it:
// BunBun.serve({ fetch: handler });
// Hono (prefix stripped automatically by mount)import { Hono } from 'hono';const app = new Hono();app.mount('/api', handler);
// Elysia (prefix stripped automatically by mount)import { Elysia } from 'elysia';new Elysia().mount('/api', handler).listen(3000);
// Next.js: app/api/uql/[[...uql]]/route.tsconst handler = createFetchHandler({ include: [User], basePath: '/api/uql' });export { handler as GET, handler as HEAD, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };
// Cloudflare Workers (pairs well with uql-orm/d1)export default { fetch: handler };
// DenoDeno.serve(handler);
// SvelteKit: src/routes/api/[...uql]/+server.tsconst handler = createFetchHandler({ include: [User], basePath: '/api' });export const fallback = ({ request }) => handler(request);
// Astro: src/pages/api/uql/[...uql].ts (on-demand rendering: SSR adapter required)const handler = createFetchHandler({ include: [User], basePath: '/api/uql' });export const prerender = false;export const ALL = ({ request }) => handler(request);Use basePath to strip a URL prefix when the runtime does not strip it for you (Next.js, SvelteKit, Astro, plain Bun.serve under a sub-path).
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. |
For entities with a soft-delete field, both delete routes soft-delete by default; pass ?hardDelete=true to permanently remove the records instead.
All GET endpoints accept UQL’s serializable JSON query syntax: $select, $populate, $exclude, $where, and $sort travel as JSON strings in the query string; $skip and $limit as numbers. Writes run inside a transaction; reads acquire and release a pooled querier automatically. HEAD requests are served as their GET counterparts, and malformed JSON in the query string or body is rejected with a 400.
HTTP QUERY (RFC 10008)
Section titled “HTTP QUERY (RFC 10008)”The read routes also accept the new QUERY method as an alternate transport: same semantics as GET (safe, idempotent, cacheable), but the JSON query travels in the request body, so large $where/$populate queries never hit URL-length limits or percent-encoding overhead.
| Method | Endpoint | Equivalent to |
|---|---|---|
QUERY |
/user |
GET /user |
QUERY |
/user/one |
GET /user/one |
QUERY |
/user/count |
GET /user/count |
Supported out of the box by createFetchHandler, the Express adapter, Node 22+, and Bun. The host framework must route the method too: SvelteKit’s fallback export does, while Next.js route handlers only accept the standard verbs, so keep GET there. Cross-origin browser calls trigger a CORS preflight (QUERY is not a safelisted method), and some proxies/CDNs may not forward unknown methods yet, which is why the browser client keeps GET as the default and makes QUERY opt-in.
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, and its keys are compile-time constrained to UniversalQuerier method names, so the server adapters, the browser client, and your own tooling share a single source of truth.
Authorization and tenant scoping (hooks)
Section titled “Authorization and tenant scoping (hooks)”Hooks run before the querier is touched. They can be async, receive the adapter’s native request as context, and abort by throwing (a numeric status on the error becomes the HTTP status):
const handler = createFetchHandler({ include: [Resource], async preFilter({ query, context }) { // context is the web Request (with uql-orm/express it is the express req) const user = await authenticate(context.headers.get('authorization')); if (!user) { throw Object.assign(new Error('unauthorized'), { status: 401 }); } // fold tenant scoping into the query; runs for every GET and DELETE query.$where ??= {}; Object.assign(query.$where, { workspaceId: user.activeWorkspaceId }); }, preSave(ctx) { // runs for POST, PUT, and PATCH; reassign ctx.body to sanitize or inject fields 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. |
Row-level security, tenant isolation, forcing soft-delete (hardDelete: false). |
post |
After the operation (post-commit). | Response shaping: strip secrets, derive presentation fields. |
The hook context also carries meta (entity metadata), op (e.g. 'findMany'), and method, so a single hook can branch per entity or per operation.
Tenant scoping (recommended: getContext + security filters)
Section titled “Tenant scoping (recommended: getContext + security filters)”Hand-folding workspaceId into $where (above) works, but for real multi-tenancy prefer a security filter plus the getContext option. getContext derives the context from the verified request and runs the whole request inside withContext, so every query - reads, writes, relations, cascades - is scoped automatically, can’t be bypassed from the wire, and fails closed if the context is missing:
const handler = createFetchHandler({ include: [Invoice], getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // from a verified session / JWT});@Filter('tenant', { condition: (ctx) => (ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined), security: true,})@Entity()export class Invoice {}See Multi-tenancy for the full walkthrough.
post receives the mutable success envelope, which covers sanitization that a forced $select/$exclude cannot express, like deriving flags from stripped secrets:
post({ meta }, envelope) { if (meta.entity === Integration) { envelope.data = (envelope.data as Integration[]).map(({ accessToken, ...rest }) => ({ ...rest, hasAccessToken: !!accessToken, })); }}Composing with custom routes
Section titled “Composing with custom routes”The handlers intentionally cover only single-entity CRUD: anything else returns a 404 from createFetchHandler and falls through via next() in the Express adapter, so both styles share one prefix. Keep hand-written routes next to them for read-modify-write logic (compute a value from current state, then write), multi-entity transactions (pool.transaction), aggregations (querier.aggregate), raw SQL and vector search, file uploads, streaming/SSE, and third-party side effects (payments, OAuth, webhooks).
Bridging a non-fetch framework (Fastify example)
Section titled “Bridging a non-fetch framework (Fastify example)”createRequestHandler takes a plain normalized object, so a bridge is a few lines:
import { createRequestHandler, toErrorResponse } from 'uql-orm/http';
const handle = createRequestHandler({ include: [User] });
fastify.all('/api/:entityPath/:subPath?', async (req, reply) => { const { entityPath, subPath } = req.params; const pending = handle({ method: req.method, entityPath, subPath, query: req.query, body: req.body, context: req, }); if (!pending) return reply.callNotFound(); try { const { status, body } = await pending; reply.status(status).send(body); } catch (err) { const { status, body } = toErrorResponse(err); reply.status(status).send(body); }});fastify.all registers the standard verbs only, so this bridge serves the GET transport but not QUERY.