• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rogerpadilla / uql / 28632896023

03 Jul 2026 01:42AM UTC coverage: 94.929% (+0.1%) from 94.827%
28632896023

push

github

rogerpadilla
feat(http)!: framework-agnostic HTTP transport core (uql-orm/http)

- define the wire contract once (routes, envelopes, query serialization);
  uql-orm/express becomes a thin adapter; new createFetchHandler mounts on
  Hono, Next.js, Bun, Deno, Workers, and SvelteKit; new uql-orm/nestjs
  module registers the pool with Nest DI
- extend the protocol: insertMany, saveOne (upsert), saveMany, bulk
  updateMany, and the HTTP QUERY (RFC 10008) read transport
- hooks: async HookContext with the adapter's native request as context,
  new post response hook; flags resolve after hooks run so preFilter can
  enforce softDelete as documented
- client: per-call and per-instance headers, AbortSignal support,
  RequestError carrying the HTTP status
- fix the broken published browser bundle (bun build replaces bunchee;
  4 KB, no reflect-metadata)
- HEAD served as GET, malformed JSON yields 400, basePath strips only at
  path boundaries, percent-encoded query strings, array $where merged via
  $and on by-id routes

BREAKING CHANGE: hook signature is (ctx: HookContext); error envelope is
{ error: { message, code } }; HttpQuerier.saveOne issues PUT; removed
buildQuerierRouter, express parseQuery, the browser re-exports, and the
dead uql-orm/test export; uql-orm/browser no longer loads reflect-metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

3172 of 3510 branches covered (90.37%)

Branch coverage included in aggregate %.

224 of 225 new or added lines in 8 files covered. (99.56%)

5514 of 5640 relevant lines covered (97.77%)

353.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.95
/packages/uql-orm/src/http/handler.ts
1
import { getEntities, getMeta } from '../entity/index.js';
2
import { getQuerier } from '../options.js';
3
import type { EntityMeta, IdValue, Querier, Query, QuerySearch, Type, UpdatePayload } from '../type/index.js';
4
import {
5
  type CrudOperation,
6
  entityPath,
7
  type HttpMethod,
8
  matchRoute,
9
  type RequestSuccessResponse,
10
  type RouteMatch,
11
} from './contract.js';
12
import { parseQueryParams } from './query.js';
13

14
/**
15
 * Framework-normalized request: adapters (express, fetch, ...) reduce their native
16
 * request to this shape and get back a status + JSON body.
17
 */
18
export type HandlerRequest<Ctx = unknown> = {
19
  readonly method: string;
20
  readonly entityPath: string;
21
  readonly subPath?: string;
22
  /**
23
   * raw query-string entries; JSON values may still be stringified.
24
   */
25
  readonly query?: Record<string, unknown>;
26
  /**
27
   * parsed JSON body.
28
   */
29
  readonly body?: unknown;
30
  /**
31
   * adapter-supplied request context (express `req`, fetch `Request`, ...), passed through to hooks.
32
   */
33
  readonly context: Ctx;
34
};
35

36
export type HandlerResponse = {
37
  readonly status: number;
38
  readonly body: unknown;
39
};
40

41
/**
42
 * Wire flags ride inside the query object (as strings on GET, booleans on QUERY),
43
 * so hooks can enforce them, e.g. force `softDelete: true` in `preFilter`.
44
 */
45
type WireFlags = {
46
  readonly softDelete?: unknown;
47
  readonly count?: unknown;
48
};
49

50
export type HookContext<E extends object, Ctx = unknown> = {
51
  readonly meta: EntityMeta<E>;
52
  readonly op: CrudOperation;
53
  readonly method: HttpMethod;
54
  /**
55
   * parsed query — mutate in place or reassign to enforce filters (tenant scoping, row-level rules).
56
   */
57
  query: Query<E>;
58
  /**
59
   * request payload — reassignable for sanitization or field injection.
60
   */
61
  body?: unknown;
62
  /**
63
   * adapter-supplied request context — the auth/tenant source (e.g. `req.user`).
64
   */
65
  readonly context: Ctx;
66
};
67

68
export type Hook<Ctx = unknown> = <E extends object>(ctx: HookContext<E, Ctx>) => void | Promise<void>;
69

70
export type ResponseHook<Ctx = unknown> = <E extends object>(
71
  ctx: HookContext<E, Ctx>,
72
  envelope: RequestSuccessResponse<unknown>,
73
) => void | Promise<void>;
74

75
export type RequestHandlerOptions<Ctx = unknown> = {
76
  // biome-ignore lint/suspicious/noExplicitAny: accepts any entity constructor
77
  include?: Type<any>[];
78
  // biome-ignore lint/suspicious/noExplicitAny: accepts any entity constructor
79
  exclude?: Type<any>[];
80
  /**
81
   * Allow augment any kind of request before it runs. Hooks may be async
82
   * and abort the request by throwing (a numeric `status` on the error is honored).
83
   */
84
  pre?: Hook<Ctx>;
85
  /**
86
   * Allow augment a save request (POST | PUT | PATCH) before it runs.
87
   */
88
  preSave?: Hook<Ctx>;
89
  /**
90
   * Allow augment a filter request (GET | DELETE) before it runs.
91
   */
92
  preFilter?: Hook<Ctx>;
93
  /**
94
   * Shape the successful response before it is sent: strip sensitive fields,
95
   * derive presentation fields, or coerce null data. Mutate `envelope.data` in place
96
   * or reassign it. Runs after the operation (and after commit for writes).
97
   */
98
  post?: ResponseHook<Ctx>;
99
};
100

101
/**
102
 * Returns `undefined` synchronously for an unknown entity or route so adapters can fall through
103
 * (e.g. express `next()`); rejects with the original error on failure so adapters map it
104
 * (e.g. `toErrorResponse`).
105
 */
106
export type RequestHandler<Ctx = unknown> = (req: HandlerRequest<Ctx>) => Promise<HandlerResponse> | undefined;
107

108
export function createRequestHandler<Ctx = unknown>(opts: RequestHandlerOptions<Ctx> = {}): RequestHandler<Ctx> {
66✔
109
  const { include, exclude, pre, preSave, preFilter, post } = opts;
66✔
110

111
  let entities = include ?? getEntities();
66✔
112
  if (exclude) {
66✔
113
    entities = entities.filter((entity) => !exclude.includes(entity));
4✔
114
  }
115
  if (!entities.length) {
66✔
116
    throw new TypeError('no entities for the uql middleware');
2✔
117
  }
118

119
  // biome-ignore lint/suspicious/noExplicitAny: heterogeneous entity map
120
  const entityByPath = new Map<string, Type<any>>(entities.map((entity) => [entityPath(entity), entity]));
79✔
121

122
  return (req) => {
64✔
123
    const entity = entityByPath.get(req.entityPath);
61✔
124
    if (!entity) {
61✔
125
      return undefined;
5✔
126
    }
127
    const match = matchRoute(req.method, req.subPath);
56✔
128
    if (!match) {
56✔
129
      return undefined;
3✔
130
    }
131
    return run(entity, match, req);
53✔
132
  };
133

134
  async function run<E extends object>(
135
    entity: Type<E>,
136
    { op, method, id }: RouteMatch,
137
    req: HandlerRequest<Ctx>,
138
  ): Promise<HandlerResponse> {
139
    const meta = getMeta(entity);
53✔
140
    // QUERY (RFC 10008) carries the JSON query in the body instead of the query string
141
    const rawQuery = method === 'QUERY' ? (req.body as Record<string, unknown> | undefined) : req.query;
53✔
142

143
    const hookCtx: HookContext<E, Ctx> = {
53✔
144
      meta,
145
      op,
146
      method,
147
      query: parseQueryParams(rawQuery) as Query<E>,
148
      body: req.body,
149
      context: req.context,
150
    };
151
    await pre?.(hookCtx);
53✔
152
    if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
50✔
153
      await preSave?.(hookCtx);
16✔
154
    } else {
155
      await preFilter?.(hookCtx);
34✔
156
    }
157

158
    const resp = await dispatch();
50✔
159
    if (post) {
44✔
160
      await post(hookCtx, resp.body as RequestSuccessResponse<unknown>);
3✔
161
    }
162
    return resp;
44✔
163

164
    function dispatch(): Promise<HandlerResponse> {
165
      // read post-hooks so both in-place mutation and reassignment of hookCtx.query apply
166
      const query = hookCtx.query;
50✔
167
      const flags = query as WireFlags;
50✔
168
      const softDelete = flags.softDelete === 'true' || flags.softDelete === true;
50✔
169
      switch (op) {
50✔
170
        case 'findOne':
171
          return withQuerier(async (querier) => {
9✔
172
            const data = await querier.findOne(entity, query);
9✔
173
            return ok({ data, count: data ? 1 : 0 });
5✔
174
          });
175
        case 'count':
176
          return withQuerier(async (querier) => {
1✔
177
            const count = await querier.count(entity, query);
1✔
178
            return ok({ data: count, count });
1✔
179
          });
180
        case 'findOneById':
181
          return withQuerier(async (querier) => {
4✔
182
            const data = await querier.findOne(entity, buildIdQuery(meta, id, query));
4✔
183
            return ok({ data, count: data ? 1 : 0 });
4!
184
          });
185
        case 'findMany':
186
          return withQuerier(async (querier) => {
14✔
187
            const findManyPromise = querier.findMany(entity, query);
14✔
188
            const countPromise = flags.count ? querier.count(entity, query) : undefined;
14✔
189
            const [data, count] = await Promise.all([findManyPromise, countPromise]);
14✔
190
            return ok({ data, count });
14✔
191
          });
192
        case 'insertOne':
193
          return withTransaction(async (querier) => {
8✔
194
            const data = await querier.insertOne(entity, hookCtx.body as E);
8✔
195
            return ok({ data, count: 1 });
6✔
196
          });
197
        case 'insertMany':
198
          return withTransaction(async (querier) => {
1✔
199
            const data = await querier.insertMany(entity, hookCtx.body as E[]);
1✔
200
            return ok({ data, count: data.length });
1✔
201
          });
202
        case 'saveOne':
203
          return withTransaction(async (querier) => {
2✔
204
            const data = await querier.saveOne(entity, hookCtx.body as E);
2✔
205
            return ok({ data, count: 1 });
2✔
206
          });
207
        case 'saveMany':
208
          return withTransaction(async (querier) => {
1✔
209
            const data = await querier.saveMany(entity, hookCtx.body as E[]);
1✔
210
            return ok({ data, count: data.length });
1✔
211
          });
212
        case 'updateOneById':
213
          return withTransaction(async (querier) => {
3✔
214
            const count = await querier.updateMany(
3✔
215
              entity,
216
              buildIdQuery(meta, id, query),
217
              hookCtx.body as UpdatePayload<E>,
218
            );
219
            return ok({ data: id, count });
3✔
220
          });
221
        case 'updateMany':
222
          return withTransaction(async (querier) => {
1✔
223
            const count = await querier.updateMany(entity, query as QuerySearch<E>, hookCtx.body as UpdatePayload<E>);
1✔
224
            return ok({ data: count, count });
1✔
225
          });
226
        case 'deleteOneById':
227
          return withTransaction(async (querier) => {
4✔
228
            const count = await querier.deleteMany(entity, buildIdQuery(meta, id, query), { softDelete });
4✔
229
            return ok({ data: id, count });
4✔
230
          });
231
        case 'deleteMany':
232
          return withTransaction(async (querier) => {
2✔
233
            const founds = await querier.findMany(entity, query);
2✔
234
            let ids: IdValue<E>[] = [];
2✔
235
            let count = 0;
2✔
236
            if (founds.length && meta.id) {
2✔
237
              const idKey = meta.id;
1✔
238
              ids = founds.map((found) => found[idKey]);
2✔
239
              count = await querier.deleteMany(entity, { $where: ids }, { softDelete });
1✔
240
            }
241
            return ok({ data: ids, count });
2✔
242
          });
243
      }
244
    }
245
  }
246
}
247

248
function ok(body: unknown): HandlerResponse {
249
  return { status: 200, body };
44✔
250
}
251

252
async function withQuerier(fn: (querier: Querier) => Promise<HandlerResponse>): Promise<HandlerResponse> {
253
  const querier = await getQuerier();
28✔
254
  try {
28✔
255
    return await fn(querier);
28✔
256
  } finally {
257
    await querier.release();
28✔
258
  }
259
}
260

261
async function withTransaction(fn: (querier: Querier) => Promise<HandlerResponse>): Promise<HandlerResponse> {
262
  const querier = await getQuerier();
22✔
263
  try {
22✔
264
    await querier.beginTransaction();
22✔
265
    const resp = await fn(querier);
22✔
266
    await querier.commitTransaction();
20✔
267
    return resp;
20✔
268
  } catch (err) {
269
    await querier.rollbackTransaction().catch(() => {});
2✔
270
    throw err;
2✔
271
  } finally {
272
    await querier.release();
22✔
273
  }
274
}
275

276
function buildIdQuery<E extends object>(meta: EntityMeta<E>, id: string | undefined, query: Query<E>): Query<E> {
277
  const idKey = meta.id as string;
11✔
278
  const where = query.$where;
11✔
279
  if (Array.isArray(where)) {
11✔
280
    query.$where = { $and: [{ [idKey]: { $in: where } }, { [idKey]: id }] } as Query<E>['$where'];
4✔
281
  } else if (typeof where === 'object' && where !== null) {
7!
282
    query.$where = { ...where, [idKey]: id } as Query<E>['$where'];
7✔
283
  } else {
NEW
284
    query.$where = { [idKey]: id } as Query<E>['$where'];
×
285
  }
286
  return query;
11✔
287
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc