@metaobjectsdev/runtime-ts 1.0.9-rc.1 → 1.0.9-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/drizzle-fastify/filter-allowlist.d.ts +23 -0
  2. package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
  3. package/dist/drizzle-fastify/filter-allowlist.js +13 -1
  4. package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
  5. package/dist/drizzle-fastify/filter-parser.d.ts +1 -6
  6. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
  7. package/dist/drizzle-fastify/filter-parser.js +91 -42
  8. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  9. package/dist/drizzle-fastify/filter-value-format.d.ts +21 -0
  10. package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -0
  11. package/dist/drizzle-fastify/filter-value-format.js +54 -0
  12. package/dist/drizzle-fastify/filter-value-format.js.map +1 -0
  13. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  14. package/dist/drizzle-fastify/index.js +10 -5
  15. package/dist/drizzle-fastify/index.js.map +1 -1
  16. package/dist/drizzle-fastify/list-params.d.ts +27 -0
  17. package/dist/drizzle-fastify/list-params.d.ts.map +1 -0
  18. package/dist/drizzle-fastify/list-params.js +43 -0
  19. package/dist/drizzle-fastify/list-params.js.map +1 -0
  20. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  21. package/dist/drizzle-fastify/mount-m2m.js +3 -1
  22. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  23. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  24. package/dist/drizzle-fastify/mount-read-only.js +8 -4
  25. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  26. package/dist/drizzle-fastify/route-error-handler.d.ts +27 -0
  27. package/dist/drizzle-fastify/route-error-handler.d.ts.map +1 -0
  28. package/dist/drizzle-fastify/route-error-handler.js +41 -0
  29. package/dist/drizzle-fastify/route-error-handler.js.map +1 -0
  30. package/dist/fastify/index.d.ts.map +1 -1
  31. package/dist/fastify/index.js +28 -11
  32. package/dist/fastify/index.js.map +1 -1
  33. package/dist/hono/index.d.ts.map +1 -1
  34. package/dist/hono/index.js +30 -21
  35. package/dist/hono/index.js.map +1 -1
  36. package/dist/hono/mount-read-only.d.ts.map +1 -1
  37. package/dist/hono/mount-read-only.js +10 -7
  38. package/dist/hono/mount-read-only.js.map +1 -1
  39. package/dist/hono/route-guard.d.ts +34 -0
  40. package/dist/hono/route-guard.d.ts.map +1 -0
  41. package/dist/hono/route-guard.js +46 -0
  42. package/dist/hono/route-guard.js.map +1 -0
  43. package/dist/route-errors.d.ts +50 -0
  44. package/dist/route-errors.d.ts.map +1 -0
  45. package/dist/route-errors.js +67 -0
  46. package/dist/route-errors.js.map +1 -0
  47. package/package.json +3 -3
  48. package/src/drizzle-fastify/filter-allowlist.ts +33 -0
  49. package/src/drizzle-fastify/filter-parser.ts +117 -39
  50. package/src/drizzle-fastify/filter-value-format.ts +68 -0
  51. package/src/drizzle-fastify/index.ts +10 -5
  52. package/src/drizzle-fastify/list-params.ts +50 -0
  53. package/src/drizzle-fastify/mount-m2m.ts +3 -1
  54. package/src/drizzle-fastify/mount-read-only.ts +8 -4
  55. package/src/drizzle-fastify/route-error-handler.ts +54 -0
  56. package/src/fastify/index.ts +24 -9
  57. package/src/hono/index.ts +27 -19
  58. package/src/hono/mount-read-only.ts +10 -13
  59. package/src/hono/route-guard.ts +58 -0
  60. package/src/route-errors.ts +77 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Well-formedness checks for filter values, keyed by the wire format a field's values
3
+ * take (docs/features/api-contract.md, "Type encodings"). The filter parser runs these
4
+ * before a value reaches SQL, so a malformed value is a 400 naming the format rather than
5
+ * a silent empty result (SQLite compares the text and matches nothing) or a driver error
6
+ * (Postgres refuses the cast).
7
+ *
8
+ * Deliberately a little more lenient than the RESPONSE encoding, because a filter bound
9
+ * is written by a person: a time may omit seconds, a timestamp may omit its time part
10
+ * (a day bound) or carry any UTC offset, and fractional seconds may have any precision.
11
+ * What is refused is a value that is not that kind of thing at all.
12
+ */
13
+
14
+ import {
15
+ FIELD_SUBTYPE_DATE,
16
+ FIELD_SUBTYPE_TIME,
17
+ FIELD_SUBTYPE_TIMESTAMP,
18
+ FIELD_SUBTYPE_UUID,
19
+ } from "@metaobjectsdev/metadata";
20
+ import type { FilterValueFormat } from "./filter-allowlist.js";
21
+
22
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
23
+ const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/;
24
+ // Date, then optionally a `T`/space time, then optionally `Z` or a ±HH[:]MM offset.
25
+ const TIMESTAMP_RE =
26
+ /^(\d{4}-\d{2}-\d{2})(?:[T ]((?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?)(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)?)?$/i;
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+
29
+ /** A `YYYY-MM-DD` that names a real calendar day (2026-02-30 does not). */
30
+ function isCalendarDate(s: string): boolean {
31
+ const m = DATE_RE.exec(s);
32
+ if (!m) return false;
33
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
34
+ const probe = new Date(Date.UTC(y, mo - 1, d));
35
+ return probe.getUTCFullYear() === y && probe.getUTCMonth() === mo - 1 && probe.getUTCDate() === d;
36
+ }
37
+
38
+ function isTimestamp(s: string): boolean {
39
+ const m = TIMESTAMP_RE.exec(s);
40
+ return m !== null && isCalendarDate(m[1] ?? "");
41
+ }
42
+
43
+ const CHECKS: Readonly<Record<FilterValueFormat, (s: string) => boolean>> = {
44
+ [FIELD_SUBTYPE_DATE]: isCalendarDate,
45
+ [FIELD_SUBTYPE_TIME]: (s) => TIME_RE.test(s),
46
+ [FIELD_SUBTYPE_TIMESTAMP]: isTimestamp,
47
+ [FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s),
48
+ };
49
+
50
+ /** The `expected` member of the `invalid_filter_value` envelope, per format. */
51
+ export const FORMAT_EXPECTED: Readonly<Record<FilterValueFormat, string>> = {
52
+ [FIELD_SUBTYPE_DATE]: "date (YYYY-MM-DD)",
53
+ [FIELD_SUBTYPE_TIME]: "time (HH:MM[:SS[.fff]])",
54
+ [FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
55
+ [FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)",
56
+ };
57
+
58
+ /** `expected` for a temporal rule that does not say WHICH temporal format it is. */
59
+ export const ANY_TEMPORAL_EXPECTED = "date, time or timestamp (ISO 8601)";
60
+
61
+ export function matchesFormat(format: FilterValueFormat, s: string): boolean {
62
+ return CHECKS[format](s);
63
+ }
64
+
65
+ /** For a `datetime` rule with no `format` (generated before it existed): any of the three. */
66
+ export function matchesAnyTemporal(s: string): boolean {
67
+ return isCalendarDate(s) || CHECKS[FIELD_SUBTYPE_TIME](s) || isTimestamp(s);
68
+ }
@@ -26,9 +26,10 @@ import { eq, count, and, asc } from "drizzle-orm";
26
26
  import qs from "qs";
27
27
  import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
28
28
  export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
29
- import { parseFilterParams, FilterParseError } from "./filter-parser.js";
29
+ import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
30
30
  import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
31
31
  import { timestampWire } from "../timestamp-wire.js";
32
+ import { withContractErrorHandler } from "./route-error-handler.js";
32
33
  export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
33
34
  export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
34
35
 
@@ -161,8 +162,11 @@ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
161
162
 
162
163
  type VerbOptions = Omit<CrudRoutesOptions, "expose">;
163
164
 
165
+ /** The adopter's route options plus the route-scoped contract error handler (an
166
+ * unexpected error answers `500 { error: "internal" }`, a malformed JSON body
167
+ * `400 { error: "invalid_json" }`) — see route-error-handler.ts. */
164
168
  function routeOpts(opts: VerbOptions): RouteShorthandOptions {
165
- return opts.routeOptions ?? {};
169
+ return withContractErrorHandler(opts.routeOptions);
166
170
  }
167
171
 
168
172
  export function mountListRoute(opts: VerbOptions): void {
@@ -211,11 +215,12 @@ export function mountListRoute(opts: VerbOptions): void {
211
215
  } else {
212
216
  // Legacy path — no allowlists configured. Only limit/offset (+ the TPH
213
217
  // discriminator predicate when subtype-scoped).
214
- const { limit, offset } = req.query as { limit?: string; offset?: string };
218
+ const limit = parsePageBound(qsParsed, "limit");
219
+ const offset = parsePageBound(qsParsed, "offset");
215
220
  if (discCond) { q = q.where(discCond); where = discCond; }
216
221
  if (src.id !== undefined) q = q.orderBy(asc(src.id));
217
- if (limit !== undefined) q = q.limit(Number(limit));
218
- if (offset !== undefined) q = q.offset(Number(offset));
222
+ if (limit !== undefined) q = q.limit(limit);
223
+ if (offset !== undefined) q = q.offset(offset);
219
224
  }
220
225
 
221
226
  // Await the query directly rather than calling `.all()`: the
@@ -0,0 +1,50 @@
1
+ /**
2
+ * List-query parameter checks shared by every mount — the Drizzle mounts (Fastify and
3
+ * Hono) and the ObjectManager Fastify mount. Deliberately free of drizzle-orm (an
4
+ * OPTIONAL peer): the ObjectManager mount must not pull it in.
5
+ */
6
+
7
+ export class FilterParseError extends Error {
8
+ constructor(public readonly code: string, message: string, public readonly details?: Record<string, unknown>) {
9
+ super(message);
10
+ this.name = "FilterParseError";
11
+ }
12
+ }
13
+
14
+ /** The page-size ceiling of a raw-SQL (column-less `.existing()`) view list, which has
15
+ * no allowlist to page through and returns at most this many rows. */
16
+ export const RAW_VIEW_MAX_LIMIT = 1000;
17
+
18
+ /** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */
19
+ export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)";
20
+
21
+ /**
22
+ * Read `?limit=` / `?offset=` as a non-negative integer, or refuse it. Before, a value
23
+ * that was not a finite number (`?limit=abc`) was dropped — every row came back — and a
24
+ * negative or fractional one reached SQL. `max` is the ceiling a mount clamps to, when it
25
+ * has one: a bound above it is refused rather than silently lowered. TS-only (no corpus
26
+ * scenario sends a malformed page bound): see docs/features/api-contract.md, "TS-only
27
+ * error responses".
28
+ */
29
+ export function parsePageBound(
30
+ query: Record<string, unknown>,
31
+ param: "limit" | "offset",
32
+ max?: number,
33
+ ): number | undefined {
34
+ const raw = query[param];
35
+ if (raw === undefined) return undefined;
36
+ const s = typeof raw === "string" ? raw : String(raw);
37
+ const n = /^\d+$/.test(s) ? Number(s) : Number.NaN;
38
+ if (!Number.isSafeInteger(n) || (max !== undefined && n > max)) {
39
+ const expected = max === undefined ? PAGINATION_EXPECTED : `an integer from 0 to ${max}`;
40
+ throw new FilterParseError(
41
+ "pagination.invalid_value",
42
+ `?${param}= must be ${expected}, got "${s}".`,
43
+ { param, value: s, expected },
44
+ );
45
+ }
46
+ return n;
47
+ }
48
+
49
+ /** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */
50
+ export const SORT_EXPECTED = "sort=<field>:asc|desc";
@@ -34,6 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify";
34
34
  import { and, eq, or, inArray } from "drizzle-orm";
35
35
  import { coerceIdForColumn } from "./util.js";
36
36
  import { timestampWire } from "../timestamp-wire.js";
37
+ import { withContractErrorHandler } from "./route-error-handler.js";
37
38
 
38
39
  // Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
39
40
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
@@ -84,7 +85,8 @@ export interface M2mRouteOptions {
84
85
  export function mountM2mRoute(opts: M2mRouteOptions): void {
85
86
  const targetPk = opts.targetPkColumn ?? "id";
86
87
  const route = `${opts.path}/:id/${opts.relationName}`;
87
- const ro = opts.routeOptions ?? {};
88
+ // Route-scoped contract error handler — see route-error-handler.ts.
89
+ const ro = withContractErrorHandler(opts.routeOptions);
88
90
  const toWire = timestampWire(opts.targetTable);
89
91
 
90
92
  opts.fastify.get(route, ro, async (req, reply) => {
@@ -1,10 +1,11 @@
1
1
  import type { FastifyInstance, RouteShorthandOptions } from "fastify";
2
2
  import { sql, eq, and, count } from "drizzle-orm";
3
3
  import qs from "qs";
4
- import { parseFilterParams, FilterParseError } from "./filter-parser.js";
4
+ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js";
5
5
  import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
6
6
  import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
7
7
  import { timestampWire } from "../timestamp-wire.js";
8
+ import { withContractErrorHandler } from "./route-error-handler.js";
8
9
 
9
10
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
10
11
  type AnyView = any;
@@ -118,7 +119,9 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
118
119
  export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
119
120
  const { fastify, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
120
121
  const idCol = opts.idColumn ?? "id";
121
- const ro = opts.routeOptions ?? {};
122
+ // Route-scoped contract error handler: an unexpected error answers
123
+ // `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
124
+ const ro = withContractErrorHandler(opts.routeOptions);
122
125
 
123
126
  const viewName = resolveViewName(view);
124
127
  const useRawSql = isEmptyColumnView(view) && !!viewName;
@@ -137,8 +140,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
137
140
  const qIdx = url.indexOf("?");
138
141
  const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
139
142
  const parsed = qs.parse(queryString) as Record<string, unknown>;
140
- const limitVal = Math.min(1000, Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)));
141
- const offsetVal = Math.max(0, Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0));
143
+ // A bound that is not an integer in range is a 400, not silently clamped.
144
+ const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
145
+ const offsetVal = parsePageBound(parsed, "offset") ?? 0;
142
146
  const withCount = isTruthyFlag(parsed["withCount"]);
143
147
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
144
148
  const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The route-level Fastify error handler every mount helper installs on the routes IT
3
+ * mounts (drizzle-fastify and the ObjectManager fastify adapter share it).
4
+ *
5
+ * Why route-level: Fastify's `errorHandler` route option is scoped to exactly that route
6
+ * (it overrides the scope's `setErrorHandler` for requests to it, and nothing else). A
7
+ * `setErrorHandler` call from inside a mount would instead change how every OTHER route
8
+ * in the adopter's scope answers — and the generated route files hand the mounts a plain
9
+ * plugin instance, so the helpers cannot know how wide that scope is. A scoped content-type
10
+ * parser was the other option; it would fix only the malformed-body half and would replace
11
+ * the adopter's own JSON parser for the whole scope.
12
+ *
13
+ * What it answers:
14
+ * - a body Fastify's JSON parser refused → `400 { "error": "invalid_json" }`;
15
+ * - any other deliberate 4xx (an auth hook's 401, schema validation, 413, 415) → rethrown,
16
+ * so the enclosing scope's handler — the adopter's, or Fastify's default — answers it
17
+ * exactly as it did before the mount existed;
18
+ * - anything else → logged server-side, then `500 { "error": "internal" }`, with no SQL,
19
+ * table, column or parameter in the body.
20
+ *
21
+ * An adopter who passes `routeOptions.errorHandler` keeps it: theirs wins, untouched.
22
+ */
23
+
24
+ import type { FastifyReply, FastifyRequest, RouteShorthandOptions } from "fastify";
25
+ import {
26
+ HTTP_STATUS_BAD_REQUEST,
27
+ HTTP_STATUS_INTERNAL,
28
+ INTERNAL_ERROR_BODY,
29
+ INVALID_JSON_BODY,
30
+ isDeliberateClientError,
31
+ isMalformedJsonBodyError,
32
+ logUnexpectedError,
33
+ } from "../route-errors.js";
34
+
35
+ export function contractRouteErrorHandler(
36
+ error: unknown,
37
+ _request: FastifyRequest,
38
+ reply: FastifyReply,
39
+ ): FastifyReply {
40
+ if (isMalformedJsonBodyError(error)) {
41
+ return reply.code(HTTP_STATUS_BAD_REQUEST).send(INVALID_JSON_BODY);
42
+ }
43
+ // Throwing from a route-level handler hands the error to the enclosing scope's handler.
44
+ if (isDeliberateClientError(error)) throw error;
45
+ logUnexpectedError(error);
46
+ return reply.code(HTTP_STATUS_INTERNAL).send(INTERNAL_ERROR_BODY);
47
+ }
48
+
49
+ /** The adopter's route options plus the contract error handler — unless they set their own. */
50
+ export function withContractErrorHandler(ro: RouteShorthandOptions | undefined): RouteShorthandOptions {
51
+ const base = ro ?? {};
52
+ if (base.errorHandler !== undefined) return base;
53
+ return { ...base, errorHandler: contractRouteErrorHandler };
54
+ }
@@ -26,6 +26,8 @@ import type { RouteShorthandOptions } from "fastify";
26
26
  import type { SortAllowlist } from "../drizzle-fastify/filter-allowlist.js";
27
27
  import { sortOrderSpec } from "../drizzle-fastify/filter-allowlist.js";
28
28
  import { isTruthyFlag, contractErrorCode } from "../drizzle-fastify/util.js";
29
+ import { parsePageBound, FilterParseError, SORT_EXPECTED } from "../drizzle-fastify/list-params.js";
30
+ import { withContractErrorHandler } from "../drizzle-fastify/route-error-handler.js";
29
31
 
30
32
  // ---------------------------------------------------------------------------
31
33
  // Public surface
@@ -107,12 +109,14 @@ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
107
109
  type SingleVerbOptions = Omit<CrudRoutesOptions, "expose">;
108
110
 
109
111
  /**
110
- * Build the route options Fastify wants. Just the consumer's routeOptions
111
- * (typed loosely to avoid Fastify's elaborate route-options union here).
112
- * Returns an empty object if nothing was provided.
112
+ * Build the route options Fastify wants: the consumer's routeOptions plus the
113
+ * route-scoped contract error handler shared with drizzle-fastify (an unexpected
114
+ * error answers `500 { error: "internal" }` instead of Fastify's default, which
115
+ * echoes the driver message; a malformed JSON body answers
116
+ * `400 { error: "invalid_json" }`). A consumer-supplied `errorHandler` wins.
113
117
  */
114
118
  function routeOpts(opts: SingleVerbOptions): RouteShorthandOptions {
115
- return opts.routeOptions ?? {};
119
+ return withContractErrorHandler(opts.routeOptions);
116
120
  }
117
121
 
118
122
  export function mountListRoute(opts: SingleVerbOptions): void {
@@ -126,9 +130,15 @@ export function mountListRoute(opts: SingleVerbOptions): void {
126
130
  const withCount = isTruthyFlag(parsed["withCount"]);
127
131
 
128
132
  const readOpts: { limit?: number; offset?: number; orderBy?: [string, "asc" | "desc"] } = {};
129
- const { limit, offset } = req.query as { limit?: string; offset?: string };
130
- if (limit !== undefined) readOpts.limit = Number(limit);
131
- if (offset !== undefined) readOpts.offset = Number(offset);
133
+ try {
134
+ const limit = parsePageBound(parsed, "limit");
135
+ const offset = parsePageBound(parsed, "offset");
136
+ if (limit !== undefined) readOpts.limit = limit;
137
+ if (offset !== undefined) readOpts.offset = offset;
138
+ } catch (err) {
139
+ if (err instanceof FilterParseError) return reply.code(400).send({ error: err.code, ...(err.details ?? {}) });
140
+ throw err;
141
+ }
132
142
 
133
143
  // Sort allowlist gate (cross-port REST contract). Only enforced when a
134
144
  // sortAllowlist is configured; absent → ?sort is ignored (back-compat).
@@ -137,7 +147,12 @@ export function mountListRoute(opts: SingleVerbOptions): void {
137
147
  if (sortParse.error) {
138
148
  // `field` is REQUIRED on the invalid_sort envelope cross-port (F20 ruling) —
139
149
  // it names the offending sort field instead of making the caller guess.
140
- return reply.code(400).send({ error: contractErrorCode(sortParse.error), field: sortParse.field });
150
+ return reply.code(400).send({
151
+ error: contractErrorCode(sortParse.error),
152
+ field: sortParse.field,
153
+ expected: SORT_EXPECTED,
154
+ allowed: Object.keys(opts.sortAllowlist),
155
+ });
141
156
  }
142
157
  if (sortParse.orderBy) readOpts.orderBy = sortParse.orderBy;
143
158
  }
@@ -304,7 +319,7 @@ export interface MetaRouteOptions {
304
319
  */
305
320
  export function mountMetaRoute(opts: MetaRouteOptions): void {
306
321
  const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
307
- opts.fastify.get(path, async (_req, reply) => {
322
+ opts.fastify.get(path, withContractErrorHandler(undefined), async (_req, reply) => {
308
323
  return reply
309
324
  .code(200)
310
325
  .header("content-type", META_CONTENT_TYPE)
package/src/hono/index.ts CHANGED
@@ -29,6 +29,7 @@ import qs from "qs";
29
29
  import { contractErrorCode } from "../drizzle-fastify/util.js";
30
30
  import {
31
31
  parseFilterParams,
32
+ parsePageBound,
32
33
  FilterParseError,
33
34
  type ParseFilterResult,
34
35
  } from "../drizzle-fastify/filter-parser.js";
@@ -42,6 +43,10 @@ export type {
42
43
  } from "../drizzle-fastify/filter-allowlist.js";
43
44
  import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
44
45
  import { timestampWire } from "../timestamp-wire.js";
46
+ // Every handler below is wrapped in guardRoute (an unexpected error answers
47
+ // `500 { error: "internal" }`, logged server-side) and reads its body through
48
+ // readJsonBody (a malformed body answers `400 { error: "invalid_json" }`).
49
+ import { guardRoute, readJsonBody } from "./route-guard.js";
45
50
  // Back-compat re-export — the unsafe local copy was consolidated onto the one
46
51
  // shared (deprecated) helper so the three adapters can't silently diverge.
47
52
  export { parseId } from "../drizzle-fastify/util.js";
@@ -175,7 +180,7 @@ async function reReadThroughView(
175
180
 
176
181
  export function mountListRoute(opts: VerbOptions): void {
177
182
  const toWire = readWire(opts);
178
- opts.app.get(opts.path, async (c) => {
183
+ opts.app.get(opts.path, guardRoute(async (c) => {
179
184
  try {
180
185
  const listSrc = readSource(opts);
181
186
  let q = opts.db.select().from(listSrc).$dynamic();
@@ -200,9 +205,10 @@ export function mountListRoute(opts: VerbOptions): void {
200
205
  if (parsed.offset !== undefined) q = q.offset(parsed.offset);
201
206
  } else {
202
207
  // No allowlists configured. Only limit/offset.
203
- const flat = c.req.query();
204
- if (flat.limit !== undefined) q = q.limit(Number(flat.limit));
205
- if (flat.offset !== undefined) q = q.offset(Number(flat.offset));
208
+ const limit = parsePageBound(qsParsed, "limit");
209
+ const offset = parsePageBound(qsParsed, "offset");
210
+ if (limit !== undefined) q = q.limit(limit);
211
+ if (offset !== undefined) q = q.offset(offset);
206
212
  }
207
213
 
208
214
  // Await the query directly rather than calling `.all()`: the drizzle-orm
@@ -230,12 +236,12 @@ export function mountListRoute(opts: VerbOptions): void {
230
236
  }
231
237
  throw err;
232
238
  }
233
- });
239
+ }));
234
240
  }
235
241
 
236
242
  export function mountGetRoute(opts: VerbOptions): void {
237
243
  const toWire = readWire(opts);
238
- opts.app.get(`${opts.path}/:id`, async (c) => {
244
+ opts.app.get(`${opts.path}/:id`, guardRoute(async (c) => {
239
245
  const id = c.req.param("id") ?? "";
240
246
  // Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
241
247
  // must stay a string ('0123' ≠ '123'), or affinity matches the WRONG row.
@@ -244,14 +250,15 @@ export function mountGetRoute(opts: VerbOptions): void {
244
250
  if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
245
251
  const row = await firstRow(opts.db, getSrc, eq(getSrc.id, idValue));
246
252
  return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
247
- });
253
+ }));
248
254
  }
249
255
 
250
256
  export function mountCreateRoute(opts: VerbOptions): void {
251
257
  const toWire = echoWire(opts);
252
- opts.app.post(opts.path, async (c) => {
253
- const body = await c.req.json().catch(() => undefined);
254
- const parsed = opts.insertSchema.safeParse(body);
258
+ opts.app.post(opts.path, guardRoute(async (c) => {
259
+ const read = await readJsonBody(c);
260
+ if (!read.ok) return read.response;
261
+ const parsed = opts.insertSchema.safeParse(read.body);
255
262
  if (!parsed.success) {
256
263
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
257
264
  }
@@ -269,15 +276,16 @@ export function mountCreateRoute(opts: VerbOptions): void {
269
276
  const row = (result as unknown[])[0];
270
277
  // Echo the row through the replica view so derived columns are present (#214).
271
278
  return c.json(toWire(await reReadThroughView(opts, row)), 201);
272
- });
279
+ }));
273
280
  }
274
281
 
275
282
  export function mountUpdateRoute(opts: VerbOptions): void {
276
283
  const toWire = echoWire(opts);
277
- const handler = async (c: Context) => {
284
+ const handler = guardRoute(async (c: Context) => {
278
285
  const id = c.req.param("id") ?? "";
279
- const body = await c.req.json().catch(() => undefined);
280
- const parsed = opts.updateSchema.safeParse(body);
286
+ const read = await readJsonBody(c);
287
+ if (!read.ok) return read.response;
288
+ const parsed = opts.updateSchema.safeParse(read.body);
281
289
  if (!parsed.success) {
282
290
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
283
291
  }
@@ -308,7 +316,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
308
316
  }
309
317
  const row = (result as unknown[])[0];
310
318
  return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
311
- };
319
+ });
312
320
  const path = `${opts.path}/:id`;
313
321
  // Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
314
322
  // and PUT, each routed to the same handler — as the drizzle-fastify flavor and
@@ -325,7 +333,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
325
333
  }
326
334
 
327
335
  export function mountDeleteRoute(opts: VerbOptions): void {
328
- opts.app.delete(`${opts.path}/:id`, async (c) => {
336
+ opts.app.delete(`${opts.path}/:id`, guardRoute(async (c) => {
329
337
  const id = c.req.param("id") ?? "";
330
338
  // Compare against the PK's real type (see mountGetRoute) — a numeric-
331
339
  // LOOKING id on a TEXT pk would otherwise DELETE the wrong row (data loss).
@@ -346,7 +354,7 @@ export function mountDeleteRoute(opts: VerbOptions): void {
346
354
  return c.body(null, 204);
347
355
  }
348
356
  return c.json({ error: "not_found" }, 404);
349
- });
357
+ }));
350
358
  }
351
359
 
352
360
  function extractRowCount(result: unknown): number {
@@ -387,7 +395,7 @@ export interface HonoMetaRouteOptions {
387
395
  */
388
396
  export function mountMetaRouteHono(opts: HonoMetaRouteOptions): void {
389
397
  const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
390
- opts.app.get(path, (c) =>
398
+ opts.app.get(path, guardRoute((c) =>
391
399
  c.body(metaJson(opts.root), 200, { "content-type": META_CONTENT_TYPE }),
392
- );
400
+ ));
393
401
  }
@@ -5,13 +5,15 @@
5
5
  import type { Hono } from "hono";
6
6
  import { sql, eq, and, count } from "drizzle-orm";
7
7
  import qs from "qs";
8
- import { parseFilterParams, FilterParseError } from "../drizzle-fastify/filter-parser.js";
8
+ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "../drizzle-fastify/filter-parser.js";
9
9
  import type {
10
10
  FilterAllowlist,
11
11
  SortAllowlist,
12
12
  } from "../drizzle-fastify/filter-allowlist.js";
13
13
  import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
14
14
  import { timestampWire } from "../timestamp-wire.js";
15
+ // An unexpected error on a mounted route answers `500 { error: "internal" }`.
16
+ import { guardRoute } from "./route-guard.js";
15
17
 
16
18
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
17
19
  type AnyView = any;
@@ -107,21 +109,16 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
107
109
  const toWire = timestampWire(view);
108
110
 
109
111
  // ── List ──────────────────────────────────────────────────────────────────
110
- app.get(path, async (c) => {
112
+ app.get(path, guardRoute(async (c) => {
111
113
  try {
112
114
  if (useRawSql) {
113
115
  const url = c.req.url;
114
116
  const qIdx = url.indexOf("?");
115
117
  const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
116
118
  const parsed = qs.parse(queryString) as Record<string, unknown>;
117
- const limitVal = Math.min(
118
- 1000,
119
- Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)),
120
- );
121
- const offsetVal = Math.max(
122
- 0,
123
- Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0),
124
- );
119
+ // A bound that is not an integer in range is a 400, not silently clamped.
120
+ const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
121
+ const offsetVal = parsePageBound(parsed, "offset") ?? 0;
125
122
  const withCount = isTruthyFlag(parsed["withCount"]);
126
123
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
127
124
  const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
@@ -175,10 +172,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
175
172
  }
176
173
  throw err;
177
174
  }
178
- });
175
+ }));
179
176
 
180
177
  // ── Get by ID ─────────────────────────────────────────────────────────────
181
- app.get(`${path}/:id`, async (c) => {
178
+ app.get(`${path}/:id`, guardRoute(async (c) => {
182
179
  const id = c.req.param("id") ?? "";
183
180
  if (useRawSql) {
184
181
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
@@ -202,7 +199,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
202
199
  .limit(1);
203
200
  const row = (rows as unknown[])[0];
204
201
  return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
205
- });
202
+ }));
206
203
 
207
204
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
208
205
  const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Per-handler error guard for the Hono mount helpers — the Hono twin of
3
+ * drizzle-fastify's route-scoped error handler (route-error-handler.ts).
4
+ *
5
+ * Hono has no per-route error handler: `app.onError` is app-wide, so installing one from
6
+ * a mount would change how the adopter's OTHER routes answer. Wrapping each handler the
7
+ * mount registers keeps the change to exactly the routes it owns.
8
+ *
9
+ * An unexpected error is logged server-side and answered `500 { "error": "internal" }`.
10
+ * Hono's default would answer a bare-text `Internal Server Error` — no leak, but not the
11
+ * contract's JSON envelope, and different from the Fastify flavor this adapter promises
12
+ * to match on the wire. An `HTTPException` (anything carrying `getResponse`) was thrown
13
+ * on purpose and is rethrown to the adopter's `onError` / Hono's default untouched.
14
+ */
15
+
16
+ import type { Context } from "hono";
17
+ import {
18
+ HTTP_STATUS_BAD_REQUEST,
19
+ HTTP_STATUS_INTERNAL,
20
+ INTERNAL_ERROR_BODY,
21
+ INVALID_JSON_BODY,
22
+ logUnexpectedError,
23
+ } from "../route-errors.js";
24
+
25
+ type RouteHandler = (c: Context) => Promise<Response> | Response;
26
+
27
+ function isHttpException(err: unknown): boolean {
28
+ return typeof (err as { getResponse?: unknown } | null)?.getResponse === "function";
29
+ }
30
+
31
+ export function guardRoute(handler: RouteHandler): RouteHandler {
32
+ return async (c: Context) => {
33
+ try {
34
+ return await handler(c);
35
+ } catch (err) {
36
+ if (isHttpException(err)) throw err;
37
+ logUnexpectedError(err);
38
+ return c.json(INTERNAL_ERROR_BODY, HTTP_STATUS_INTERNAL);
39
+ }
40
+ };
41
+ }
42
+
43
+ /** The outcome of reading a JSON request body: the value, or the 400 to answer. */
44
+ export type JsonBodyRead = { ok: true; body: unknown } | { ok: false; response: Response };
45
+
46
+ /**
47
+ * Read the request body as JSON. A body that does not parse — including an empty one —
48
+ * answers `400 { "error": "invalid_json" }`, matching the Fastify flavor. Before, the
49
+ * parse failure was swallowed into `undefined` and surfaced as a Zod `validation` error
50
+ * complaining that the body was not an object, which named the wrong problem.
51
+ */
52
+ export async function readJsonBody(c: Context): Promise<JsonBodyRead> {
53
+ try {
54
+ return { ok: true, body: await c.req.json() };
55
+ } catch {
56
+ return { ok: false, response: c.json(INVALID_JSON_BODY, HTTP_STATUS_BAD_REQUEST) };
57
+ }
58
+ }