Skip to content

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:

  • createFetchHandler returns a web-standard (request: Request) => Promise<Response>.
  • uql-orm/express binds the same core to Express 5.
  • createRequestHandler takes 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.

import { createFetchHandler } from 'uql-orm/http';
import './uql.config.js'; // setQuerierPool + entity imports
import { 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.

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.

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.

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,
}));
}
}

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.

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.