@metaobjectsdev/runtime-ts 1.0.8 → 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.
- package/dist/drizzle-fastify/filter-allowlist.d.ts +23 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-allowlist.js +13 -1
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +73 -10
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts +21 -0
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-value-format.js +54 -0
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +5 -1
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +3 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +4 -1
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/route-error-handler.d.ts +27 -0
- package/dist/drizzle-fastify/route-error-handler.d.ts.map +1 -0
- package/dist/drizzle-fastify/route-error-handler.js +41 -0
- package/dist/drizzle-fastify/route-error-handler.js.map +1 -0
- package/dist/fastify/index.d.ts.map +1 -1
- package/dist/fastify/index.js +8 -5
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +23 -15
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +6 -4
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/hono/route-guard.d.ts +34 -0
- package/dist/hono/route-guard.d.ts.map +1 -0
- package/dist/hono/route-guard.js +46 -0
- package/dist/hono/route-guard.js.map +1 -0
- package/dist/route-errors.d.ts +50 -0
- package/dist/route-errors.d.ts.map +1 -0
- package/dist/route-errors.js +67 -0
- package/dist/route-errors.js.map +1 -0
- package/package.json +3 -3
- package/src/drizzle-fastify/filter-allowlist.ts +33 -0
- package/src/drizzle-fastify/filter-parser.ts +89 -10
- package/src/drizzle-fastify/filter-value-format.ts +68 -0
- package/src/drizzle-fastify/index.ts +5 -1
- package/src/drizzle-fastify/mount-m2m.ts +3 -1
- package/src/drizzle-fastify/mount-read-only.ts +4 -1
- package/src/drizzle-fastify/route-error-handler.ts +54 -0
- package/src/fastify/index.ts +8 -5
- package/src/hono/index.ts +22 -16
- package/src/hono/mount-read-only.ts +6 -4
- package/src/hono/route-guard.ts +58 -0
- 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.
|
|
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.
|
|
68
|
-
"@metaobjectsdev/render": "1.0.
|
|
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;
|
|
@@ -37,7 +38,37 @@ export class FilterParseError extends Error {
|
|
|
37
38
|
const DEFAULT_MAX_NESTING = 5;
|
|
38
39
|
const DEFAULT_MAX_IN_LIST = 100;
|
|
39
40
|
|
|
41
|
+
/** Top-level list-query parameters this parser (or the mount around it) reads. A
|
|
42
|
+
* filterable field that happens to share one of these names is addressed only through
|
|
43
|
+
* `filter[<name>]`, so the bare-field check below never claims them. */
|
|
44
|
+
const RESERVED_LIST_PARAMS: ReadonlySet<string> = new Set([
|
|
45
|
+
"filter", "sort", "limit", "offset", "search", "withCount",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `?priority=low` on a list whose allowlist has `priority` is almost always a caller who
|
|
50
|
+
* meant `?filter[priority][eq]=low` — and silently ignoring it returns EVERY row, which
|
|
51
|
+
* reads as "the filter matched everything". Refuse it, naming the syntax that works.
|
|
52
|
+
*
|
|
53
|
+
* Only a bare name that IS a filterable field is refused. Any other unknown parameter —
|
|
54
|
+
* a cache-buster, a tracking tag, a param some proxy appends — is left alone, as before.
|
|
55
|
+
* TS-only (the other ports ignore unknown parameters): see docs/features/api-contract.md,
|
|
56
|
+
* "TS-only filter extensions".
|
|
57
|
+
*/
|
|
58
|
+
function rejectBareFieldParams(query: Record<string, unknown>, allowlist: FilterAllowlist): void {
|
|
59
|
+
for (const [key, value] of Object.entries(query)) {
|
|
60
|
+
if (RESERVED_LIST_PARAMS.has(key) || !Object.hasOwn(allowlist, key)) continue;
|
|
61
|
+
const shown = typeof value === "string" ? value : "<value>";
|
|
62
|
+
throw new FilterParseError(
|
|
63
|
+
"filter.bare_field",
|
|
64
|
+
`"${key}" is a filterable field, but a bare ?${key}= parameter is not a filter. Use filter[${key}][eq]=${shown}.`,
|
|
65
|
+
{ field: key, expected: `filter[${key}][eq]=${shown}` },
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
|
|
71
|
+
rejectBareFieldParams(opts.query, opts.allowlist);
|
|
41
72
|
const result: ParseFilterResult = {};
|
|
42
73
|
|
|
43
74
|
const limit = opts.query.limit;
|
|
@@ -154,14 +185,14 @@ function compileOp(
|
|
|
154
185
|
throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
|
|
155
186
|
}
|
|
156
187
|
switch (op as FilterOp) {
|
|
157
|
-
case "eq": return eq(col as any, coerce(value, rule
|
|
158
|
-
case "ne": return ne(col as any, coerce(value, rule
|
|
159
|
-
case "gt": return gt(col as any, coerce(value, rule
|
|
160
|
-
case "gte": return gte(col as any, coerce(value, rule
|
|
161
|
-
case "lt": return lt(col as any, coerce(value, rule
|
|
162
|
-
case "lte": return lte(col as any, coerce(value, rule
|
|
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));
|
|
163
194
|
case "in": {
|
|
164
|
-
const list = String(value).split(",").map((v) => coerce(v.trim(), rule
|
|
195
|
+
const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
|
|
165
196
|
if (list.length > maxInList) {
|
|
166
197
|
throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
|
|
167
198
|
}
|
|
@@ -186,7 +217,7 @@ function compileOp(
|
|
|
186
217
|
// isNull's value is always coerced as boolean (true/false), regardless of the
|
|
187
218
|
// field's declared subType — the operator is "is the value null?", not "is X
|
|
188
219
|
// equal to null?". Field subtype is irrelevant.
|
|
189
|
-
const b =
|
|
220
|
+
const b = coerceAs(value, "boolean", field, op) as boolean;
|
|
190
221
|
return b ? isNull(col as any) : not(isNull(col as any));
|
|
191
222
|
}
|
|
192
223
|
}
|
|
@@ -213,7 +244,54 @@ export function likePatternToGlob(pattern: string): string {
|
|
|
213
244
|
return out;
|
|
214
245
|
}
|
|
215
246
|
|
|
216
|
-
function
|
|
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 {
|
|
217
295
|
if (value === null || value === undefined) return null;
|
|
218
296
|
const s = typeof value === "string" ? value : String(value);
|
|
219
297
|
switch (subType) {
|
|
@@ -224,7 +302,8 @@ function coerce(value: unknown, subType: string, field: string, op: string, date
|
|
|
224
302
|
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires boolean, got "${s}".`, { field, op, expected: "boolean" });
|
|
225
303
|
}
|
|
226
304
|
case "number": {
|
|
227
|
-
|
|
305
|
+
// `Number("")` and `Number(" ")` are 0 — an empty bound is not the number zero.
|
|
306
|
+
const n = s.trim() === "" ? Number.NaN : Number(s);
|
|
228
307
|
if (!Number.isFinite(n)) {
|
|
229
308
|
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires number, got "${s}".`, { field, op, expected: "number" });
|
|
230
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/fastify/index.ts
CHANGED
|
@@ -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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
254
|
-
|
|
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
|
|
280
|
-
|
|
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
|
+
}
|