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

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 (54) 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.map +1 -1
  6. package/dist/drizzle-fastify/filter-parser.js +48 -10
  7. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  8. package/dist/drizzle-fastify/filter-value-format.d.ts +21 -0
  9. package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -0
  10. package/dist/drizzle-fastify/filter-value-format.js +54 -0
  11. package/dist/drizzle-fastify/filter-value-format.js.map +1 -0
  12. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  13. package/dist/drizzle-fastify/index.js +5 -1
  14. package/dist/drizzle-fastify/index.js.map +1 -1
  15. package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
  16. package/dist/drizzle-fastify/mount-m2m.js +3 -1
  17. package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
  18. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  19. package/dist/drizzle-fastify/mount-read-only.js +4 -1
  20. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  21. package/dist/drizzle-fastify/route-error-handler.d.ts +27 -0
  22. package/dist/drizzle-fastify/route-error-handler.d.ts.map +1 -0
  23. package/dist/drizzle-fastify/route-error-handler.js +41 -0
  24. package/dist/drizzle-fastify/route-error-handler.js.map +1 -0
  25. package/dist/fastify/index.d.ts.map +1 -1
  26. package/dist/fastify/index.js +8 -5
  27. package/dist/fastify/index.js.map +1 -1
  28. package/dist/hono/index.d.ts.map +1 -1
  29. package/dist/hono/index.js +23 -15
  30. package/dist/hono/index.js.map +1 -1
  31. package/dist/hono/mount-read-only.d.ts.map +1 -1
  32. package/dist/hono/mount-read-only.js +6 -4
  33. package/dist/hono/mount-read-only.js.map +1 -1
  34. package/dist/hono/route-guard.d.ts +34 -0
  35. package/dist/hono/route-guard.d.ts.map +1 -0
  36. package/dist/hono/route-guard.js +46 -0
  37. package/dist/hono/route-guard.js.map +1 -0
  38. package/dist/route-errors.d.ts +50 -0
  39. package/dist/route-errors.d.ts.map +1 -0
  40. package/dist/route-errors.js +67 -0
  41. package/dist/route-errors.js.map +1 -0
  42. package/package.json +3 -3
  43. package/src/drizzle-fastify/filter-allowlist.ts +33 -0
  44. package/src/drizzle-fastify/filter-parser.ts +59 -10
  45. package/src/drizzle-fastify/filter-value-format.ts +68 -0
  46. package/src/drizzle-fastify/index.ts +5 -1
  47. package/src/drizzle-fastify/mount-m2m.ts +3 -1
  48. package/src/drizzle-fastify/mount-read-only.ts +4 -1
  49. package/src/drizzle-fastify/route-error-handler.ts +54 -0
  50. package/src/fastify/index.ts +8 -5
  51. package/src/hono/index.ts +22 -16
  52. package/src/hono/mount-read-only.ts +6 -4
  53. package/src/hono/route-guard.ts +58 -0
  54. package/src/route-errors.ts +77 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/runtime-ts",
3
- "version": "1.0.9-rc.1",
3
+ "version": "1.0.9-rc.2",
4
4
  "description": "Node-side runtime helpers for MetaObjects: Fastify route builders, Drizzle filter/sort integration, Kysely drivers.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -64,8 +64,8 @@
64
64
  "access": "public"
65
65
  },
66
66
  "dependencies": {
67
- "@metaobjectsdev/metadata": "1.0.9-rc.1",
68
- "@metaobjectsdev/render": "1.0.9-rc.1",
67
+ "@metaobjectsdev/metadata": "1.0.9-rc.2",
68
+ "@metaobjectsdev/render": "1.0.9-rc.2",
69
69
  "qs": "^6.13.0"
70
70
  },
71
71
  "peerDependencies": {
@@ -10,12 +10,30 @@ import {
10
10
  FILTER_OPS,
11
11
  OPS_BY_SUBTYPE,
12
12
  opsForSubType,
13
+ FIELD_SUBTYPE_DATE,
14
+ FIELD_SUBTYPE_TIME,
15
+ FIELD_SUBTYPE_TIMESTAMP,
16
+ FIELD_SUBTYPE_UUID,
13
17
  type FilterOp,
14
18
  } from "@metaobjectsdev/metadata";
15
19
  export { FILTER_OPS, OPS_BY_SUBTYPE, opsForSubType, type FilterOp };
16
20
 
17
21
  export type FilterSubType = "string" | "number" | "boolean" | "datetime";
18
22
 
23
+ /**
24
+ * The wire formats a filter value can be checked against, beyond the coarse
25
+ * `FilterSubType`. Each is the `field.*` subtype whose Tier-1 wire encoding
26
+ * (docs/features/api-contract.md, "Type encodings") the value must match — so the
27
+ * values ARE the metamodel subtype names, not a second vocabulary.
28
+ */
29
+ export const FILTER_VALUE_FORMATS = [
30
+ FIELD_SUBTYPE_DATE,
31
+ FIELD_SUBTYPE_TIME,
32
+ FIELD_SUBTYPE_TIMESTAMP,
33
+ FIELD_SUBTYPE_UUID,
34
+ ] as const;
35
+ export type FilterValueFormat = (typeof FILTER_VALUE_FORMATS)[number];
36
+
19
37
  export interface FilterFieldRule {
20
38
  readonly ops: readonly FilterOp[];
21
39
  readonly subType: FilterSubType;
@@ -32,6 +50,21 @@ export interface FilterFieldRule {
32
50
  * both as strings under every dialect, so they are not governed by `timestampMode`.
33
51
  */
34
52
  readonly dateValues?: boolean;
53
+ /**
54
+ * The field's exact wire format, set by codegen for `field.date` / `field.time` /
55
+ * `field.timestamp` / `field.uuid`. The filter parser rejects a value that does not
56
+ * match it with `invalid_filter_value` instead of binding it — on SQLite a malformed
57
+ * date compared as text and silently matched nothing; on Postgres it was a driver
58
+ * error. Absent (an allowlist generated before this existed), a `datetime` value is
59
+ * still checked against the union of the three temporal formats.
60
+ */
61
+ readonly format?: FilterValueFormat;
62
+ /**
63
+ * The declared members of a `field.enum` (`@values`), set by codegen. A value that is
64
+ * not a member is rejected with `invalid_filter_value`. Absent, the parser falls back
65
+ * to the Drizzle column's own `enumValues` when it has them.
66
+ */
67
+ readonly enumValues?: readonly string[];
35
68
  }
36
69
 
37
70
  export type FilterAllowlist = Readonly<Record<string, FilterFieldRule>>;
@@ -4,6 +4,7 @@ import {
4
4
  } from "drizzle-orm";
5
5
  import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
6
6
  import { sortOrderSpec } from "./filter-allowlist.js";
7
+ import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, matchesAnyTemporal, matchesFormat } from "./filter-value-format.js";
7
8
 
8
9
  // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
9
10
  type AnyTable = any;
@@ -184,14 +185,14 @@ function compileOp(
184
185
  throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
185
186
  }
186
187
  switch (op as FilterOp) {
187
- case "eq": return eq(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
188
- case "ne": return ne(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
189
- case "gt": return gt(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
190
- case "gte": return gte(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
191
- case "lt": return lt(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
192
- case "lte": return lte(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
188
+ case "eq": return eq(col as any, coerce(value, rule, col, field, op));
189
+ case "ne": return ne(col as any, coerce(value, rule, col, field, op));
190
+ case "gt": return gt(col as any, coerce(value, rule, col, field, op));
191
+ case "gte": return gte(col as any, coerce(value, rule, col, field, op));
192
+ case "lt": return lt(col as any, coerce(value, rule, col, field, op));
193
+ case "lte": return lte(col as any, coerce(value, rule, col, field, op));
193
194
  case "in": {
194
- const list = String(value).split(",").map((v) => coerce(v.trim(), rule.subType, field, op, rule.dateValues));
195
+ const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
195
196
  if (list.length > maxInList) {
196
197
  throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
197
198
  }
@@ -216,7 +217,7 @@ function compileOp(
216
217
  // isNull's value is always coerced as boolean (true/false), regardless of the
217
218
  // field's declared subType — the operator is "is the value null?", not "is X
218
219
  // equal to null?". Field subtype is irrelevant.
219
- const b = coerce(value, "boolean", field, op) as boolean;
220
+ const b = coerceAs(value, "boolean", field, op) as boolean;
220
221
  return b ? isNull(col as any) : not(isNull(col as any));
221
222
  }
222
223
  }
@@ -243,7 +244,54 @@ export function likePatternToGlob(pattern: string): string {
243
244
  return out;
244
245
  }
245
246
 
246
- function coerce(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown {
247
+ function invalidValue(
248
+ field: string,
249
+ op: string,
250
+ s: string,
251
+ expected: string,
252
+ extra: Record<string, unknown> = {},
253
+ ): FilterParseError {
254
+ return new FilterParseError(
255
+ "filter.invalid_value",
256
+ `Field "${field}" op "${op}" requires ${expected}, got "${s}".`,
257
+ { field, op, expected, ...extra },
258
+ );
259
+ }
260
+
261
+ /** A Drizzle column's own enum members (`text(..., { enum })`, `pgEnum`), if it has them. */
262
+ function columnEnumValues(col: unknown): readonly string[] | undefined {
263
+ const v = (col as { enumValues?: unknown } | null | undefined)?.enumValues;
264
+ return Array.isArray(v) && v.length > 0 && v.every((m) => typeof m === "string") ? (v as string[]) : undefined;
265
+ }
266
+
267
+ /**
268
+ * Refuse a value that cannot be the field's type BEFORE it is bound. Without this a
269
+ * malformed date on SQLite compared as text and silently matched nothing (200 `[]`),
270
+ * and on Postgres the driver refused the cast (a 500) — either way the caller could not
271
+ * tell a typo from an empty result. The envelope is the cross-port `invalid_filter_value`,
272
+ * carrying `op` and `expected` as the number/boolean checks already did.
273
+ */
274
+ function assertWellFormed(s: string, rule: FilterFieldRule, col: unknown, field: string, op: string): void {
275
+ const members = rule.enumValues ?? columnEnumValues(col);
276
+ if (members !== undefined && !members.includes(s)) {
277
+ throw invalidValue(field, op, s, `one of: ${members.join(", ")}`, { allowed: [...members] });
278
+ }
279
+ if (rule.format !== undefined) {
280
+ if (!matchesFormat(rule.format, s)) throw invalidValue(field, op, s, FORMAT_EXPECTED[rule.format]);
281
+ } else if (rule.subType === "datetime" && !matchesAnyTemporal(s)) {
282
+ throw invalidValue(field, op, s, ANY_TEMPORAL_EXPECTED);
283
+ }
284
+ }
285
+
286
+ /** Check a comparison value against the field's rule, then coerce it for binding. */
287
+ function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string): unknown {
288
+ if (value === null || value === undefined) return null;
289
+ const s = typeof value === "string" ? value : String(value);
290
+ assertWellFormed(s, rule, col, field, op);
291
+ return coerceAs(s, rule.subType, field, op, rule.dateValues);
292
+ }
293
+
294
+ function coerceAs(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown {
247
295
  if (value === null || value === undefined) return null;
248
296
  const s = typeof value === "string" ? value : String(value);
249
297
  switch (subType) {
@@ -254,7 +302,8 @@ function coerce(value: unknown, subType: string, field: string, op: string, date
254
302
  throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires boolean, got "${s}".`, { field, op, expected: "boolean" });
255
303
  }
256
304
  case "number": {
257
- const n = Number(s);
305
+ // `Number("")` and `Number(" ")` are 0 — an empty bound is not the number zero.
306
+ const n = s.trim() === "" ? Number.NaN : Number(s);
258
307
  if (!Number.isFinite(n)) {
259
308
  throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires number, got "${s}".`, { field, op, expected: "number" });
260
309
  }
@@ -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
+ }
@@ -29,6 +29,7 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
29
29
  import { parseFilterParams, 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 {
@@ -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) => {
@@ -5,6 +5,7 @@ import { parseFilterParams, 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;
@@ -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,7 @@ 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 { withContractErrorHandler } from "../drizzle-fastify/route-error-handler.js";
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Public surface
@@ -107,12 +108,14 @@ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
107
108
  type SingleVerbOptions = Omit<CrudRoutesOptions, "expose">;
108
109
 
109
110
  /**
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.
111
+ * Build the route options Fastify wants: the consumer's routeOptions plus the
112
+ * route-scoped contract error handler shared with drizzle-fastify (an unexpected
113
+ * error answers `500 { error: "internal" }` instead of Fastify's default, which
114
+ * echoes the driver message; a malformed JSON body answers
115
+ * `400 { error: "invalid_json" }`). A consumer-supplied `errorHandler` wins.
113
116
  */
114
117
  function routeOpts(opts: SingleVerbOptions): RouteShorthandOptions {
115
- return opts.routeOptions ?? {};
118
+ return withContractErrorHandler(opts.routeOptions);
116
119
  }
117
120
 
118
121
  export function mountListRoute(opts: SingleVerbOptions): void {
@@ -304,7 +307,7 @@ export interface MetaRouteOptions {
304
307
  */
305
308
  export function mountMetaRoute(opts: MetaRouteOptions): void {
306
309
  const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
307
- opts.fastify.get(path, async (_req, reply) => {
310
+ opts.fastify.get(path, withContractErrorHandler(undefined), async (_req, reply) => {
308
311
  return reply
309
312
  .code(200)
310
313
  .header("content-type", META_CONTENT_TYPE)
package/src/hono/index.ts CHANGED
@@ -42,6 +42,10 @@ export type {
42
42
  } from "../drizzle-fastify/filter-allowlist.js";
43
43
  import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
44
44
  import { timestampWire } from "../timestamp-wire.js";
45
+ // Every handler below is wrapped in guardRoute (an unexpected error answers
46
+ // `500 { error: "internal" }`, logged server-side) and reads its body through
47
+ // readJsonBody (a malformed body answers `400 { error: "invalid_json" }`).
48
+ import { guardRoute, readJsonBody } from "./route-guard.js";
45
49
  // Back-compat re-export — the unsafe local copy was consolidated onto the one
46
50
  // shared (deprecated) helper so the three adapters can't silently diverge.
47
51
  export { parseId } from "../drizzle-fastify/util.js";
@@ -175,7 +179,7 @@ async function reReadThroughView(
175
179
 
176
180
  export function mountListRoute(opts: VerbOptions): void {
177
181
  const toWire = readWire(opts);
178
- opts.app.get(opts.path, async (c) => {
182
+ opts.app.get(opts.path, guardRoute(async (c) => {
179
183
  try {
180
184
  const listSrc = readSource(opts);
181
185
  let q = opts.db.select().from(listSrc).$dynamic();
@@ -230,12 +234,12 @@ export function mountListRoute(opts: VerbOptions): void {
230
234
  }
231
235
  throw err;
232
236
  }
233
- });
237
+ }));
234
238
  }
235
239
 
236
240
  export function mountGetRoute(opts: VerbOptions): void {
237
241
  const toWire = readWire(opts);
238
- opts.app.get(`${opts.path}/:id`, async (c) => {
242
+ opts.app.get(`${opts.path}/:id`, guardRoute(async (c) => {
239
243
  const id = c.req.param("id") ?? "";
240
244
  // Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
241
245
  // must stay a string ('0123' ≠ '123'), or affinity matches the WRONG row.
@@ -244,14 +248,15 @@ export function mountGetRoute(opts: VerbOptions): void {
244
248
  if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
245
249
  const row = await firstRow(opts.db, getSrc, eq(getSrc.id, idValue));
246
250
  return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
247
- });
251
+ }));
248
252
  }
249
253
 
250
254
  export function mountCreateRoute(opts: VerbOptions): void {
251
255
  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);
256
+ opts.app.post(opts.path, guardRoute(async (c) => {
257
+ const read = await readJsonBody(c);
258
+ if (!read.ok) return read.response;
259
+ const parsed = opts.insertSchema.safeParse(read.body);
255
260
  if (!parsed.success) {
256
261
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
257
262
  }
@@ -269,15 +274,16 @@ export function mountCreateRoute(opts: VerbOptions): void {
269
274
  const row = (result as unknown[])[0];
270
275
  // Echo the row through the replica view so derived columns are present (#214).
271
276
  return c.json(toWire(await reReadThroughView(opts, row)), 201);
272
- });
277
+ }));
273
278
  }
274
279
 
275
280
  export function mountUpdateRoute(opts: VerbOptions): void {
276
281
  const toWire = echoWire(opts);
277
- const handler = async (c: Context) => {
282
+ const handler = guardRoute(async (c: Context) => {
278
283
  const id = c.req.param("id") ?? "";
279
- const body = await c.req.json().catch(() => undefined);
280
- const parsed = opts.updateSchema.safeParse(body);
284
+ const read = await readJsonBody(c);
285
+ if (!read.ok) return read.response;
286
+ const parsed = opts.updateSchema.safeParse(read.body);
281
287
  if (!parsed.success) {
282
288
  return c.json({ error: "validation", issues: parsed.error.issues }, 400);
283
289
  }
@@ -308,7 +314,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
308
314
  }
309
315
  const row = (result as unknown[])[0];
310
316
  return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
311
- };
317
+ });
312
318
  const path = `${opts.path}/:id`;
313
319
  // Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
314
320
  // and PUT, each routed to the same handler — as the drizzle-fastify flavor and
@@ -325,7 +331,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
325
331
  }
326
332
 
327
333
  export function mountDeleteRoute(opts: VerbOptions): void {
328
- opts.app.delete(`${opts.path}/:id`, async (c) => {
334
+ opts.app.delete(`${opts.path}/:id`, guardRoute(async (c) => {
329
335
  const id = c.req.param("id") ?? "";
330
336
  // Compare against the PK's real type (see mountGetRoute) — a numeric-
331
337
  // LOOKING id on a TEXT pk would otherwise DELETE the wrong row (data loss).
@@ -346,7 +352,7 @@ export function mountDeleteRoute(opts: VerbOptions): void {
346
352
  return c.body(null, 204);
347
353
  }
348
354
  return c.json({ error: "not_found" }, 404);
349
- });
355
+ }));
350
356
  }
351
357
 
352
358
  function extractRowCount(result: unknown): number {
@@ -387,7 +393,7 @@ export interface HonoMetaRouteOptions {
387
393
  */
388
394
  export function mountMetaRouteHono(opts: HonoMetaRouteOptions): void {
389
395
  const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
390
- opts.app.get(path, (c) =>
396
+ opts.app.get(path, guardRoute((c) =>
391
397
  c.body(metaJson(opts.root), 200, { "content-type": META_CONTENT_TYPE }),
392
- );
398
+ ));
393
399
  }
@@ -12,6 +12,8 @@ import type {
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,7 +109,7 @@ 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;
@@ -175,10 +177,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
175
177
  }
176
178
  throw err;
177
179
  }
178
- });
180
+ }));
179
181
 
180
182
  // ── Get by ID ─────────────────────────────────────────────────────────────
181
- app.get(`${path}/:id`, async (c) => {
183
+ app.get(`${path}/:id`, guardRoute(async (c) => {
182
184
  const id = c.req.param("id") ?? "";
183
185
  if (useRawSql) {
184
186
  // biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
@@ -202,7 +204,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
202
204
  .limit(1);
203
205
  const row = (rows as unknown[])[0];
204
206
  return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
205
- });
207
+ }));
206
208
 
207
209
  // ── Mutations explicitly rejected (405) ───────────────────────────────────
208
210
  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
+ }