@metaobjectsdev/runtime-ts 1.0.9-rc.2 → 1.0.9-rc.4
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-parser.d.ts +1 -6
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +51 -33
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts +13 -0
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.js +23 -0
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +8 -6
- 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-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +5 -4
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/fastify/index.d.ts.map +1 -1
- package/dist/fastify/index.js +23 -8
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +10 -8
- 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 +4 -3
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/route-errors.d.ts +13 -0
- package/dist/route-errors.d.ts.map +1 -1
- package/dist/route-errors.js +18 -0
- package/dist/route-errors.js.map +1 -1
- package/package.json +3 -3
- package/src/drizzle-fastify/filter-parser.ts +66 -30
- package/src/drizzle-fastify/filter-value-format.ts +25 -0
- package/src/drizzle-fastify/index.ts +8 -6
- package/src/drizzle-fastify/list-params.ts +50 -0
- package/src/drizzle-fastify/mount-read-only.ts +5 -4
- package/src/fastify/index.ts +19 -6
- package/src/hono/index.ts +8 -5
- package/src/hono/mount-read-only.ts +4 -9
- package/src/route-errors.ts +22 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List-query parameter checks shared by every mount — the Drizzle mounts (Fastify and
|
|
3
|
+
* Hono) and the ObjectManager Fastify mount. Deliberately free of drizzle-orm (an
|
|
4
|
+
* OPTIONAL peer): the ObjectManager mount must not pull it in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class FilterParseError extends Error {
|
|
8
|
+
constructor(public readonly code: string, message: string, public readonly details?: Record<string, unknown>) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "FilterParseError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The page-size ceiling of a raw-SQL (column-less `.existing()`) view list, which has
|
|
15
|
+
* no allowlist to page through and returns at most this many rows. */
|
|
16
|
+
export const RAW_VIEW_MAX_LIMIT = 1000;
|
|
17
|
+
|
|
18
|
+
/** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */
|
|
19
|
+
export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read `?limit=` / `?offset=` as a non-negative integer, or refuse it. Before, a value
|
|
23
|
+
* that was not a finite number (`?limit=abc`) was dropped — every row came back — and a
|
|
24
|
+
* negative or fractional one reached SQL. `max` is the ceiling a mount clamps to, when it
|
|
25
|
+
* has one: a bound above it is refused rather than silently lowered. TS-only (no corpus
|
|
26
|
+
* scenario sends a malformed page bound): see docs/features/api-contract.md, "TS-only
|
|
27
|
+
* error responses".
|
|
28
|
+
*/
|
|
29
|
+
export function parsePageBound(
|
|
30
|
+
query: Record<string, unknown>,
|
|
31
|
+
param: "limit" | "offset",
|
|
32
|
+
max?: number,
|
|
33
|
+
): number | undefined {
|
|
34
|
+
const raw = query[param];
|
|
35
|
+
if (raw === undefined) return undefined;
|
|
36
|
+
const s = typeof raw === "string" ? raw : String(raw);
|
|
37
|
+
const n = /^\d+$/.test(s) ? Number(s) : Number.NaN;
|
|
38
|
+
if (!Number.isSafeInteger(n) || (max !== undefined && n > max)) {
|
|
39
|
+
const expected = max === undefined ? PAGINATION_EXPECTED : `an integer from 0 to ${max}`;
|
|
40
|
+
throw new FilterParseError(
|
|
41
|
+
"pagination.invalid_value",
|
|
42
|
+
`?${param}= must be ${expected}, got "${s}".`,
|
|
43
|
+
{ param, value: s, expected },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */
|
|
50
|
+
export const SORT_EXPECTED = "sort=<field>:asc|desc";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
2
2
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
3
3
|
import qs from "qs";
|
|
4
|
-
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
4
|
+
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js";
|
|
5
5
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
6
6
|
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
|
|
7
7
|
import { timestampWire } from "../timestamp-wire.js";
|
|
@@ -140,8 +140,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
140
140
|
const qIdx = url.indexOf("?");
|
|
141
141
|
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
142
142
|
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
143
|
-
|
|
144
|
-
const
|
|
143
|
+
// A bound that is not an integer in range is a 400, not silently clamped.
|
|
144
|
+
const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
|
|
145
|
+
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
145
146
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
146
147
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
147
148
|
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
|
|
@@ -223,7 +224,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
223
224
|
});
|
|
224
225
|
|
|
225
226
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
226
|
-
// PUT is here because the WRITABLE mount serves it (
|
|
227
|
+
// PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
|
|
227
228
|
// projection must reject it the same way the other three are rejected. Omitting it
|
|
228
229
|
// left `PUT /<collection>/:id` falling through to Fastify's 404 — telling a caller
|
|
229
230
|
// the resource does not exist when it plainly does and answers GET.
|
package/src/fastify/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { FastifyInstance } from "fastify";
|
|
20
20
|
import { withConstraintMapping } from "../constraint-errors.js";
|
|
21
21
|
import type { ZodTypeAny } from "zod";
|
|
22
|
+
import { validationErrorBody } from "../route-errors.js";
|
|
22
23
|
import qs from "qs";
|
|
23
24
|
import type { ObjectManager } from "../object-manager.js";
|
|
24
25
|
import type { Row } from "../persistence-driver.js";
|
|
@@ -26,6 +27,7 @@ import type { RouteShorthandOptions } from "fastify";
|
|
|
26
27
|
import type { SortAllowlist } from "../drizzle-fastify/filter-allowlist.js";
|
|
27
28
|
import { sortOrderSpec } from "../drizzle-fastify/filter-allowlist.js";
|
|
28
29
|
import { isTruthyFlag, contractErrorCode } from "../drizzle-fastify/util.js";
|
|
30
|
+
import { parsePageBound, FilterParseError, SORT_EXPECTED } from "../drizzle-fastify/list-params.js";
|
|
29
31
|
import { withContractErrorHandler } from "../drizzle-fastify/route-error-handler.js";
|
|
30
32
|
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
@@ -129,9 +131,15 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
129
131
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
130
132
|
|
|
131
133
|
const readOpts: { limit?: number; offset?: number; orderBy?: [string, "asc" | "desc"] } = {};
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
try {
|
|
135
|
+
const limit = parsePageBound(parsed, "limit");
|
|
136
|
+
const offset = parsePageBound(parsed, "offset");
|
|
137
|
+
if (limit !== undefined) readOpts.limit = limit;
|
|
138
|
+
if (offset !== undefined) readOpts.offset = offset;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err instanceof FilterParseError) return reply.code(400).send({ error: err.code, ...(err.details ?? {}) });
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
135
143
|
|
|
136
144
|
// Sort allowlist gate (cross-port REST contract). Only enforced when a
|
|
137
145
|
// sortAllowlist is configured; absent → ?sort is ignored (back-compat).
|
|
@@ -140,7 +148,12 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
140
148
|
if (sortParse.error) {
|
|
141
149
|
// `field` is REQUIRED on the invalid_sort envelope cross-port (F20 ruling) —
|
|
142
150
|
// it names the offending sort field instead of making the caller guess.
|
|
143
|
-
return reply.code(400).send({
|
|
151
|
+
return reply.code(400).send({
|
|
152
|
+
error: contractErrorCode(sortParse.error),
|
|
153
|
+
field: sortParse.field,
|
|
154
|
+
expected: SORT_EXPECTED,
|
|
155
|
+
allowed: Object.keys(opts.sortAllowlist),
|
|
156
|
+
});
|
|
144
157
|
}
|
|
145
158
|
if (sortParse.orderBy) readOpts.orderBy = sortParse.orderBy;
|
|
146
159
|
}
|
|
@@ -190,7 +203,7 @@ export function mountCreateRoute(opts: SingleVerbOptions): void {
|
|
|
190
203
|
opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
|
|
191
204
|
const parsed = opts.insertSchema.safeParse(req.body);
|
|
192
205
|
if (!parsed.success) {
|
|
193
|
-
return reply.code(400).send(
|
|
206
|
+
return reply.code(400).send(validationErrorBody(parsed.error.issues));
|
|
194
207
|
}
|
|
195
208
|
// Zod returns `unknown` for parsed.data; the schema is authored from the
|
|
196
209
|
// same metadata that drives ObjectManager, so the shape is guaranteed
|
|
@@ -217,7 +230,7 @@ export function mountUpdateRoute(opts: SingleVerbOptions): void {
|
|
|
217
230
|
const { id } = req.params as { id: string };
|
|
218
231
|
const parsed = opts.updateSchema.safeParse(req.body);
|
|
219
232
|
if (!parsed.success) {
|
|
220
|
-
return reply.code(400).send(
|
|
233
|
+
return reply.code(400).send(validationErrorBody(parsed.error.issues));
|
|
221
234
|
}
|
|
222
235
|
// Use ifMissing: "ignore" so the helper itself owns the 404 mapping
|
|
223
236
|
// (consistent with mountDeleteRoute). ObjectManager's default behavior
|
package/src/hono/index.ts
CHANGED
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
import type { Hono, Context } from "hono";
|
|
25
25
|
import { classifyConstraintError, RedactedDatabaseError, logAndRedact } from "../constraint-errors.js";
|
|
26
26
|
import type { ZodTypeAny } from "zod";
|
|
27
|
+
import { validationErrorBody } from "../route-errors.js";
|
|
27
28
|
import { eq, count, and } from "drizzle-orm";
|
|
28
29
|
import qs from "qs";
|
|
29
30
|
import { contractErrorCode } from "../drizzle-fastify/util.js";
|
|
30
31
|
import {
|
|
31
32
|
parseFilterParams,
|
|
33
|
+
parsePageBound,
|
|
32
34
|
FilterParseError,
|
|
33
35
|
type ParseFilterResult,
|
|
34
36
|
} from "../drizzle-fastify/filter-parser.js";
|
|
@@ -204,9 +206,10 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
204
206
|
if (parsed.offset !== undefined) q = q.offset(parsed.offset);
|
|
205
207
|
} else {
|
|
206
208
|
// No allowlists configured. Only limit/offset.
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
if (
|
|
209
|
+
const limit = parsePageBound(qsParsed, "limit");
|
|
210
|
+
const offset = parsePageBound(qsParsed, "offset");
|
|
211
|
+
if (limit !== undefined) q = q.limit(limit);
|
|
212
|
+
if (offset !== undefined) q = q.offset(offset);
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
// Await the query directly rather than calling `.all()`: the drizzle-orm
|
|
@@ -258,7 +261,7 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
258
261
|
if (!read.ok) return read.response;
|
|
259
262
|
const parsed = opts.insertSchema.safeParse(read.body);
|
|
260
263
|
if (!parsed.success) {
|
|
261
|
-
return c.json(
|
|
264
|
+
return c.json(validationErrorBody(parsed.error.issues), 400);
|
|
262
265
|
}
|
|
263
266
|
// Same redaction contract as the Fastify mount: a constraint the DB enforces must not
|
|
264
267
|
// come back as a 500 carrying the SQL and its bound parameters. Both mounts are
|
|
@@ -285,7 +288,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
285
288
|
if (!read.ok) return read.response;
|
|
286
289
|
const parsed = opts.updateSchema.safeParse(read.body);
|
|
287
290
|
if (!parsed.success) {
|
|
288
|
-
return c.json(
|
|
291
|
+
return c.json(validationErrorBody(parsed.error.issues), 400);
|
|
289
292
|
}
|
|
290
293
|
// An empty patch is a no-op update, answered as a read — the Fastify mount's
|
|
291
294
|
// rule; its block in drizzle-fastify/index.ts carries the full rationale.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Hono } from "hono";
|
|
6
6
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
7
7
|
import qs from "qs";
|
|
8
|
-
import { parseFilterParams, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
8
|
+
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
9
9
|
import type {
|
|
10
10
|
FilterAllowlist,
|
|
11
11
|
SortAllowlist,
|
|
@@ -116,14 +116,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
116
116
|
const qIdx = url.indexOf("?");
|
|
117
117
|
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
118
118
|
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
123
|
-
const offsetVal = Math.max(
|
|
124
|
-
0,
|
|
125
|
-
Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0),
|
|
126
|
-
);
|
|
119
|
+
// A bound that is not an integer in range is a 400, not silently clamped.
|
|
120
|
+
const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
|
|
121
|
+
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
127
122
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
128
123
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
129
124
|
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
|
package/src/route-errors.ts
CHANGED
|
@@ -38,6 +38,28 @@ export interface ErrorEnvelope {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export const INTERNAL_ERROR_BODY: ErrorEnvelope = Object.freeze({ error: ERROR_CODE_INTERNAL });
|
|
41
|
+
|
|
42
|
+
/** Wire code for a request body that failed its schema. */
|
|
43
|
+
export const ERROR_CODE_VALIDATION = "validation";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The 400 body for a body that failed its Zod schema: every issue as Zod reported it,
|
|
47
|
+
* minus `pattern`. Zod 4 attaches the regex SOURCE to a failed `.regex()` check, and for a
|
|
48
|
+
* generated `field.date` / `field.timestamp` check that is a ~300-character calendar regex
|
|
49
|
+
* (an authored `validator.regex` pattern is likewise the server's own business). The
|
|
50
|
+
* issue's `message` already names the expected format. Shared by every mount.
|
|
51
|
+
*/
|
|
52
|
+
export function validationErrorBody<T extends object>(
|
|
53
|
+
issues: readonly T[],
|
|
54
|
+
): { readonly error: string; readonly issues: Omit<T, "pattern">[] } {
|
|
55
|
+
return {
|
|
56
|
+
error: ERROR_CODE_VALIDATION,
|
|
57
|
+
issues: issues.map((issue) => {
|
|
58
|
+
const { pattern: _pattern, ...rest } = issue as T & { pattern?: unknown };
|
|
59
|
+
return rest;
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
41
63
|
export const INVALID_JSON_BODY: ErrorEnvelope = Object.freeze({ error: ERROR_CODE_INVALID_JSON });
|
|
42
64
|
|
|
43
65
|
/**
|