@nifrajs/schema 2.1.0 → 2.3.0
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/README.md +8 -8
- package/dist/adapter.d.ts +4 -4
- package/dist/adapter.js +4 -4
- package/dist/formats.d.ts +1 -1
- package/dist/formats.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/openapi.d.ts +4 -4
- package/dist/openapi.js +3 -3
- package/dist/pagination.d.ts +4 -4
- package/dist/pagination.js +3 -3
- package/dist/t.d.ts +4 -4
- package/dist/t.js +7 -7
- package/package.json +4 -4
- package/src/adapter.ts +5 -5
- package/src/formats.ts +1 -1
- package/src/index.ts +4 -4
- package/src/openapi.ts +9 -9
- package/src/pagination.ts +4 -4
- package/src/t.ts +7 -7
package/README.md
CHANGED
|
@@ -20,12 +20,12 @@ const app = server().post("/users", { body: t.object({ name: t.string(), age: t.
|
|
|
20
20
|
const openapi = toOpenAPI(app) // OpenAPI 3.1
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
- **`t` builder**
|
|
23
|
+
- **`t` builder** - `string`/`number`/`integer`/`boolean`/`null`/`literal`/`object`/
|
|
24
24
|
`array`/`optional`/`union`/`record`, with options (`min`/`max`, `pattern`, `format`,
|
|
25
25
|
…) that become JSON Schema constraints. Validators are compiled (fast).
|
|
26
|
-
- **Validating string formats**
|
|
26
|
+
- **Validating string formats** - `email`/`uuid`/`date-time`/`date`/`time`/`uri`/`ipv4`
|
|
27
27
|
validate *and* annotate; register more with `registerFormat`.
|
|
28
|
-
- **`toOpenAPI`**
|
|
28
|
+
- **`toOpenAPI`** - richest from a contract (it carries `response` schemas + op names →
|
|
29
29
|
`operationId`s); also works on a live app. Routes using a BYO Standard Schema are
|
|
30
30
|
emitted without a detailed schema (Standard Schema exposes no JSON Schema).
|
|
31
31
|
|
|
@@ -52,14 +52,14 @@ It emits `paths`, `parameters` (path + object query), `requestBody`, and `respon
|
|
|
52
52
|
- **`servers`**, **top-level `tags`**, and an info **`description`** (document options).
|
|
53
53
|
- **`securitySchemes`** → `components.securitySchemes`, a document-wide **`security`**, and per-operation
|
|
54
54
|
`security` (`[]` marks an operation explicitly public).
|
|
55
|
-
- **Non-200 responses** and **non-JSON content**
|
|
55
|
+
- **Non-200 responses** and **non-JSON content** - a contract op's `responses` map declares extra status
|
|
56
56
|
codes; `requestContentType` / `responseContentType` set media types other than `application/json`.
|
|
57
|
-
- **`$ref` reuse**
|
|
57
|
+
- **`$ref` reuse** - a schema with a `$id` (`t.object({…}, { $id: "User" })`) is hoisted into
|
|
58
58
|
`components.schemas` once and referenced by `$ref` everywhere it's used.
|
|
59
|
-
- Per-operation **`summary`**, **`description`**, **`tags`**, **`deprecated`**
|
|
59
|
+
- Per-operation **`summary`**, **`description`**, **`tags`**, **`deprecated`** - declared on the contract op.
|
|
60
60
|
|
|
61
61
|
A **contract** is richest (its ops carry all of the above); in **app** mode a route's response is a
|
|
62
|
-
generic `200` with no schema
|
|
62
|
+
generic `200` with no schema - declare a `defineContract` with `response` schemas, or pass
|
|
63
63
|
`options.operations` (keyed by `"METHOD /path"`) to enrich app routes. Routes using a BYO Standard Schema
|
|
64
64
|
are still emitted without body/response detail (Standard Schema exposes no JSON Schema).
|
|
65
65
|
|
|
@@ -67,7 +67,7 @@ are still emitted without body/response detail (Standard Schema exposes no JSON
|
|
|
67
67
|
|
|
68
68
|
## For AI agents
|
|
69
69
|
|
|
70
|
-
Start with [`LLM.md`](./LLM.md)
|
|
70
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
|
|
71
71
|
one cheap read instead of the whole corpus. For the wider framework: the repo's
|
|
72
72
|
[`AGENTS.md`](../../AGENTS.md) is the copy-paste quick reference, and
|
|
73
73
|
[`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus. Run `nifra check` as the
|
package/dist/adapter.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { StandardSchemaV1 } from "@nifrajs/core/schema";
|
|
|
2
2
|
import type { Static, TSchema } from "@sinclair/typebox";
|
|
3
3
|
/**
|
|
4
4
|
* A `t` schema. It is a Standard Schema (so any nifra route validates it with no
|
|
5
|
-
* special-casing) whose raw TypeBox definition stays reachable as `jsonSchema`
|
|
5
|
+
* special-casing) whose raw TypeBox definition stays reachable as `jsonSchema` -
|
|
6
6
|
* and because a TypeBox schema *is* a JSON Schema, that field is exactly what lets
|
|
7
7
|
* `toOpenAPI` emit a real request/response schema for the route. BYO Standard
|
|
8
8
|
* Schemas (zod/valibot/arktype) validate too but expose no JSON Schema, so only
|
|
@@ -16,13 +16,13 @@ export type NifraSchema<T extends TSchema = TSchema> = StandardSchemaV1<Static<T
|
|
|
16
16
|
*
|
|
17
17
|
* **Validation is fast where it can be, and works everywhere.** The first `validate`
|
|
18
18
|
* builds a compiled `TypeCompiler` validator (codegen via `new Function`) and memoizes
|
|
19
|
-
* it
|
|
19
|
+
* it - composing schemas (`t.object({ a: t.string() })`) never compiles the inner
|
|
20
20
|
* pieces; only a schema actually used to validate a request pays the one-time codegen
|
|
21
21
|
* cost. On Bun and Node this is the only path and it is untouched.
|
|
22
22
|
*
|
|
23
23
|
* Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) **forbid dynamic code
|
|
24
24
|
* generation**, so that first compile throws there. We catch it once per schema and
|
|
25
|
-
* fall back to TypeBox's eval-free `Value` checker
|
|
25
|
+
* fall back to TypeBox's eval-free `Value` checker - same semantics (and same global
|
|
26
26
|
* `FormatRegistry`, so `t.string({ format })` behaves identically), no `new Function`.
|
|
27
27
|
* The branch is decided once and memoized, so the hot path is a single check either way.
|
|
28
28
|
*
|
|
@@ -31,7 +31,7 @@ export type NifraSchema<T extends TSchema = TSchema> = StandardSchemaV1<Static<T
|
|
|
31
31
|
*
|
|
32
32
|
* `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
|
|
33
33
|
* BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
|
|
34
|
-
* with a numeric field can't validate without this
|
|
34
|
+
* with a numeric field can't validate without this - it's how `t.pageQuery` yields a real `number`.
|
|
35
35
|
* Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
|
|
36
36
|
* would silently accept `"20"` where the contract said `20`.
|
|
37
37
|
*/
|
package/dist/adapter.js
CHANGED
|
@@ -18,13 +18,13 @@ function toIssue(error) {
|
|
|
18
18
|
*
|
|
19
19
|
* **Validation is fast where it can be, and works everywhere.** The first `validate`
|
|
20
20
|
* builds a compiled `TypeCompiler` validator (codegen via `new Function`) and memoizes
|
|
21
|
-
* it
|
|
21
|
+
* it - composing schemas (`t.object({ a: t.string() })`) never compiles the inner
|
|
22
22
|
* pieces; only a schema actually used to validate a request pays the one-time codegen
|
|
23
23
|
* cost. On Bun and Node this is the only path and it is untouched.
|
|
24
24
|
*
|
|
25
25
|
* Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) **forbid dynamic code
|
|
26
26
|
* generation**, so that first compile throws there. We catch it once per schema and
|
|
27
|
-
* fall back to TypeBox's eval-free `Value` checker
|
|
27
|
+
* fall back to TypeBox's eval-free `Value` checker - same semantics (and same global
|
|
28
28
|
* `FormatRegistry`, so `t.string({ format })` behaves identically), no `new Function`.
|
|
29
29
|
* The branch is decided once and memoized, so the hot path is a single check either way.
|
|
30
30
|
*
|
|
@@ -33,7 +33,7 @@ function toIssue(error) {
|
|
|
33
33
|
*
|
|
34
34
|
* `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
|
|
35
35
|
* BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
|
|
36
|
-
* with a numeric field can't validate without this
|
|
36
|
+
* with a numeric field can't validate without this - it's how `t.pageQuery` yields a real `number`.
|
|
37
37
|
* Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
|
|
38
38
|
* would silently accept `"20"` where the contract said `20`.
|
|
39
39
|
*/
|
|
@@ -48,7 +48,7 @@ export function fromTypeBox(schema, options) {
|
|
|
48
48
|
validate: (value) => {
|
|
49
49
|
// Install the standard string formats before the first Compile/Check. Driven from this
|
|
50
50
|
// reachable path (not a top-level import side effect) so a production bundle can't
|
|
51
|
-
// tree-shake the registration away
|
|
51
|
+
// tree-shake the registration away - see ./formats.ts. Idempotent, ~free after first call.
|
|
52
52
|
ensureDefaultFormats();
|
|
53
53
|
// Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
|
|
54
54
|
// compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
|
package/dist/formats.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function registerFormat(name: string, validate: (value: string) =
|
|
|
6
6
|
* This is driven from the validate path (see `./adapter.ts`) rather than run as a bare
|
|
7
7
|
* top-level import side effect *on purpose*: a production bundler (`Bun.build`, esbuild)
|
|
8
8
|
* tree-shakes a module that is only imported for its effect, and Bun.build does **not**
|
|
9
|
-
* honor the `sideEffects` package.json field
|
|
9
|
+
* honor the `sideEffects` package.json field - so the old top-level registration loop was
|
|
10
10
|
* dropped from prod server bundles, making every `t.string({ format })` reject with
|
|
11
11
|
* "Unknown format" while unbundled `nifra dev` worked. Calling this from code the validator
|
|
12
12
|
* actually reaches keeps the registration in the bundle.
|
package/dist/formats.js
CHANGED
|
@@ -32,7 +32,7 @@ let defaultsRegistered = false;
|
|
|
32
32
|
* This is driven from the validate path (see `./adapter.ts`) rather than run as a bare
|
|
33
33
|
* top-level import side effect *on purpose*: a production bundler (`Bun.build`, esbuild)
|
|
34
34
|
* tree-shakes a module that is only imported for its effect, and Bun.build does **not**
|
|
35
|
-
* honor the `sideEffects` package.json field
|
|
35
|
+
* honor the `sideEffects` package.json field - so the old top-level registration loop was
|
|
36
36
|
* dropped from prod server bundles, making every `t.string({ format })` reject with
|
|
37
37
|
* "Unknown format" while unbundled `nifra dev` worked. Calling this from code the validator
|
|
38
38
|
* actually reaches keeps the registration in the bundle.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/schema`
|
|
2
|
+
* `@nifrajs/schema` - the optional, batteries-included schema builder for nifra.
|
|
3
3
|
*
|
|
4
4
|
* `t` is TypeBox-backed: validators are compiled (fast) and every schema is a JSON
|
|
5
5
|
* Schema (free OpenAPI). It implements Standard Schema, so `@nifrajs/core` validates
|
|
6
|
-
* it through the exact same path as any BYO library
|
|
6
|
+
* it through the exact same path as any BYO library - `@nifrajs/core` never depends on
|
|
7
7
|
* a validator; this package is the opt-in default.
|
|
8
8
|
*/
|
|
9
9
|
export { fromTypeBox, type NifraSchema } from "./adapter.js";
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/schema`
|
|
2
|
+
* `@nifrajs/schema` - the optional, batteries-included schema builder for nifra.
|
|
3
3
|
*
|
|
4
4
|
* `t` is TypeBox-backed: validators are compiled (fast) and every schema is a JSON
|
|
5
5
|
* Schema (free OpenAPI). It implements Standard Schema, so `@nifrajs/core` validates
|
|
6
|
-
* it through the exact same path as any BYO library
|
|
6
|
+
* it through the exact same path as any BYO library - `@nifrajs/core` never depends on
|
|
7
7
|
* a validator; this package is the opt-in default.
|
|
8
8
|
*/
|
|
9
9
|
export { fromTypeBox } from "./adapter.js";
|
|
10
10
|
// `registerFormat` adds/overrides a custom string format. The standard formats (email, uuid, …)
|
|
11
|
-
// install themselves lazily from the validate path (see ./adapter.ts)
|
|
11
|
+
// install themselves lazily from the validate path (see ./adapter.ts) - NOT as an import side
|
|
12
12
|
// effect of this module, which a production bundler would tree-shake away.
|
|
13
13
|
export { registerFormat } from "./formats.js";
|
|
14
14
|
export { toOpenAPI } from "./openapi.js";
|
|
15
|
-
// Cursor-pagination runtime helpers
|
|
15
|
+
// Cursor-pagination runtime helpers - pair with `t.paginated` / `t.pageQuery`.
|
|
16
16
|
export { decodeCursor, encodeCursor, paginate } from "./pagination.js";
|
|
17
17
|
export { t } from "./t.js";
|
|
18
18
|
//# sourceMappingURL=index.js.map
|
package/dist/openapi.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { ContractShape } from "@nifrajs/core/contract";
|
|
|
2
2
|
import { type JsonSchema } from "@nifrajs/core/reflection";
|
|
3
3
|
import type { Server } from "@nifrajs/core/server";
|
|
4
4
|
/**
|
|
5
|
-
* OpenAPI 3.1 generation. We model a practical slice of the spec
|
|
5
|
+
* OpenAPI 3.1 generation. We model a practical slice of the spec - enough to feed Swagger UI / codegen
|
|
6
6
|
* and to validate structurally: paths, parameters, request bodies, responses (incl. non-200 and
|
|
7
7
|
* non-JSON), tags, security, servers, and `$ref` reuse via `components.schemas`.
|
|
8
8
|
*
|
|
9
9
|
* Schemas carry full detail only for `t`/TypeBox inputs (they expose a JSON Schema); a BYO Standard
|
|
10
10
|
* Schema validates at runtime but exposes no JSON Schema, so its route is emitted without body/response
|
|
11
|
-
* detail. A **contract** is richest
|
|
11
|
+
* detail. A **contract** is richest - its operations carry `response`, `tags`, `security`, additional
|
|
12
12
|
* `responses`, etc.; an **app** emits request shapes + a generic `200` (an inline handler has no
|
|
13
13
|
* response *schema* to serialize), enrichable via `options.operations`.
|
|
14
14
|
*/
|
|
@@ -33,7 +33,7 @@ export interface ToOpenAPIOptions {
|
|
|
33
33
|
readonly description?: string;
|
|
34
34
|
/** Server URLs the API is served from (OpenAPI `servers`). */
|
|
35
35
|
readonly servers?: readonly OpenAPIServer[];
|
|
36
|
-
/** Tag definitions (OpenAPI top-level `tags`)
|
|
36
|
+
/** Tag definitions (OpenAPI top-level `tags`) - names referenced by an operation's `tags`. */
|
|
37
37
|
readonly tags?: readonly OpenAPITag[];
|
|
38
38
|
/** Reusable security schemes → `components.securitySchemes` (e.g. `{ bearer: { type: "http", scheme: "bearer" } }`). */
|
|
39
39
|
readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>;
|
|
@@ -42,7 +42,7 @@ export interface ToOpenAPIOptions {
|
|
|
42
42
|
/**
|
|
43
43
|
* Per-operation overrides, shallow-merged over the generated operation. Keyed by `operationId`
|
|
44
44
|
* (contract op name) or `"METHOD /path"` (e.g. `"GET /users/:id"`). The escape hatch for detail that
|
|
45
|
-
* can't be introspected
|
|
45
|
+
* can't be introspected - richer response bodies, examples, app-route tags/security.
|
|
46
46
|
*/
|
|
47
47
|
readonly operations?: Readonly<Record<string, Record<string, unknown>>>;
|
|
48
48
|
}
|
package/dist/openapi.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { reflectRoutes, reflectSchema, } from "@nifrajs/core/reflection";
|
|
2
2
|
/**
|
|
3
|
-
* Collects schemas that carry a `$id` into `components.schemas`, returning a `$ref` in their place
|
|
3
|
+
* Collects schemas that carry a `$id` into `components.schemas`, returning a `$ref` in their place -
|
|
4
4
|
* so a schema used by N operations is emitted once. Schemas without a `$id` stay inline (the existing
|
|
5
5
|
* behavior). The first sighting of an id wins; later ones just `$ref` it.
|
|
6
6
|
*/
|
|
@@ -209,9 +209,9 @@ export function toOpenAPI(input, options = {}) {
|
|
|
209
209
|
body: route.schema?.body,
|
|
210
210
|
query: route.schema?.query,
|
|
211
211
|
params: route.schema?.params,
|
|
212
|
-
// A route may now declare a `response` contract
|
|
212
|
+
// A route may now declare a `response` contract - emit it as the 200 body schema.
|
|
213
213
|
response: route.schema?.response,
|
|
214
|
-
// …and an `errors` contract
|
|
214
|
+
// …and an `errors` contract - emit each as a non-2xx response.
|
|
215
215
|
responses: reflectedErrorsToResponses(route.schema?.errors),
|
|
216
216
|
operationId: undefined,
|
|
217
217
|
}, store, options.operations);
|
package/dist/pagination.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cursor-pagination runtime helpers
|
|
2
|
+
* Cursor-pagination runtime helpers - an opaque cursor codec + a page builder. Pair with `t.paginated`
|
|
3
3
|
* (the response schema) and `t.pageQuery` (the request schema).
|
|
4
4
|
*
|
|
5
5
|
* Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
|
|
6
|
-
* Workers. Cursors are opaque but NOT signed
|
|
6
|
+
* Workers. Cursors are opaque but NOT signed - treat a decoded cursor as untrusted client input: validate
|
|
7
7
|
* its shape before keying a DB query off it (it's a position hint, not an authorization token).
|
|
8
8
|
*/
|
|
9
9
|
/** Encode any JSON-serializable value (e.g. the last row's sort key) into an opaque cursor string. */
|
|
10
10
|
export declare function encodeCursor(value: unknown): string;
|
|
11
|
-
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor
|
|
11
|
+
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor - treat that
|
|
12
12
|
* as "start from the beginning" rather than erroring on a client-supplied string. */
|
|
13
13
|
export declare function decodeCursor<T = unknown>(cursor: string | null | undefined): T | undefined;
|
|
14
|
-
/** A cursor-pagination page
|
|
14
|
+
/** A cursor-pagination page - matches the shape of `t.paginated(item)`. */
|
|
15
15
|
export interface Page<Item> {
|
|
16
16
|
readonly items: Item[];
|
|
17
17
|
readonly nextCursor: string | null;
|
package/dist/pagination.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cursor-pagination runtime helpers
|
|
2
|
+
* Cursor-pagination runtime helpers - an opaque cursor codec + a page builder. Pair with `t.paginated`
|
|
3
3
|
* (the response schema) and `t.pageQuery` (the request schema).
|
|
4
4
|
*
|
|
5
5
|
* Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
|
|
6
|
-
* Workers. Cursors are opaque but NOT signed
|
|
6
|
+
* Workers. Cursors are opaque but NOT signed - treat a decoded cursor as untrusted client input: validate
|
|
7
7
|
* its shape before keying a DB query off it (it's a position hint, not an authorization token).
|
|
8
8
|
*/
|
|
9
9
|
/** UTF-8 → URL-safe base64 (no padding). */
|
|
@@ -24,7 +24,7 @@ function fromBase64Url(input) {
|
|
|
24
24
|
export function encodeCursor(value) {
|
|
25
25
|
return toBase64Url(JSON.stringify(value));
|
|
26
26
|
}
|
|
27
|
-
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor
|
|
27
|
+
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor - treat that
|
|
28
28
|
* as "start from the beginning" rather than erroring on a client-supplied string. */
|
|
29
29
|
export function decodeCursor(cursor) {
|
|
30
30
|
if (cursor === null || cursor === undefined || cursor === "")
|
package/dist/t.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ArrayOptions, type IntegerOptions, type NumberOptions, type Object
|
|
|
2
2
|
import { type NifraSchema } from "./adapter.js";
|
|
3
3
|
type Props = Record<string, NifraSchema>;
|
|
4
4
|
/**
|
|
5
|
-
* The built-in schema builder. Each constructor returns a `NifraSchema`
|
|
5
|
+
* The built-in schema builder. Each constructor returns a `NifraSchema` - a
|
|
6
6
|
* Standard Schema whose validated output type flows into `c.body`/`c.query`, and
|
|
7
7
|
* whose `jsonSchema` powers `toOpenAPI`. Options (min/max, length, pattern, …)
|
|
8
8
|
* pass straight through to TypeBox and so become JSON Schema constraints.
|
|
@@ -21,7 +21,7 @@ export declare const t: {
|
|
|
21
21
|
readonly null: () => NifraSchema<import("@sinclair/typebox").TNull>;
|
|
22
22
|
readonly literal: <const L extends TLiteralValue>(value: L) => NifraSchema<import("@sinclair/typebox").TLiteral<L>>;
|
|
23
23
|
readonly object: <P extends Props>(props: P, options?: ObjectOptions) => NifraSchema<import("@sinclair/typebox").TObject<{ [K in keyof P]: P[K]["jsonSchema"]; }>>;
|
|
24
|
-
/** Like `t.object` but ACCEPTS (and passes through) unknown fields
|
|
24
|
+
/** Like `t.object` but ACCEPTS (and passes through) unknown fields - the explicit opt-out of the
|
|
25
25
|
* strict default. Prefer `t.object` unless you genuinely need an open object. */
|
|
26
26
|
readonly looseObject: <P extends Props>(props: P, options?: ObjectOptions) => NifraSchema<import("@sinclair/typebox").TObject<{ [K in keyof P]: P[K]["jsonSchema"]; }>>;
|
|
27
27
|
readonly array: <T extends TSchema>(item: NifraSchema<T>, options?: ArrayOptions) => NifraSchema<import("@sinclair/typebox").TArray<T>>;
|
|
@@ -35,7 +35,7 @@ export declare const t: {
|
|
|
35
35
|
nextCursor: TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>;
|
|
36
36
|
}>>;
|
|
37
37
|
/** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
|
|
38
|
-
* caps `limit`
|
|
38
|
+
* caps `limit` - a larger value fails validation (a 422), so a client can't request an unbounded page.
|
|
39
39
|
* `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
|
|
40
40
|
* a real `number` in the handler (`c.query.limit`), not a string. */
|
|
41
41
|
readonly pageQuery: (options?: {
|
|
@@ -47,7 +47,7 @@ export declare const t: {
|
|
|
47
47
|
/** A request-query schema with string->scalar COERCION on. Query values always arrive as strings
|
|
48
48
|
* (`?limit=20` -> `"20"`), so a plain `t.object({ limit: t.integer() })` in a `query` slot fails to
|
|
49
49
|
* validate; use `t.query` and `t.integer()`/`t.number()`/`t.boolean()` fields become real numbers/
|
|
50
|
-
* booleans in `c.query`. **Open by default** (unknown query fields pass through
|
|
50
|
+
* booleans in `c.query`. **Open by default** (unknown query fields pass through - `additionalProperties:
|
|
51
51
|
* true`): query params are read by name, not spread into a DB write, so unknown params (UTM tracking,
|
|
52
52
|
* `fbclid`, etc.) passing through is safe, and rejecting them is a production-only footgun (ad/social
|
|
53
53
|
* traffic appends params your schema never declares, causing 422s that never appear in dev/CI). Pass
|
package/dist/t.js
CHANGED
|
@@ -9,7 +9,7 @@ function unwrap(props) {
|
|
|
9
9
|
return out;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* The built-in schema builder. Each constructor returns a `NifraSchema`
|
|
12
|
+
* The built-in schema builder. Each constructor returns a `NifraSchema` - a
|
|
13
13
|
* Standard Schema whose validated output type flows into `c.body`/`c.query`, and
|
|
14
14
|
* whose `jsonSchema` powers `toOpenAPI`. Options (min/max, length, pattern, …)
|
|
15
15
|
* pass straight through to TypeBox and so become JSON Schema constraints.
|
|
@@ -27,12 +27,12 @@ export const t = {
|
|
|
27
27
|
boolean: () => fromTypeBox(Type.Boolean()),
|
|
28
28
|
null: () => fromTypeBox(Type.Null()),
|
|
29
29
|
literal: (value) => fromTypeBox(Type.Literal(value)),
|
|
30
|
-
// `t.object` REJECTS unknown fields by default (`additionalProperties: false`)
|
|
30
|
+
// `t.object` REJECTS unknown fields by default (`additionalProperties: false`) - the trust-boundary
|
|
31
31
|
// rule. A body with extra keys fails validation (a 422), so `c.body` never carries attacker-supplied
|
|
32
32
|
// properties (no mass-assignment). Use `t.looseObject` (or pass `{ additionalProperties: true }`) to
|
|
33
33
|
// opt into an open object; an explicit `options.additionalProperties` always wins over the default.
|
|
34
34
|
object: (props, options) => fromTypeBox(Type.Object(unwrap(props), { additionalProperties: false, ...options })),
|
|
35
|
-
/** Like `t.object` but ACCEPTS (and passes through) unknown fields
|
|
35
|
+
/** Like `t.object` but ACCEPTS (and passes through) unknown fields - the explicit opt-out of the
|
|
36
36
|
* strict default. Prefer `t.object` unless you genuinely need an open object. */
|
|
37
37
|
looseObject: (props, options) => fromTypeBox(Type.Object(unwrap(props), { additionalProperties: true, ...options })),
|
|
38
38
|
array: (item, options) => fromTypeBox(Type.Array(item.jsonSchema, options)),
|
|
@@ -40,13 +40,13 @@ export const t = {
|
|
|
40
40
|
optional: (schema) => fromTypeBox(Type.Optional(schema.jsonSchema)),
|
|
41
41
|
// `const S` captures the argument as a tuple; the explicit return type then maps
|
|
42
42
|
// over that captured tuple (`S[K]["jsonSchema"]`) so the union's `Static` is
|
|
43
|
-
// `A | B`, not `unknown`
|
|
43
|
+
// `A | B`, not `unknown` - the value-level `.map` can't preserve per-element
|
|
44
44
|
// types, so the output type is derived from `S` and the result cast to match
|
|
45
45
|
// (order/length are preserved by `map`, so the tuple shape is sound).
|
|
46
46
|
union: (schemas) => fromTypeBox(Type.Union(schemas.map((schema) => schema.jsonSchema))),
|
|
47
47
|
record: (value, options) => fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
|
|
48
48
|
// Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
|
|
49
|
-
// itself during inference. Cursor pagination
|
|
49
|
+
// itself during inference. Cursor pagination - not OFFSET - is the production default: stable under
|
|
50
50
|
// concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
|
|
51
51
|
/** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
|
|
52
52
|
paginated: (item, options) => fromTypeBox(Type.Object({
|
|
@@ -54,7 +54,7 @@ export const t = {
|
|
|
54
54
|
nextCursor: Type.Union([Type.String(), Type.Null()]),
|
|
55
55
|
}, { additionalProperties: false, ...options })),
|
|
56
56
|
/** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
|
|
57
|
-
* caps `limit`
|
|
57
|
+
* caps `limit` - a larger value fails validation (a 422), so a client can't request an unbounded page.
|
|
58
58
|
* `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
|
|
59
59
|
* a real `number` in the handler (`c.query.limit`), not a string. */
|
|
60
60
|
pageQuery: (options) => fromTypeBox(Type.Object({
|
|
@@ -64,7 +64,7 @@ export const t = {
|
|
|
64
64
|
/** A request-query schema with string->scalar COERCION on. Query values always arrive as strings
|
|
65
65
|
* (`?limit=20` -> `"20"`), so a plain `t.object({ limit: t.integer() })` in a `query` slot fails to
|
|
66
66
|
* validate; use `t.query` and `t.integer()`/`t.number()`/`t.boolean()` fields become real numbers/
|
|
67
|
-
* booleans in `c.query`. **Open by default** (unknown query fields pass through
|
|
67
|
+
* booleans in `c.query`. **Open by default** (unknown query fields pass through - `additionalProperties:
|
|
68
68
|
* true`): query params are read by name, not spread into a DB write, so unknown params (UTM tracking,
|
|
69
69
|
* `fbclid`, etc.) passing through is safe, and rejecting them is a production-only footgun (ad/social
|
|
70
70
|
* traffic appends params your schema never declares, causing 422s that never appear in dev/CI). Pass
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/schema",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Built-in schema builder `t`
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Built-in schema builder `t` - TypeBox-backed (free JSON Schema + compiled validators), implements Standard Schema so nifra routes validate it natively.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dist",
|
|
36
36
|
"src"
|
|
37
37
|
],
|
|
38
|
-
"comment:sideEffects": "No module here runs work at import time: the standard string formats install lazily from the validate path (src/adapter.ts → src/formats.ts ensureDefaultFormats), not as a top-level import side effect. The previous `sideEffects: [formats]` array did not survive bundling
|
|
38
|
+
"comment:sideEffects": "No module here runs work at import time: the standard string formats install lazily from the validate path (src/adapter.ts → src/formats.ts ensureDefaultFormats), not as a top-level import side effect. The previous `sideEffects: [formats]` array did not survive bundling - Bun.build ignores the field - which is why formats were dropped from prod bundles. `false` is accurate now and lets consumers tree-shake freely.",
|
|
39
39
|
"sideEffects": false,
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@sinclair/typebox": "^0.34.49"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@nifrajs/core": "^2.
|
|
51
|
+
"@nifrajs/core": "^2.3.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@nifrajs/core": "workspace:*",
|
package/src/adapter.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { ensureDefaultFormats } from "./formats.ts"
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* A `t` schema. It is a Standard Schema (so any nifra route validates it with no
|
|
14
|
-
* special-casing) whose raw TypeBox definition stays reachable as `jsonSchema`
|
|
14
|
+
* special-casing) whose raw TypeBox definition stays reachable as `jsonSchema` -
|
|
15
15
|
* and because a TypeBox schema *is* a JSON Schema, that field is exactly what lets
|
|
16
16
|
* `toOpenAPI` emit a real request/response schema for the route. BYO Standard
|
|
17
17
|
* Schemas (zod/valibot/arktype) validate too but expose no JSON Schema, so only
|
|
@@ -39,13 +39,13 @@ function toIssue(error: { readonly message: string; readonly path: string }): St
|
|
|
39
39
|
*
|
|
40
40
|
* **Validation is fast where it can be, and works everywhere.** The first `validate`
|
|
41
41
|
* builds a compiled `TypeCompiler` validator (codegen via `new Function`) and memoizes
|
|
42
|
-
* it
|
|
42
|
+
* it - composing schemas (`t.object({ a: t.string() })`) never compiles the inner
|
|
43
43
|
* pieces; only a schema actually used to validate a request pays the one-time codegen
|
|
44
44
|
* cost. On Bun and Node this is the only path and it is untouched.
|
|
45
45
|
*
|
|
46
46
|
* Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) **forbid dynamic code
|
|
47
47
|
* generation**, so that first compile throws there. We catch it once per schema and
|
|
48
|
-
* fall back to TypeBox's eval-free `Value` checker
|
|
48
|
+
* fall back to TypeBox's eval-free `Value` checker - same semantics (and same global
|
|
49
49
|
* `FormatRegistry`, so `t.string({ format })` behaves identically), no `new Function`.
|
|
50
50
|
* The branch is decided once and memoized, so the hot path is a single check either way.
|
|
51
51
|
*
|
|
@@ -54,7 +54,7 @@ function toIssue(error: { readonly message: string; readonly path: string }): St
|
|
|
54
54
|
*
|
|
55
55
|
* `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
|
|
56
56
|
* BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
|
|
57
|
-
* with a numeric field can't validate without this
|
|
57
|
+
* with a numeric field can't validate without this - it's how `t.pageQuery` yields a real `number`.
|
|
58
58
|
* Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
|
|
59
59
|
* would silently accept `"20"` where the contract said `20`.
|
|
60
60
|
*/
|
|
@@ -72,7 +72,7 @@ export function fromTypeBox<T extends TSchema>(
|
|
|
72
72
|
validate: (value: unknown): StandardResult<Static<T>> => {
|
|
73
73
|
// Install the standard string formats before the first Compile/Check. Driven from this
|
|
74
74
|
// reachable path (not a top-level import side effect) so a production bundle can't
|
|
75
|
-
// tree-shake the registration away
|
|
75
|
+
// tree-shake the registration away - see ./formats.ts. Idempotent, ~free after first call.
|
|
76
76
|
ensureDefaultFormats()
|
|
77
77
|
// Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
|
|
78
78
|
// compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
|
package/src/formats.ts
CHANGED
|
@@ -36,7 +36,7 @@ let defaultsRegistered = false
|
|
|
36
36
|
* This is driven from the validate path (see `./adapter.ts`) rather than run as a bare
|
|
37
37
|
* top-level import side effect *on purpose*: a production bundler (`Bun.build`, esbuild)
|
|
38
38
|
* tree-shakes a module that is only imported for its effect, and Bun.build does **not**
|
|
39
|
-
* honor the `sideEffects` package.json field
|
|
39
|
+
* honor the `sideEffects` package.json field - so the old top-level registration loop was
|
|
40
40
|
* dropped from prod server bundles, making every `t.string({ format })` reject with
|
|
41
41
|
* "Unknown format" while unbundled `nifra dev` worked. Calling this from code the validator
|
|
42
42
|
* actually reaches keeps the registration in the bundle.
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/schema`
|
|
2
|
+
* `@nifrajs/schema` - the optional, batteries-included schema builder for nifra.
|
|
3
3
|
*
|
|
4
4
|
* `t` is TypeBox-backed: validators are compiled (fast) and every schema is a JSON
|
|
5
5
|
* Schema (free OpenAPI). It implements Standard Schema, so `@nifrajs/core` validates
|
|
6
|
-
* it through the exact same path as any BYO library
|
|
6
|
+
* it through the exact same path as any BYO library - `@nifrajs/core` never depends on
|
|
7
7
|
* a validator; this package is the opt-in default.
|
|
8
8
|
*/
|
|
9
9
|
export { fromTypeBox, type NifraSchema } from "./adapter.ts"
|
|
10
10
|
// `registerFormat` adds/overrides a custom string format. The standard formats (email, uuid, …)
|
|
11
|
-
// install themselves lazily from the validate path (see ./adapter.ts)
|
|
11
|
+
// install themselves lazily from the validate path (see ./adapter.ts) - NOT as an import side
|
|
12
12
|
// effect of this module, which a production bundler would tree-shake away.
|
|
13
13
|
export { registerFormat } from "./formats.ts"
|
|
14
14
|
export { type OpenAPIDocument, type OpenAPIInfo, toOpenAPI } from "./openapi.ts"
|
|
15
|
-
// Cursor-pagination runtime helpers
|
|
15
|
+
// Cursor-pagination runtime helpers - pair with `t.paginated` / `t.pageQuery`.
|
|
16
16
|
export { decodeCursor, encodeCursor, type Page, paginate } from "./pagination.ts"
|
|
17
17
|
export { t } from "./t.ts"
|
package/src/openapi.ts
CHANGED
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
import type { Server } from "@nifrajs/core/server"
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* OpenAPI 3.1 generation. We model a practical slice of the spec
|
|
12
|
+
* OpenAPI 3.1 generation. We model a practical slice of the spec - enough to feed Swagger UI / codegen
|
|
13
13
|
* and to validate structurally: paths, parameters, request bodies, responses (incl. non-200 and
|
|
14
14
|
* non-JSON), tags, security, servers, and `$ref` reuse via `components.schemas`.
|
|
15
15
|
*
|
|
16
16
|
* Schemas carry full detail only for `t`/TypeBox inputs (they expose a JSON Schema); a BYO Standard
|
|
17
17
|
* Schema validates at runtime but exposes no JSON Schema, so its route is emitted without body/response
|
|
18
|
-
* detail. A **contract** is richest
|
|
18
|
+
* detail. A **contract** is richest - its operations carry `response`, `tags`, `security`, additional
|
|
19
19
|
* `responses`, etc.; an **app** emits request shapes + a generic `200` (an inline handler has no
|
|
20
20
|
* response *schema* to serialize), enrichable via `options.operations`.
|
|
21
21
|
*/
|
|
@@ -44,7 +44,7 @@ export interface ToOpenAPIOptions {
|
|
|
44
44
|
readonly description?: string
|
|
45
45
|
/** Server URLs the API is served from (OpenAPI `servers`). */
|
|
46
46
|
readonly servers?: readonly OpenAPIServer[]
|
|
47
|
-
/** Tag definitions (OpenAPI top-level `tags`)
|
|
47
|
+
/** Tag definitions (OpenAPI top-level `tags`) - names referenced by an operation's `tags`. */
|
|
48
48
|
readonly tags?: readonly OpenAPITag[]
|
|
49
49
|
/** Reusable security schemes → `components.securitySchemes` (e.g. `{ bearer: { type: "http", scheme: "bearer" } }`). */
|
|
50
50
|
readonly securitySchemes?: Readonly<Record<string, Record<string, unknown>>>
|
|
@@ -53,7 +53,7 @@ export interface ToOpenAPIOptions {
|
|
|
53
53
|
/**
|
|
54
54
|
* Per-operation overrides, shallow-merged over the generated operation. Keyed by `operationId`
|
|
55
55
|
* (contract op name) or `"METHOD /path"` (e.g. `"GET /users/:id"`). The escape hatch for detail that
|
|
56
|
-
* can't be introspected
|
|
56
|
+
* can't be introspected - richer response bodies, examples, app-route tags/security.
|
|
57
57
|
*/
|
|
58
58
|
readonly operations?: Readonly<Record<string, Record<string, unknown>>>
|
|
59
59
|
}
|
|
@@ -107,7 +107,7 @@ export interface OpenAPIDocument {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Collects schemas that carry a `$id` into `components.schemas`, returning a `$ref` in their place
|
|
110
|
+
* Collects schemas that carry a `$id` into `components.schemas`, returning a `$ref` in their place -
|
|
111
111
|
* so a schema used by N operations is emitted once. Schemas without a `$id` stay inline (the existing
|
|
112
112
|
* behavior). The first sighting of an id wins; later ones just `$ref` it.
|
|
113
113
|
*/
|
|
@@ -211,11 +211,11 @@ interface OperationInput {
|
|
|
211
211
|
readonly path: string
|
|
212
212
|
readonly body: SchemaReflection | undefined
|
|
213
213
|
readonly query: SchemaReflection | undefined
|
|
214
|
-
/** Reflected params schema
|
|
214
|
+
/** Reflected params schema - per-field constraints merge into path parameters. */
|
|
215
215
|
readonly params: SchemaReflection | undefined
|
|
216
216
|
readonly response: SchemaReflection | undefined
|
|
217
217
|
readonly operationId: string | undefined
|
|
218
|
-
// `| undefined` (not just `?`) so a contract op's optional fields
|
|
218
|
+
// `| undefined` (not just `?`) so a contract op's optional fields - `string | undefined` etc. - are
|
|
219
219
|
// assignable under `exactOptionalPropertyTypes` when spread into this literal.
|
|
220
220
|
readonly summary?: string | undefined
|
|
221
221
|
readonly description?: string | undefined
|
|
@@ -356,9 +356,9 @@ export function toOpenAPI(
|
|
|
356
356
|
body: route.schema?.body,
|
|
357
357
|
query: route.schema?.query,
|
|
358
358
|
params: route.schema?.params,
|
|
359
|
-
// A route may now declare a `response` contract
|
|
359
|
+
// A route may now declare a `response` contract - emit it as the 200 body schema.
|
|
360
360
|
response: route.schema?.response,
|
|
361
|
-
// …and an `errors` contract
|
|
361
|
+
// …and an `errors` contract - emit each as a non-2xx response.
|
|
362
362
|
responses: reflectedErrorsToResponses(route.schema?.errors),
|
|
363
363
|
operationId: undefined,
|
|
364
364
|
},
|
package/src/pagination.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cursor-pagination runtime helpers
|
|
2
|
+
* Cursor-pagination runtime helpers - an opaque cursor codec + a page builder. Pair with `t.paginated`
|
|
3
3
|
* (the response schema) and `t.pageQuery` (the request schema).
|
|
4
4
|
*
|
|
5
5
|
* Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
|
|
6
|
-
* Workers. Cursors are opaque but NOT signed
|
|
6
|
+
* Workers. Cursors are opaque but NOT signed - treat a decoded cursor as untrusted client input: validate
|
|
7
7
|
* its shape before keying a DB query off it (it's a position hint, not an authorization token).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -27,7 +27,7 @@ export function encodeCursor(value: unknown): string {
|
|
|
27
27
|
return toBase64Url(JSON.stringify(value))
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor
|
|
30
|
+
/** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor - treat that
|
|
31
31
|
* as "start from the beginning" rather than erroring on a client-supplied string. */
|
|
32
32
|
export function decodeCursor<T = unknown>(cursor: string | null | undefined): T | undefined {
|
|
33
33
|
if (cursor === null || cursor === undefined || cursor === "") return undefined
|
|
@@ -38,7 +38,7 @@ export function decodeCursor<T = unknown>(cursor: string | null | undefined): T
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** A cursor-pagination page
|
|
41
|
+
/** A cursor-pagination page - matches the shape of `t.paginated(item)`. */
|
|
42
42
|
export interface Page<Item> {
|
|
43
43
|
readonly items: Item[]
|
|
44
44
|
readonly nextCursor: string | null
|
package/src/t.ts
CHANGED
|
@@ -22,7 +22,7 @@ function unwrap<P extends Props>(props: P): { [K in keyof P]: P[K]["jsonSchema"]
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* The built-in schema builder. Each constructor returns a `NifraSchema`
|
|
25
|
+
* The built-in schema builder. Each constructor returns a `NifraSchema` - a
|
|
26
26
|
* Standard Schema whose validated output type flows into `c.body`/`c.query`, and
|
|
27
27
|
* whose `jsonSchema` powers `toOpenAPI`. Options (min/max, length, pattern, …)
|
|
28
28
|
* pass straight through to TypeBox and so become JSON Schema constraints.
|
|
@@ -41,13 +41,13 @@ export const t = {
|
|
|
41
41
|
null: () => fromTypeBox(Type.Null()),
|
|
42
42
|
literal: <const L extends TLiteralValue>(value: L) => fromTypeBox(Type.Literal(value)),
|
|
43
43
|
|
|
44
|
-
// `t.object` REJECTS unknown fields by default (`additionalProperties: false`)
|
|
44
|
+
// `t.object` REJECTS unknown fields by default (`additionalProperties: false`) - the trust-boundary
|
|
45
45
|
// rule. A body with extra keys fails validation (a 422), so `c.body` never carries attacker-supplied
|
|
46
46
|
// properties (no mass-assignment). Use `t.looseObject` (or pass `{ additionalProperties: true }`) to
|
|
47
47
|
// opt into an open object; an explicit `options.additionalProperties` always wins over the default.
|
|
48
48
|
object: <P extends Props>(props: P, options?: ObjectOptions) =>
|
|
49
49
|
fromTypeBox(Type.Object(unwrap(props), { additionalProperties: false, ...options })),
|
|
50
|
-
/** Like `t.object` but ACCEPTS (and passes through) unknown fields
|
|
50
|
+
/** Like `t.object` but ACCEPTS (and passes through) unknown fields - the explicit opt-out of the
|
|
51
51
|
* strict default. Prefer `t.object` unless you genuinely need an open object. */
|
|
52
52
|
looseObject: <P extends Props>(props: P, options?: ObjectOptions) =>
|
|
53
53
|
fromTypeBox(Type.Object(unwrap(props), { additionalProperties: true, ...options })),
|
|
@@ -58,7 +58,7 @@ export const t = {
|
|
|
58
58
|
fromTypeBox(Type.Optional(schema.jsonSchema)),
|
|
59
59
|
// `const S` captures the argument as a tuple; the explicit return type then maps
|
|
60
60
|
// over that captured tuple (`S[K]["jsonSchema"]`) so the union's `Static` is
|
|
61
|
-
// `A | B`, not `unknown`
|
|
61
|
+
// `A | B`, not `unknown` - the value-level `.map` can't preserve per-element
|
|
62
62
|
// types, so the output type is derived from `S` and the result cast to match
|
|
63
63
|
// (order/length are preserved by `map`, so the tuple shape is sound).
|
|
64
64
|
union: <const S extends readonly NifraSchema[]>(schemas: S) =>
|
|
@@ -69,7 +69,7 @@ export const t = {
|
|
|
69
69
|
fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
|
|
70
70
|
|
|
71
71
|
// Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
|
|
72
|
-
// itself during inference. Cursor pagination
|
|
72
|
+
// itself during inference. Cursor pagination - not OFFSET - is the production default: stable under
|
|
73
73
|
// concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
|
|
74
74
|
/** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
|
|
75
75
|
paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) =>
|
|
@@ -83,7 +83,7 @@ export const t = {
|
|
|
83
83
|
),
|
|
84
84
|
),
|
|
85
85
|
/** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
|
|
86
|
-
* caps `limit`
|
|
86
|
+
* caps `limit` - a larger value fails validation (a 422), so a client can't request an unbounded page.
|
|
87
87
|
* `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
|
|
88
88
|
* a real `number` in the handler (`c.query.limit`), not a string. */
|
|
89
89
|
pageQuery: (options?: { maxLimit?: number }) =>
|
|
@@ -100,7 +100,7 @@ export const t = {
|
|
|
100
100
|
/** A request-query schema with string->scalar COERCION on. Query values always arrive as strings
|
|
101
101
|
* (`?limit=20` -> `"20"`), so a plain `t.object({ limit: t.integer() })` in a `query` slot fails to
|
|
102
102
|
* validate; use `t.query` and `t.integer()`/`t.number()`/`t.boolean()` fields become real numbers/
|
|
103
|
-
* booleans in `c.query`. **Open by default** (unknown query fields pass through
|
|
103
|
+
* booleans in `c.query`. **Open by default** (unknown query fields pass through - `additionalProperties:
|
|
104
104
|
* true`): query params are read by name, not spread into a DB write, so unknown params (UTM tracking,
|
|
105
105
|
* `fbclid`, etc.) passing through is safe, and rejecting them is a production-only footgun (ad/social
|
|
106
106
|
* traffic appends params your schema never declares, causing 422s that never appear in dev/CI). Pass
|