Skip to content

Commit ffd5d56

Browse files
committed
Merge branch 'worktree-agent-a61a1c156ca160adf'
2 parents 5772c24 + 002cb40 commit ffd5d56

21 files changed

Lines changed: 1096 additions & 50 deletions

‎docs/features/api-contract.md‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ or use the TS-only `?search` extension, which IS case-insensitive.
146146

147147
### TS-only filter extensions (not part of the cross-port contract)
148148

149-
The TypeScript runtime parser ships six filter behaviors beyond the nine
149+
The TypeScript runtime parser ships seven filter behaviors beyond the nine
150150
operators. They are **NOT part of the cross-port REST contract** — the other
151151
ports (Java, Kotlin, Python, C#) do not implement them, and a relying adopter
152152
must not assume them on a non-TS backend. They are deliberately deferred until
@@ -160,6 +160,7 @@ added cross-port later as a purely additive, non-breaking change):
160160
| leading-wildcard gating | a `like` pattern starting with `%` → HTTP 400 (`filter.leading_wildcard_disallowed`) |
161161
| filter nesting-depth cap | rejects deeply-nested `or`/`and` (tied to the combinators) |
162162
| bare filterable-field parameter | `?priority=low` where `priority` is in the allowlist → HTTP 400 `{ "error": "filter.bare_field", "field": "priority", "expected": "filter[priority][eq]=low" }` instead of silently returning every row. Any other unknown parameter (a cache-buster, a tracking tag) is still ignored, and the reserved list parameters (`filter`, `sort`, `limit`, `offset`, `search`, `withCount`) are never claimed |
163+
| filter-value format check | a comparison value (`eq`/`ne`/`gt`/`gte`/`lt`/`lte`, and every element of an `in` list) that cannot be the field's type → HTTP 400 `{ "error": "invalid_filter_value", "field": "publishedOn", "op": "gte", "expected": "date (YYYY-MM-DD)" }` instead of reaching SQL, where SQLite compared the text and silently returned `[]` and Postgres failed the cast. Checked per field: `field.date` (a real calendar day), `field.time` (`HH:MM[:SS[.fff]]`), `field.timestamp` (a date, optionally with a time and a `Z`/offset), `field.uuid` (`8-4-4-4-12` hex), `field.enum` (a declared member — the response adds `allowed`), numbers (an empty value is not `0`) and booleans. The generated `<Entity>FilterAllowlist` carries the `format` / `enumValues` this needs; an allowlist generated before them still has a temporal value checked against all three temporal formats and an enum checked against the Drizzle column's own members. The envelope is the cross-port one; what is TS-only is refusing a malformed comparison value — the other ports pass it through to the database and only the `isNull` value is corpus-gated |
163164

164165
**Leading-wildcard gating is fail-closed with no metadata opt-in.** The
165166
generated `<Entity>FilterAllowlist` hardcodes `leadingWildcard: false` on every
@@ -178,6 +179,29 @@ safety limit, not a feature — TS enforces it, the other ports currently do not
178179
Unifying that cap cross-port is the one item here worth doing regardless of
179180
feature demand (it is a consistency/safety divergence, not a capability).
180181

182+
### TS-only error responses (not part of the cross-port contract)
183+
184+
The TypeScript mount helpers (`@metaobjectsdev/runtime-ts/drizzle-fastify`,
185+
`/fastify` and `/hono`) pin two responses the contract leaves open — HTTP 5xx is
186+
implementation-defined below, and no corpus scenario sends a malformed body. Both
187+
use the contract's `{ "error": "<code>" }` envelope, and both are scoped to the
188+
routes the helpers mount: an adopter's own routes, and a Fastify `setErrorHandler`
189+
or Hono `onError` the adopter installed, answer exactly as they did before.
190+
191+
| Response | When |
192+
|---|---|
193+
| malformed JSON body | a `POST`/`PATCH`/`PUT` body that does not parse as JSON (an empty body sent as `application/json` included) → HTTP 400 `{ "error": "invalid_json" }`. Before, Fastify answered its own `{ "statusCode": 400, "code": "FST_ERR_CTP_INVALID_JSON_BODY", … }` and Hono a Zod `validation` error about a missing object |
194+
| unexpected server error | anything that is not a filter, validation, not-found or constraint answer — a query against a column the database no longer has, a driver failure → HTTP 500 `{ "error": "internal" }`, the code the cross-port reference servers already use. The body names no SQL, table, column or bound parameter; the full error goes to the server log (`console.error`). Before, Fastify's default handler echoed the driver message, which for Drizzle is the query text and its parameter values |
195+
196+
How each framework scopes it: on Fastify, the helpers pass a **route-level**
197+
`errorHandler` in the options of each route they register (Fastify applies it to
198+
that route only). A deliberate 4xx raised on such a route — an auth `preHandler`'s
199+
401, schema validation, 413, 415 — is rethrown to the enclosing scope's handler
200+
untouched, and an `errorHandler` you pass in `routeOptions` replaces the helpers'
201+
own. Hono has no per-route handler (`app.onError` is app-wide), so the helpers wrap
202+
each handler they register instead; an `HTTPException` is rethrown to your
203+
`onError`.
204+
181205
### Sort + pagination
182206

183207
- `sort=<field>:asc|desc` — single sort key (multi-sort not in the
@@ -300,7 +324,8 @@ Non-2xx responses MUST return:
300324
- HTTP 400 — validation and filter/sort-parser errors.
301325
- HTTP 404 — `{ "error": "not_found" }`.
302326
- HTTP 409 — a declared constraint conflicting with existing state (a uniqueness or referential violation); a constraint rejecting the request's own value stays a 400.
303-
- HTTP 5xx — implementation-defined.
327+
- HTTP 5xx — implementation-defined (the TS mount helpers answer
328+
`{ "error": "internal" }` and nothing more — see "TS-only error responses").
304329

305330
#### Filter and sort errors name the field
306331

@@ -491,9 +516,10 @@ codegen status" above and "Verified by" below, and
491516

492517
What's still genuinely open:
493518

494-
- The six **TS-only filter extensions** (`?search=`, `filter[or]` /
519+
- The seven **TS-only filter extensions** (`?search=`, `filter[or]` /
495520
`filter[and]` nesting, leading-wildcard gating, the nesting-depth cap,
496-
the `in`-list size cap, and the bare filterable-field 400) — see "TS-only filter extensions" above.
521+
the `in`-list size cap, the bare filterable-field 400, and the
522+
filter-value format check) — see "TS-only filter extensions" above.
497523
None touch the metamodel vocabulary, so any of them can be promoted
498524
cross-port later as a purely additive, non-breaking change if real
499525
consumer demand shows up.

‎server/typescript/packages/codegen-ts/src/templates/filter-allowlist.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
FIELD_SUBTYPE_TIME,
1313
FIELD_SUBTYPE_TIMESTAMP,
1414
FIELD_SUBTYPE_CURRENCY,
15+
FIELD_SUBTYPE_UUID,
16+
FIELD_SUBTYPE_ENUM,
1517
opsForField,
1618
} from "@metaobjectsdev/metadata";
19+
import { enumValues } from "../enum-meta.js";
1720
import { sortableFields, declaredSortDefaultOrder } from "./filter-shared.js";
1821
import type { RenderContext } from "../render-context.js";
1922

@@ -41,6 +44,38 @@ function filterSubTypeFor(fieldSubType: string): "string" | "number" | "boolean"
4144
return "string";
4245
}
4346

47+
/**
48+
* Field subtypes whose filter values have an exact wire format the coarse FilterSubType
49+
* cannot express ("datetime" is three formats; a uuid is a "string"). The emitted
50+
* `format` value IS the subtype name — runtime-ts keys its checks on the same constants.
51+
*/
52+
const VALUE_FORMAT_SUBTYPES = new Set<string>([
53+
FIELD_SUBTYPE_DATE,
54+
FIELD_SUBTYPE_TIME,
55+
FIELD_SUBTYPE_TIMESTAMP,
56+
FIELD_SUBTYPE_UUID,
57+
]);
58+
59+
/**
60+
* The `format` / `enumValues` members of a rule, so runtime-ts's filter parser can refuse
61+
* a value that cannot be the field's type (`invalid_filter_value`) instead of binding it —
62+
* a malformed date compared as text on SQLite and silently matched nothing. Scalar fields
63+
* only: an array field's filter value is not one element of the declared type.
64+
*/
65+
function valueShape(f: MetaField): string {
66+
if (f.resolvedIsArray()) return "";
67+
if (VALUE_FORMAT_SUBTYPES.has(f.subType)) return `, format: ${JSON.stringify(f.subType)} as const`;
68+
if (f.subType === FIELD_SUBTYPE_ENUM) {
69+
// Member SYMBOLS for string- and int-backed enums alike: an int-backed column's codec
70+
// maps the symbol to its integer when the value is bound.
71+
const members = enumValues(f);
72+
if (members !== undefined && members.length > 0) {
73+
return `, enumValues: [${members.map((m) => JSON.stringify(m)).join(", ")}] as const`;
74+
}
75+
}
76+
return "";
77+
}
78+
4479
function filterableFields(entity: MetaObject, exclude?: string): MetaField[] {
4580
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in allowlists.
4681
return entity
@@ -80,7 +115,7 @@ export const ${entity.name}FilterAllowlist = {} as const satisfies FilterAllowli
80115
const dateValues = ctx?.timestampMode === "date" && f.subType === FIELD_SUBTYPE_TIMESTAMP
81116
? ", dateValues: true as const"
82117
: "";
83-
return ` ${f.name}: { ops: [${ops}] as const, subType: ${JSON.stringify(sub)} as const, leadingWildcard: false${dateValues} }`;
118+
return ` ${f.name}: { ops: [${ops}] as const, subType: ${JSON.stringify(sub)} as const, leadingWildcard: false${dateValues}${valueShape(f)} }`;
84119
})
85120
.join(",\n");
86121
return code`

‎server/typescript/packages/codegen-ts/test/fixtures/filter-fixture.json‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@
3535
{ "identity.primary": { "name": "id", "@fields": "id" } }
3636
]
3737
}
38+
},
39+
{
40+
"object.entity": {
41+
"name": "Book",
42+
"children": [
43+
{ "source.rdb": { "@table": "books" } },
44+
{ "field.long": { "name": "id" } },
45+
{ "field.date": { "name": "publishedOn", "@filterable": true } },
46+
{ "field.time": { "name": "opensAt", "@filterable": true } },
47+
{ "field.timestamp": { "name": "updatedAt", "@filterable": true } },
48+
{ "field.uuid": { "name": "externalId", "@filterable": true } },
49+
{ "field.enum": { "name": "genre", "@filterable": true, "@values": ["fiction", "poetry"] } },
50+
{
51+
"field.enum": {
52+
"name": "priority",
53+
"@filterable": true,
54+
"@values": ["low", "high"],
55+
"@intValueMap": { "low": 1, "high": 2 }
56+
}
57+
},
58+
{ "field.string": { "name": "title", "@filterable": true } },
59+
{ "identity.primary": { "name": "id", "@fields": "id" } }
60+
]
61+
}
3862
}
3963
]
4064
}

‎server/typescript/packages/codegen-ts/test/templates/filter-allowlist.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ describe("renderFilterAllowlist", () => {
3737
expect(out).not.toContain("internalNote");
3838
});
3939

40+
// The coarse subType ("datetime", "string") cannot tell the runtime a date from a
41+
// time, or a uuid or enum from free text — so a malformed bound reached SQL and
42+
// matched nothing. The rule now carries the exact wire format / enum members, and
43+
// runtime-ts's parser answers `invalid_filter_value` for a value that does not fit
44+
// (runtime-ts test/drizzle-fastify/filter-value-format.test.ts pins that half).
45+
test("carries the wire format for date/time/timestamp/uuid and the members of an enum", async () => {
46+
const out = renderFilterAllowlist(await loadEntity("Book")).toString();
47+
expect(out).toMatch(/publishedOn:\s*\{[^}]*format: "date"/);
48+
expect(out).toMatch(/opensAt:\s*\{[^}]*format: "time"/);
49+
expect(out).toMatch(/updatedAt:\s*\{[^}]*format: "timestamp"/);
50+
expect(out).toMatch(/externalId:\s*\{[^}]*format: "uuid"/);
51+
expect(out).toMatch(/genre:\s*\{[^}]*enumValues: \["fiction", "poetry"\]/);
52+
// An int-backed enum is filtered by MEMBER name too (the column codec maps it).
53+
expect(out).toMatch(/priority:\s*\{[^}]*enumValues: \["low", "high"\]/);
54+
// A plain string carries neither.
55+
expect(out).not.toMatch(/title:\s*\{[^}]*(format|enumValues)/);
56+
});
57+
4058
test("entity with no filterable fields emits empty allowlist", async () => {
4159
const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);
4260
const subscriber = root.objects().find((c) => c.name === "Subscriber")!;

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-allowlist.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,30 @@ import {
1010
FILTER_OPS,
1111
OPS_BY_SUBTYPE,
1212
opsForSubType,
13+
FIELD_SUBTYPE_DATE,
14+
FIELD_SUBTYPE_TIME,
15+
FIELD_SUBTYPE_TIMESTAMP,
16+
FIELD_SUBTYPE_UUID,
1317
type FilterOp,
1418
} from "@metaobjectsdev/metadata";
1519
export { FILTER_OPS, OPS_BY_SUBTYPE, opsForSubType, type FilterOp };
1620

1721
export type FilterSubType = "string" | "number" | "boolean" | "datetime";
1822

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+
1937
export interface FilterFieldRule {
2038
readonly ops: readonly FilterOp[];
2139
readonly subType: FilterSubType;
@@ -32,6 +50,21 @@ export interface FilterFieldRule {
3250
* both as strings under every dialect, so they are not governed by `timestampMode`.
3351
*/
3452
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[];
3568
}
3669

3770
export type FilterAllowlist = Readonly<Record<string, FilterFieldRule>>;

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts‎

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
} from "drizzle-orm";
55
import type { FilterAllowlist, FilterOp, FilterFieldRule, SortAllowlist } from "./filter-allowlist.js";
66
import { sortOrderSpec } from "./filter-allowlist.js";
7+
import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, matchesAnyTemporal, matchesFormat } from "./filter-value-format.js";
78

89
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
910
type AnyTable = any;
@@ -184,14 +185,14 @@ function compileOp(
184185
throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
185186
}
186187
switch (op as FilterOp) {
187-
case "eq": return eq(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
188-
case "ne": return ne(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
189-
case "gt": return gt(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
190-
case "gte": return gte(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
191-
case "lt": return lt(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
192-
case "lte": return lte(col as any, coerce(value, rule.subType, field, op, rule.dateValues));
188+
case "eq": return eq(col as any, coerce(value, rule, col, field, op));
189+
case "ne": return ne(col as any, coerce(value, rule, col, field, op));
190+
case "gt": return gt(col as any, coerce(value, rule, col, field, op));
191+
case "gte": return gte(col as any, coerce(value, rule, col, field, op));
192+
case "lt": return lt(col as any, coerce(value, rule, col, field, op));
193+
case "lte": return lte(col as any, coerce(value, rule, col, field, op));
193194
case "in": {
194-
const list = String(value).split(",").map((v) => coerce(v.trim(), rule.subType, field, op, rule.dateValues));
195+
const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
195196
if (list.length > maxInList) {
196197
throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
197198
}
@@ -216,7 +217,7 @@ function compileOp(
216217
// isNull's value is always coerced as boolean (true/false), regardless of the
217218
// field's declared subType — the operator is "is the value null?", not "is X
218219
// equal to null?". Field subtype is irrelevant.
219-
const b = coerce(value, "boolean", field, op) as boolean;
220+
const b = coerceAs(value, "boolean", field, op) as boolean;
220221
return b ? isNull(col as any) : not(isNull(col as any));
221222
}
222223
}
@@ -243,7 +244,54 @@ export function likePatternToGlob(pattern: string): string {
243244
return out;
244245
}
245246

246-
function coerce(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown {
247+
function invalidValue(
248+
field: string,
249+
op: string,
250+
s: string,
251+
expected: string,
252+
extra: Record<string, unknown> = {},
253+
): FilterParseError {
254+
return new FilterParseError(
255+
"filter.invalid_value",
256+
`Field "${field}" op "${op}" requires ${expected}, got "${s}".`,
257+
{ field, op, expected, ...extra },
258+
);
259+
}
260+
261+
/** A Drizzle column's own enum members (`text(..., { enum })`, `pgEnum`), if it has them. */
262+
function columnEnumValues(col: unknown): readonly string[] | undefined {
263+
const v = (col as { enumValues?: unknown } | null | undefined)?.enumValues;
264+
return Array.isArray(v) && v.length > 0 && v.every((m) => typeof m === "string") ? (v as string[]) : undefined;
265+
}
266+
267+
/**
268+
* Refuse a value that cannot be the field's type BEFORE it is bound. Without this a
269+
* malformed date on SQLite compared as text and silently matched nothing (200 `[]`),
270+
* and on Postgres the driver refused the cast (a 500) — either way the caller could not
271+
* tell a typo from an empty result. The envelope is the cross-port `invalid_filter_value`,
272+
* carrying `op` and `expected` as the number/boolean checks already did.
273+
*/
274+
function assertWellFormed(s: string, rule: FilterFieldRule, col: unknown, field: string, op: string): void {
275+
const members = rule.enumValues ?? columnEnumValues(col);
276+
if (members !== undefined && !members.includes(s)) {
277+
throw invalidValue(field, op, s, `one of: ${members.join(", ")}`, { allowed: [...members] });
278+
}
279+
if (rule.format !== undefined) {
280+
if (!matchesFormat(rule.format, s)) throw invalidValue(field, op, s, FORMAT_EXPECTED[rule.format]);
281+
} else if (rule.subType === "datetime" && !matchesAnyTemporal(s)) {
282+
throw invalidValue(field, op, s, ANY_TEMPORAL_EXPECTED);
283+
}
284+
}
285+
286+
/** Check a comparison value against the field's rule, then coerce it for binding. */
287+
function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string): unknown {
288+
if (value === null || value === undefined) return null;
289+
const s = typeof value === "string" ? value : String(value);
290+
assertWellFormed(s, rule, col, field, op);
291+
return coerceAs(s, rule.subType, field, op, rule.dateValues);
292+
}
293+
294+
function coerceAs(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown {
247295
if (value === null || value === undefined) return null;
248296
const s = typeof value === "string" ? value : String(value);
249297
switch (subType) {
@@ -254,7 +302,8 @@ function coerce(value: unknown, subType: string, field: string, op: string, date
254302
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires boolean, got "${s}".`, { field, op, expected: "boolean" });
255303
}
256304
case "number": {
257-
const n = Number(s);
305+
// `Number("")` and `Number(" ")` are 0 — an empty bound is not the number zero.
306+
const n = s.trim() === "" ? Number.NaN : Number(s);
258307
if (!Number.isFinite(n)) {
259308
throw new FilterParseError("filter.invalid_value", `Field "${field}" op "${op}" requires number, got "${s}".`, { field, op, expected: "number" });
260309
}

0 commit comments

Comments
 (0)