@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.
Files changed (41) hide show
  1. package/dist/drizzle-fastify/filter-parser.d.ts +1 -6
  2. package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
  3. package/dist/drizzle-fastify/filter-parser.js +51 -33
  4. package/dist/drizzle-fastify/filter-parser.js.map +1 -1
  5. package/dist/drizzle-fastify/filter-value-format.d.ts +13 -0
  6. package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
  7. package/dist/drizzle-fastify/filter-value-format.js +23 -0
  8. package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
  9. package/dist/drizzle-fastify/index.d.ts.map +1 -1
  10. package/dist/drizzle-fastify/index.js +8 -6
  11. package/dist/drizzle-fastify/index.js.map +1 -1
  12. package/dist/drizzle-fastify/list-params.d.ts +27 -0
  13. package/dist/drizzle-fastify/list-params.d.ts.map +1 -0
  14. package/dist/drizzle-fastify/list-params.js +43 -0
  15. package/dist/drizzle-fastify/list-params.js.map +1 -0
  16. package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
  17. package/dist/drizzle-fastify/mount-read-only.js +5 -4
  18. package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
  19. package/dist/fastify/index.d.ts.map +1 -1
  20. package/dist/fastify/index.js +23 -8
  21. package/dist/fastify/index.js.map +1 -1
  22. package/dist/hono/index.d.ts.map +1 -1
  23. package/dist/hono/index.js +10 -8
  24. package/dist/hono/index.js.map +1 -1
  25. package/dist/hono/mount-read-only.d.ts.map +1 -1
  26. package/dist/hono/mount-read-only.js +4 -3
  27. package/dist/hono/mount-read-only.js.map +1 -1
  28. package/dist/route-errors.d.ts +13 -0
  29. package/dist/route-errors.d.ts.map +1 -1
  30. package/dist/route-errors.js +18 -0
  31. package/dist/route-errors.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/drizzle-fastify/filter-parser.ts +66 -30
  34. package/src/drizzle-fastify/filter-value-format.ts +25 -0
  35. package/src/drizzle-fastify/index.ts +8 -6
  36. package/src/drizzle-fastify/list-params.ts +50 -0
  37. package/src/drizzle-fastify/mount-read-only.ts +5 -4
  38. package/src/fastify/index.ts +19 -6
  39. package/src/hono/index.ts +8 -5
  40. package/src/hono/mount-read-only.ts +4 -9
  41. 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
- const limitVal = Math.min(1000, Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)));
144
- const offsetVal = Math.max(0, Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0));
143
+ // A bound that is not an integer in range is a 400, not silently clamped.
144
+ const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
145
+ const offsetVal = parsePageBound(parsed, "offset") ?? 0;
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 (a full-replace update), so a
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.
@@ -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
- const { limit, offset } = req.query as { limit?: string; offset?: string };
133
- if (limit !== undefined) readOpts.limit = Number(limit);
134
- if (offset !== undefined) readOpts.offset = Number(offset);
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({ error: contractErrorCode(sortParse.error), field: sortParse.field });
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({ error: "validation", issues: parsed.error.issues });
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({ error: "validation", issues: parsed.error.issues });
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 flat = c.req.query();
208
- if (flat.limit !== undefined) q = q.limit(Number(flat.limit));
209
- if (flat.offset !== undefined) q = q.offset(Number(flat.offset));
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({ error: "validation", issues: parsed.error.issues }, 400);
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({ error: "validation", issues: parsed.error.issues }, 400);
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
- const limitVal = Math.min(
120
- 1000,
121
- Math.max(1, Number(typeof parsed["limit"] === "string" ? parsed["limit"] : 1000)),
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[];
@@ -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
  /**