@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.
- 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 +1 -6
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +91 -42
- 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 +10 -5
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/list-params.d.ts +27 -0
- package/dist/drizzle-fastify/list-params.d.ts.map +1 -0
- package/dist/drizzle-fastify/list-params.js +43 -0
- package/dist/drizzle-fastify/list-params.js.map +1 -0
- 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 +8 -4
- 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 +28 -11
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +30 -21
- 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 +10 -7
- 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 +117 -39
- package/src/drizzle-fastify/filter-value-format.ts +68 -0
- package/src/drizzle-fastify/index.ts +10 -5
- package/src/drizzle-fastify/list-params.ts +50 -0
- package/src/drizzle-fastify/mount-m2m.ts +3 -1
- package/src/drizzle-fastify/mount-read-only.ts +8 -4
- package/src/drizzle-fastify/route-error-handler.ts +54 -0
- package/src/fastify/index.ts +24 -9
- package/src/hono/index.ts +27 -19
- package/src/hono/mount-read-only.ts +10 -13
- package/src/hono/route-guard.ts +58 -0
- package/src/route-errors.ts +77 -0
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// equivalent so the cross-port API contract holds for projection endpoints.
|
|
4
4
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
5
5
|
import qs from "qs";
|
|
6
|
-
import { parseFilterParams, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
6
|
+
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
7
7
|
import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
|
|
8
8
|
import { timestampWire } from "../timestamp-wire.js";
|
|
9
|
+
// An unexpected error on a mounted route answers `500 { error: "internal" }`.
|
|
10
|
+
import { guardRoute } from "./route-guard.js";
|
|
9
11
|
function resolveViewName(view) {
|
|
10
12
|
const cfg = viewBaseConfig(view);
|
|
11
13
|
if (cfg) {
|
|
@@ -75,15 +77,16 @@ export function mountReadOnlyCrudRoutes(opts) {
|
|
|
75
77
|
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
|
|
76
78
|
const toWire = timestampWire(view);
|
|
77
79
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
78
|
-
app.get(path, async (c) => {
|
|
80
|
+
app.get(path, guardRoute(async (c) => {
|
|
79
81
|
try {
|
|
80
82
|
if (useRawSql) {
|
|
81
83
|
const url = c.req.url;
|
|
82
84
|
const qIdx = url.indexOf("?");
|
|
83
85
|
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
84
86
|
const parsed = qs.parse(queryString);
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
+
// A bound that is not an integer in range is a 400, not silently clamped.
|
|
88
|
+
const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
|
|
89
|
+
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
87
90
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
88
91
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
89
92
|
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)));
|
|
@@ -142,9 +145,9 @@ export function mountReadOnlyCrudRoutes(opts) {
|
|
|
142
145
|
}
|
|
143
146
|
throw err;
|
|
144
147
|
}
|
|
145
|
-
});
|
|
148
|
+
}));
|
|
146
149
|
// ── Get by ID ─────────────────────────────────────────────────────────────
|
|
147
|
-
app.get(`${path}/:id`, async (c) => {
|
|
150
|
+
app.get(`${path}/:id`, guardRoute(async (c) => {
|
|
148
151
|
const id = c.req.param("id") ?? "";
|
|
149
152
|
if (useRawSql) {
|
|
150
153
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
@@ -168,7 +171,7 @@ export function mountReadOnlyCrudRoutes(opts) {
|
|
|
168
171
|
.limit(1);
|
|
169
172
|
const row = rows[0];
|
|
170
173
|
return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
|
|
171
|
-
});
|
|
174
|
+
}));
|
|
172
175
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
173
176
|
const reject = (c) => c.json({ error: "method_not_allowed", message: `${c.req.method} is not supported on a projection (read-only).` }, 405);
|
|
174
177
|
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mount-read-only.js","sourceRoot":"","sources":["../../src/hono/mount-read-only.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,8EAA8E;AAC9E,4EAA4E;AAG5E,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;
|
|
1
|
+
{"version":3,"file":"mount-read-only.js","sourceRoot":"","sources":["../../src/hono/mount-read-only.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
import type { Context } from "hono";
|
|
16
|
+
type RouteHandler = (c: Context) => Promise<Response> | Response;
|
|
17
|
+
export declare function guardRoute(handler: RouteHandler): RouteHandler;
|
|
18
|
+
/** The outcome of reading a JSON request body: the value, or the 400 to answer. */
|
|
19
|
+
export type JsonBodyRead = {
|
|
20
|
+
ok: true;
|
|
21
|
+
body: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
response: Response;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Read the request body as JSON. A body that does not parse — including an empty one —
|
|
28
|
+
* answers `400 { "error": "invalid_json" }`, matching the Fastify flavor. Before, the
|
|
29
|
+
* parse failure was swallowed into `undefined` and surfaced as a Zod `validation` error
|
|
30
|
+
* complaining that the body was not an object, which named the wrong problem.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readJsonBody(c: Context): Promise<JsonBodyRead>;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=route-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-guard.d.ts","sourceRoot":"","sources":["../../src/hono/route-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AASpC,KAAK,YAAY,GAAG,CAAC,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;AAMjE,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAU9D;AAED,mFAAmF;AACnF,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAE3F;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,YAAY,CAAC,CAMpE"}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_INTERNAL, INTERNAL_ERROR_BODY, INVALID_JSON_BODY, logUnexpectedError, } from "../route-errors.js";
|
|
16
|
+
function isHttpException(err) {
|
|
17
|
+
return typeof err?.getResponse === "function";
|
|
18
|
+
}
|
|
19
|
+
export function guardRoute(handler) {
|
|
20
|
+
return async (c) => {
|
|
21
|
+
try {
|
|
22
|
+
return await handler(c);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (isHttpException(err))
|
|
26
|
+
throw err;
|
|
27
|
+
logUnexpectedError(err);
|
|
28
|
+
return c.json(INTERNAL_ERROR_BODY, HTTP_STATUS_INTERNAL);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read the request body as JSON. A body that does not parse — including an empty one —
|
|
34
|
+
* answers `400 { "error": "invalid_json" }`, matching the Fastify flavor. Before, the
|
|
35
|
+
* parse failure was swallowed into `undefined` and surfaced as a Zod `validation` error
|
|
36
|
+
* complaining that the body was not an object, which named the wrong problem.
|
|
37
|
+
*/
|
|
38
|
+
export async function readJsonBody(c) {
|
|
39
|
+
try {
|
|
40
|
+
return { ok: true, body: await c.req.json() };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: false, response: c.json(INVALID_JSON_BODY, HTTP_STATUS_BAD_REQUEST) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=route-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../../src/hono/route-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EACL,uBAAuB,EACvB,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,oBAAoB,CAAC;AAI5B,SAAS,eAAe,CAAC,GAAY;IACnC,OAAO,OAAQ,GAAwC,EAAE,WAAW,KAAK,UAAU,CAAC;AACtF,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,OAAqB;IAC9C,OAAO,KAAK,EAAE,CAAU,EAAE,EAAE;QAC1B,IAAI,CAAC;YACH,OAAO,MAAM,OAAO,CAAC,CAAC,CAAC,CAAC;QAC1B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,eAAe,CAAC,GAAG,CAAC;gBAAE,MAAM,GAAG,CAAC;YACpC,kBAAkB,CAAC,GAAG,CAAC,CAAC;YACxB,OAAO,CAAC,CAAC,IAAI,CAAC,mBAAmB,EAAE,oBAAoB,CAAC,CAAC;QAC3D,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAKD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,CAAU;IAC3C,IAAI,CAAC;QACH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,iBAAiB,EAAE,uBAAuB,CAAC,EAAE,CAAC;IACrF,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error envelopes the HTTP mount helpers answer with when a request fails for a
|
|
3
|
+
* reason that is NOT a filter, validation or constraint problem — shared by the
|
|
4
|
+
* drizzle-fastify, fastify (ObjectManager) and hono adapters so they cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* Two defects, both found by a cold review of a generated app:
|
|
7
|
+
*
|
|
8
|
+
* 1. **An unexpected error leaked the query.** With the Drizzle schema out of step with
|
|
9
|
+
* the database (a renamed column), a list answered Fastify's default 500 —
|
|
10
|
+
* `{"statusCode":500,…,"message":"Failed query: select \"id\", \"display_name\" …
|
|
11
|
+
* \nparams: …"}`: the SQL, the table and column names and the bound VALUES, to an
|
|
12
|
+
* unauthenticated caller. The write paths had already been redacted
|
|
13
|
+
* (`constraint-errors.ts`); the reads never were. Now every mounted route answers
|
|
14
|
+
* `500 { "error": "internal" }` — the code the cross-port reference servers already
|
|
15
|
+
* use — and the detail goes to the server log.
|
|
16
|
+
* 2. **A malformed JSON body bypassed the envelope.** Fastify's content-type parser
|
|
17
|
+
* answered `{"statusCode":400,"code":"FST_ERR_CTP_INVALID_JSON_BODY",…}`, and the Hono
|
|
18
|
+
* mount folded it into a Zod `validation` error. Both now answer
|
|
19
|
+
* `400 { "error": "invalid_json" }`.
|
|
20
|
+
*
|
|
21
|
+
* The api-contract corpus pins neither response (5xx is implementation-defined there, and
|
|
22
|
+
* no scenario sends a malformed body), so both are TS-helper extensions — documented in
|
|
23
|
+
* docs/features/api-contract.md, "TS-only error responses".
|
|
24
|
+
*/
|
|
25
|
+
/** Wire code for an unexpected server-side failure. Carries no detail, by design. */
|
|
26
|
+
export declare const ERROR_CODE_INTERNAL = "internal";
|
|
27
|
+
/** Wire code for a request body that is not parseable JSON. */
|
|
28
|
+
export declare const ERROR_CODE_INVALID_JSON = "invalid_json";
|
|
29
|
+
export declare const HTTP_STATUS_BAD_REQUEST = 400;
|
|
30
|
+
export declare const HTTP_STATUS_INTERNAL = 500;
|
|
31
|
+
export interface ErrorEnvelope {
|
|
32
|
+
readonly error: string;
|
|
33
|
+
}
|
|
34
|
+
export declare const INTERNAL_ERROR_BODY: ErrorEnvelope;
|
|
35
|
+
export declare const INVALID_JSON_BODY: ErrorEnvelope;
|
|
36
|
+
/** True for Fastify's "the body is not valid JSON" errors. */
|
|
37
|
+
export declare function isMalformedJsonBodyError(err: unknown): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* True when the error deliberately carries a CLIENT status (4xx) — an auth hook's 401, a
|
|
40
|
+
* schema-validation 400, a 413 for an oversized body. Those were raised on purpose by the
|
|
41
|
+
* adopter or the framework and are theirs to answer; the mount helpers only take over the
|
|
42
|
+
* errors nobody meant to send.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isDeliberateClientError(err: unknown): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Log an unexpected error server-side, exactly once. A `RedactedDatabaseError` was
|
|
47
|
+
* already logged by the write path that raised it (it carries no detail of its own).
|
|
48
|
+
*/
|
|
49
|
+
export declare function logUnexpectedError(err: unknown): void;
|
|
50
|
+
//# sourceMappingURL=route-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-errors.d.ts","sourceRoot":"","sources":["../src/route-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,qFAAqF;AACrF,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAC9C,+DAA+D;AAC/D,eAAO,MAAM,uBAAuB,iBAAiB,CAAC;AAEtD,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,mBAAmB,EAAE,aAA6D,CAAC;AAChG,eAAO,MAAM,iBAAiB,EAAE,aAAiE,CAAC;AAWlG,8DAA8D;AAC9D,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAG9D;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAI7D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAGrD"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error envelopes the HTTP mount helpers answer with when a request fails for a
|
|
3
|
+
* reason that is NOT a filter, validation or constraint problem — shared by the
|
|
4
|
+
* drizzle-fastify, fastify (ObjectManager) and hono adapters so they cannot drift.
|
|
5
|
+
*
|
|
6
|
+
* Two defects, both found by a cold review of a generated app:
|
|
7
|
+
*
|
|
8
|
+
* 1. **An unexpected error leaked the query.** With the Drizzle schema out of step with
|
|
9
|
+
* the database (a renamed column), a list answered Fastify's default 500 —
|
|
10
|
+
* `{"statusCode":500,…,"message":"Failed query: select \"id\", \"display_name\" …
|
|
11
|
+
* \nparams: …"}`: the SQL, the table and column names and the bound VALUES, to an
|
|
12
|
+
* unauthenticated caller. The write paths had already been redacted
|
|
13
|
+
* (`constraint-errors.ts`); the reads never were. Now every mounted route answers
|
|
14
|
+
* `500 { "error": "internal" }` — the code the cross-port reference servers already
|
|
15
|
+
* use — and the detail goes to the server log.
|
|
16
|
+
* 2. **A malformed JSON body bypassed the envelope.** Fastify's content-type parser
|
|
17
|
+
* answered `{"statusCode":400,"code":"FST_ERR_CTP_INVALID_JSON_BODY",…}`, and the Hono
|
|
18
|
+
* mount folded it into a Zod `validation` error. Both now answer
|
|
19
|
+
* `400 { "error": "invalid_json" }`.
|
|
20
|
+
*
|
|
21
|
+
* The api-contract corpus pins neither response (5xx is implementation-defined there, and
|
|
22
|
+
* no scenario sends a malformed body), so both are TS-helper extensions — documented in
|
|
23
|
+
* docs/features/api-contract.md, "TS-only error responses".
|
|
24
|
+
*/
|
|
25
|
+
import { RedactedDatabaseError, logAndRedact } from "./constraint-errors.js";
|
|
26
|
+
/** Wire code for an unexpected server-side failure. Carries no detail, by design. */
|
|
27
|
+
export const ERROR_CODE_INTERNAL = "internal";
|
|
28
|
+
/** Wire code for a request body that is not parseable JSON. */
|
|
29
|
+
export const ERROR_CODE_INVALID_JSON = "invalid_json";
|
|
30
|
+
export const HTTP_STATUS_BAD_REQUEST = 400;
|
|
31
|
+
export const HTTP_STATUS_INTERNAL = 500;
|
|
32
|
+
export const INTERNAL_ERROR_BODY = Object.freeze({ error: ERROR_CODE_INTERNAL });
|
|
33
|
+
export const INVALID_JSON_BODY = Object.freeze({ error: ERROR_CODE_INVALID_JSON });
|
|
34
|
+
/**
|
|
35
|
+
* Fastify's error codes for a body its JSON parser refused: syntactically invalid, and
|
|
36
|
+
* empty-while-declared-JSON. Both are the caller sending something that is not JSON.
|
|
37
|
+
*/
|
|
38
|
+
const FASTIFY_MALFORMED_JSON_CODES = new Set([
|
|
39
|
+
"FST_ERR_CTP_INVALID_JSON_BODY",
|
|
40
|
+
"FST_ERR_CTP_EMPTY_JSON_BODY",
|
|
41
|
+
]);
|
|
42
|
+
/** True for Fastify's "the body is not valid JSON" errors. */
|
|
43
|
+
export function isMalformedJsonBodyError(err) {
|
|
44
|
+
const code = err?.code;
|
|
45
|
+
return typeof code === "string" && FASTIFY_MALFORMED_JSON_CODES.has(code);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* True when the error deliberately carries a CLIENT status (4xx) — an auth hook's 401, a
|
|
49
|
+
* schema-validation 400, a 413 for an oversized body. Those were raised on purpose by the
|
|
50
|
+
* adopter or the framework and are theirs to answer; the mount helpers only take over the
|
|
51
|
+
* errors nobody meant to send.
|
|
52
|
+
*/
|
|
53
|
+
export function isDeliberateClientError(err) {
|
|
54
|
+
const o = err;
|
|
55
|
+
const status = typeof o?.statusCode === "number" ? o.statusCode : o?.status;
|
|
56
|
+
return typeof status === "number" && status >= 400 && status < 500;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Log an unexpected error server-side, exactly once. A `RedactedDatabaseError` was
|
|
60
|
+
* already logged by the write path that raised it (it carries no detail of its own).
|
|
61
|
+
*/
|
|
62
|
+
export function logUnexpectedError(err) {
|
|
63
|
+
if (err instanceof RedactedDatabaseError)
|
|
64
|
+
return;
|
|
65
|
+
logAndRedact(err);
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=route-errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-errors.js","sourceRoot":"","sources":["../src/route-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE7E,qFAAqF;AACrF,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAC9C,+DAA+D;AAC/D,MAAM,CAAC,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAEtD,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAC3C,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAMxC,MAAM,CAAC,MAAM,mBAAmB,GAAkB,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAC;AAChG,MAAM,CAAC,MAAM,iBAAiB,GAAkB,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAC;AAElG;;;GAGG;AACH,MAAM,4BAA4B,GAAwB,IAAI,GAAG,CAAC;IAChE,+BAA+B;IAC/B,6BAA6B;CAC9B,CAAC,CAAC;AAEH,8DAA8D;AAC9D,MAAM,UAAU,wBAAwB,CAAC,GAAY;IACnD,MAAM,IAAI,GAAI,GAAiC,EAAE,IAAI,CAAC;IACtD,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,4BAA4B,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CAAC,GAAY;IAClD,MAAM,CAAC,GAAG,GAAwD,CAAC;IACnE,MAAM,MAAM,GAAG,OAAO,CAAC,EAAE,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;IAC5E,OAAO,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,IAAI,GAAG,IAAI,MAAM,GAAG,GAAG,CAAC;AACrE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,IAAI,GAAG,YAAY,qBAAqB;QAAE,OAAO;IACjD,YAAY,CAAC,GAAG,CAAC,CAAC;AACpB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metaobjectsdev/runtime-ts",
|
|
3
|
-
"version": "1.0.9-rc.
|
|
3
|
+
"version": "1.0.9-rc.3",
|
|
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.
|
|
68
|
-
"@metaobjectsdev/render": "1.0.9-rc.
|
|
67
|
+
"@metaobjectsdev/metadata": "1.0.9-rc.3",
|
|
68
|
+
"@metaobjectsdev/render": "1.0.9-rc.3",
|
|
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>>;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
is, Column, SQL as DrizzleSQL,
|
|
2
3
|
eq, ne, gt, gte, lt, lte, inArray, like, ilike, isNull, not, and, or, asc, desc, sql,
|
|
3
4
|
type SQL, type SQLWrapper,
|
|
4
5
|
} from "drizzle-orm";
|
|
5
6
|
import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
|
|
6
7
|
import { sortOrderSpec } from "./filter-allowlist.js";
|
|
8
|
+
import { FilterParseError, parsePageBound, SORT_EXPECTED } from "./list-params.js";
|
|
9
|
+
|
|
10
|
+
// Re-exported so existing importers keep one door; the definitions live in the
|
|
11
|
+
// drizzle-free list-params module, which the ObjectManager Fastify mount also uses.
|
|
12
|
+
export { FilterParseError, parsePageBound, PAGINATION_EXPECTED, RAW_VIEW_MAX_LIMIT, SORT_EXPECTED } from "./list-params.js";
|
|
13
|
+
import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, matchesAnyTemporal, matchesFormat } from "./filter-value-format.js";
|
|
7
14
|
|
|
8
15
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
|
|
9
16
|
type AnyTable = any;
|
|
@@ -27,12 +34,6 @@ export interface ParseFilterResult {
|
|
|
27
34
|
searchWhere?: SQL;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
export class FilterParseError extends Error {
|
|
31
|
-
constructor(public readonly code: string, message: string, public readonly details?: Record<string, unknown>) {
|
|
32
|
-
super(message);
|
|
33
|
-
this.name = "FilterParseError";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
37
|
|
|
37
38
|
const DEFAULT_MAX_NESTING = 5;
|
|
38
39
|
const DEFAULT_MAX_IN_LIST = 100;
|
|
@@ -49,37 +50,58 @@ const RESERVED_LIST_PARAMS: ReadonlySet<string> = new Set([
|
|
|
49
50
|
* meant `?filter[priority][eq]=low` — and silently ignoring it returns EVERY row, which
|
|
50
51
|
* reads as "the filter matched everything". Refuse it, naming the syntax that works.
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* A bare name that is a field of the entity but NOT on the filter allowlist is refused
|
|
54
|
+
* too — the caller plainly meant that field — saying it is not filterable and listing
|
|
55
|
+
* the fields that are. Any other unknown parameter — a cache-buster, a tracking tag, a
|
|
56
|
+
* param some proxy appends — is left alone, as before.
|
|
54
57
|
* TS-only (the other ports ignore unknown parameters): see docs/features/api-contract.md,
|
|
55
58
|
* "TS-only filter extensions".
|
|
56
59
|
*/
|
|
57
|
-
function rejectBareFieldParams(query: Record<string, unknown>, allowlist: FilterAllowlist): void {
|
|
60
|
+
function rejectBareFieldParams(query: Record<string, unknown>, allowlist: FilterAllowlist, table: AnyTable): void {
|
|
58
61
|
for (const [key, value] of Object.entries(query)) {
|
|
59
|
-
if (RESERVED_LIST_PARAMS.has(key)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
if (RESERVED_LIST_PARAMS.has(key)) continue;
|
|
63
|
+
if (Object.hasOwn(allowlist, key)) {
|
|
64
|
+
const shown = typeof value === "string" ? value : "<value>";
|
|
65
|
+
throw new FilterParseError(
|
|
66
|
+
"filter.bare_field",
|
|
67
|
+
`"${key}" is a filterable field, but a bare ?${key}= parameter is not a filter. Use filter[${key}][eq]=${shown}.`,
|
|
68
|
+
{ field: key, expected: `filter[${key}][eq]=${shown}` },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (isEntityColumn(table, key)) {
|
|
72
|
+
const allowed = Object.keys(allowlist);
|
|
73
|
+
throw new FilterParseError(
|
|
74
|
+
"filter.bare_field",
|
|
75
|
+
`"${key}" is a field of this entity, but it is not @filterable, so it cannot be filtered on` +
|
|
76
|
+
(allowed.length > 0 ? ` (filterable: ${allowed.join(", ")}).` : " (no field is filterable)."),
|
|
77
|
+
{ field: key, filterable: false, expected: `a filterable field — "${key}" is not @filterable`, allowed },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/** Is `key` one of the table's (or view's) own columns? A Drizzle table exposes each
|
|
84
|
+
* column as a property keyed by its field name; anything else found there (a function,
|
|
85
|
+
* a symbol-backed config, a proxy that throws on an unknown key) is not a field. */
|
|
86
|
+
function isEntityColumn(table: AnyTable, key: string): boolean {
|
|
87
|
+
if (table === null || typeof table !== "object") return false;
|
|
88
|
+
try {
|
|
89
|
+
const v: unknown = table[key];
|
|
90
|
+
return is(v, Column) || is(v, DrizzleSQL.Aliased);
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
69
97
|
export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
|
|
70
|
-
rejectBareFieldParams(opts.query, opts.allowlist);
|
|
98
|
+
rejectBareFieldParams(opts.query, opts.allowlist, opts.table);
|
|
71
99
|
const result: ParseFilterResult = {};
|
|
72
100
|
|
|
73
|
-
const limit = opts.query
|
|
74
|
-
if (limit !== undefined)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
const offset = opts.query.offset;
|
|
79
|
-
if (offset !== undefined) {
|
|
80
|
-
const n = Number(offset);
|
|
81
|
-
if (Number.isFinite(n)) result.offset = n;
|
|
82
|
-
}
|
|
101
|
+
const limit = parsePageBound(opts.query, "limit");
|
|
102
|
+
if (limit !== undefined) result.limit = limit;
|
|
103
|
+
const offset = parsePageBound(opts.query, "offset");
|
|
104
|
+
if (offset !== undefined) result.offset = offset;
|
|
83
105
|
|
|
84
106
|
if (opts.query.filter && typeof opts.query.filter === "object") {
|
|
85
107
|
const where = parseNode(
|
|
@@ -184,14 +206,14 @@ function compileOp(
|
|
|
184
206
|
throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
|
|
185
207
|
}
|
|
186
208
|
switch (op as FilterOp) {
|
|
187
|
-
case "eq": return eq(col as any, coerce(value, rule
|
|
188
|
-
case "ne": return ne(col as any, coerce(value, rule
|
|
189
|
-
case "gt": return gt(col as any, coerce(value, rule
|
|
190
|
-
case "gte": return gte(col as any, coerce(value, rule
|
|
191
|
-
case "lt": return lt(col as any, coerce(value, rule
|
|
192
|
-
case "lte": return lte(col as any, coerce(value, rule
|
|
209
|
+
case "eq": return eq(col as any, coerce(value, rule, col, field, op));
|
|
210
|
+
case "ne": return ne(col as any, coerce(value, rule, col, field, op));
|
|
211
|
+
case "gt": return gt(col as any, coerce(value, rule, col, field, op));
|
|
212
|
+
case "gte": return gte(col as any, coerce(value, rule, col, field, op));
|
|
213
|
+
case "lt": return lt(col as any, coerce(value, rule, col, field, op));
|
|
214
|
+
case "lte": return lte(col as any, coerce(value, rule, col, field, op));
|
|
193
215
|
case "in": {
|
|
194
|
-
const list = String(value).split(",").map((v) => coerce(v.trim(), rule
|
|
216
|
+
const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
|
|
195
217
|
if (list.length > maxInList) {
|
|
196
218
|
throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
|
|
197
219
|
}
|
|
@@ -216,7 +238,7 @@ function compileOp(
|
|
|
216
238
|
// isNull's value is always coerced as boolean (true/false), regardless of the
|
|
217
239
|
// field's declared subType — the operator is "is the value null?", not "is X
|
|
218
240
|
// equal to null?". Field subtype is irrelevant.
|
|
219
|
-
const b =
|
|
241
|
+
const b = coerceAs(value, "boolean", field, op) as boolean;
|
|
220
242
|
return b ? isNull(col as any) : not(isNull(col as any));
|
|
221
243
|
}
|
|
222
244
|
}
|
|
@@ -243,7 +265,54 @@ export function likePatternToGlob(pattern: string): string {
|
|
|
243
265
|
return out;
|
|
244
266
|
}
|
|
245
267
|
|
|
246
|
-
function
|
|
268
|
+
function invalidValue(
|
|
269
|
+
field: string,
|
|
270
|
+
op: string,
|
|
271
|
+
s: string,
|
|
272
|
+
expected: string,
|
|
273
|
+
extra: Record<string, unknown> = {},
|
|
274
|
+
): FilterParseError {
|
|
275
|
+
return new FilterParseError(
|
|
276
|
+
"filter.invalid_value",
|
|
277
|
+
`Field "${field}" op "${op}" requires ${expected}, got "${s}".`,
|
|
278
|
+
{ field, op, expected, ...extra },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A Drizzle column's own enum members (`text(..., { enum })`, `pgEnum`), if it has them. */
|
|
283
|
+
function columnEnumValues(col: unknown): readonly string[] | undefined {
|
|
284
|
+
const v = (col as { enumValues?: unknown } | null | undefined)?.enumValues;
|
|
285
|
+
return Array.isArray(v) && v.length > 0 && v.every((m) => typeof m === "string") ? (v as string[]) : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Refuse a value that cannot be the field's type BEFORE it is bound. Without this a
|
|
290
|
+
* malformed date on SQLite compared as text and silently matched nothing (200 `[]`),
|
|
291
|
+
* and on Postgres the driver refused the cast (a 500) — either way the caller could not
|
|
292
|
+
* tell a typo from an empty result. The envelope is the cross-port `invalid_filter_value`,
|
|
293
|
+
* carrying `op` and `expected` as the number/boolean checks already did.
|
|
294
|
+
*/
|
|
295
|
+
function assertWellFormed(s: string, rule: FilterFieldRule, col: unknown, field: string, op: string): void {
|
|
296
|
+
const members = rule.enumValues ?? columnEnumValues(col);
|
|
297
|
+
if (members !== undefined && !members.includes(s)) {
|
|
298
|
+
throw invalidValue(field, op, s, `one of: ${members.join(", ")}`, { allowed: [...members] });
|
|
299
|
+
}
|
|
300
|
+
if (rule.format !== undefined) {
|
|
301
|
+
if (!matchesFormat(rule.format, s)) throw invalidValue(field, op, s, FORMAT_EXPECTED[rule.format]);
|
|
302
|
+
} else if (rule.subType === "datetime" && !matchesAnyTemporal(s)) {
|
|
303
|
+
throw invalidValue(field, op, s, ANY_TEMPORAL_EXPECTED);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Check a comparison value against the field's rule, then coerce it for binding. */
|
|
308
|
+
function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string): unknown {
|
|
309
|
+
if (value === null || value === undefined) return null;
|
|
310
|
+
const s = typeof value === "string" ? value : String(value);
|
|
311
|
+
assertWellFormed(s, rule, col, field, op);
|
|
312
|
+
return coerceAs(s, rule.subType, field, op, rule.dateValues);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function coerceAs(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown {
|
|
247
316
|
if (value === null || value === undefined) return null;
|
|
248
317
|
const s = typeof value === "string" ? value : String(value);
|
|
249
318
|
switch (subType) {
|
|
@@ -254,7 +323,8 @@ function coerce(value: unknown, subType: string, field: string, op: string, date
|
|
|
254
323
|
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires boolean, got "${s}".`, { field, op, expected: "boolean" });
|
|
255
324
|
}
|
|
256
325
|
case "number": {
|
|
257
|
-
|
|
326
|
+
// `Number("")` and `Number(" ")` are 0 — an empty bound is not the number zero.
|
|
327
|
+
const n = s.trim() === "" ? Number.NaN : Number(s);
|
|
258
328
|
if (!Number.isFinite(n)) {
|
|
259
329
|
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires number, got "${s}".`, { field, op, expected: "number" });
|
|
260
330
|
}
|
|
@@ -302,7 +372,11 @@ function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist):
|
|
|
302
372
|
const colonIdx = spec.indexOf(":");
|
|
303
373
|
const field = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
|
|
304
374
|
if (!sortAllowlist[field]) {
|
|
305
|
-
throw new FilterParseError(
|
|
375
|
+
throw new FilterParseError(
|
|
376
|
+
"sort.unknown_field",
|
|
377
|
+
`Unknown sort field "${field}". The syntax is ${SORT_EXPECTED}.`,
|
|
378
|
+
{ field, expected: SORT_EXPECTED, allowed: Object.keys(sortAllowlist) },
|
|
379
|
+
);
|
|
306
380
|
}
|
|
307
381
|
// `?sort=field` with no `:order` takes the field's DECLARED default order.
|
|
308
382
|
//
|
|
@@ -324,7 +398,11 @@ function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist):
|
|
|
324
398
|
);
|
|
325
399
|
const order = orderRaw.toLowerCase();
|
|
326
400
|
if (order !== "asc" && order !== "desc") {
|
|
327
|
-
throw new FilterParseError(
|
|
401
|
+
throw new FilterParseError(
|
|
402
|
+
"sort.invalid_order",
|
|
403
|
+
`Sort order must be asc|desc, got "${orderRaw}". The syntax is ${SORT_EXPECTED}.`,
|
|
404
|
+
{ field, expected: SORT_EXPECTED, allowed: Object.keys(sortAllowlist) },
|
|
405
|
+
);
|
|
328
406
|
}
|
|
329
407
|
const col = table[field];
|
|
330
408
|
return [order === "asc" ? asc(col as any) : desc(col as any)];
|