From f9b1f564ad949a81a8407fd90ecc14d497bb445d Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Mon, 28 Sep 2026 20:23:01 +0000 Subject: [PATCH 1/5] feat(schema): compose typed schemas across boundaries --- .changeset/schema-boundaries.md | 12 + libs/knex-schema/src/entity.ts | 32 +- libs/schema-json/README.md | 11 + libs/schema-json/src/boundaries.test.ts | 108 ++++ libs/schema-json/src/standardJsonSchema.ts | 25 +- libs/schema-json/src/toJsonSchema.ts | 85 ++- libs/schema-json/src/types.ts | 2 + libs/schema/BOUNDARIES.md | 151 +++++ libs/schema/README.md | 9 + libs/schema/package.json | 3 +- libs/schema/src/boundaries-docs.test.ts | 62 ++ .../schema/src/builders/ArraySchemaBuilder.ts | 8 +- .../builders/BoundarySchemaBuilder.test.ts | 167 ++++++ .../src/builders/BoundarySchemaBuilder.ts | 528 ++++++++++++++++++ .../src/builders/ExternSchemaBuilder.ts | 3 +- .../src/builders/IntersectionSchemaBuilder.ts | 4 +- libs/schema/src/builders/LazySchemaBuilder.ts | 48 +- .../src/builders/ObjectSchemaBuilder.ts | 49 +- .../src/builders/RecordSchemaBuilder.ts | 4 +- libs/schema/src/builders/SchemaBuilder.ts | 205 ++++--- .../schema/src/builders/TupleSchemaBuilder.ts | 9 +- .../schema/src/builders/UnionSchemaBuilder.ts | 4 +- libs/schema/src/builders/boundaries.test-d.ts | 102 ++++ libs/schema/src/core.ts | 7 + libs/schema/tsconfig.build.json | 2 +- libs/schema/tsconfig.typecheck.json | 6 + libs/schema/vitest.config.mts | 12 + libs/server-openapi/README.md | 12 + libs/server-openapi/src/boundaries.test.ts | 102 ++++ .../src/generateAsyncApiSpec.ts | 17 +- .../server-openapi/src/generateOpenApiSpec.ts | 41 +- libs/server-openapi/src/schemaConverter.ts | 9 +- libs/server-openapi/src/schemaRegistry.ts | 103 +++- websites/docs/app/server-openapi/page.tsx | 13 + websites/schema/app/docs/[[...slug]]/page.tsx | 2 + websites/schema/app/docs/sections/index.ts | 6 + .../app/docs/sections/schema-boundaries.tsx | 87 +++ websites/schema/app/schema-json/page.tsx | 13 + 38 files changed, 1871 insertions(+), 192 deletions(-) create mode 100644 .changeset/schema-boundaries.md create mode 100644 libs/schema-json/src/boundaries.test.ts create mode 100644 libs/schema/BOUNDARIES.md create mode 100644 libs/schema/src/boundaries-docs.test.ts create mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.test.ts create mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.ts create mode 100644 libs/schema/src/builders/boundaries.test-d.ts create mode 100644 libs/schema/tsconfig.typecheck.json create mode 100644 libs/schema/vitest.config.mts create mode 100644 libs/server-openapi/src/boundaries.test.ts create mode 100644 websites/schema/app/docs/sections/schema-boundaries.tsx diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md new file mode 100644 index 00000000..e5c45af5 --- /dev/null +++ b/.changeset/schema-boundaries.md @@ -0,0 +1,12 @@ +--- +"@cleverbrush/schema": minor +"@cleverbrush/schema-json": minor +"@cleverbrush/server-openapi": minor +"@cleverbrush/knex-schema": minor +--- + +Add optional-aware fallbacks and preprocessing, immutable named schema references, +and typed input/output decoding with nested inference and Standard Schema support. +Generate directional JSON Schema/OpenAPI components with strict name collision +checks. Preserve existing InferType output semantics and legacy optional null +acceptance. Keep ORM navigation inference compatible with the richer schema types. diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts index 93ff5896..4fbb5254 100644 --- a/libs/knex-schema/src/entity.ts +++ b/libs/knex-schema/src/entity.ts @@ -9,7 +9,6 @@ // extension (compatible with the existing query/include implementation). import { - type ArraySchemaBuilder, type InferType, ObjectSchemaBuilder, type PropertyDescriptorTree, @@ -129,18 +128,27 @@ export type EntityPropSelector< * * @public */ -export type UnwrapNavSchema = - TProp extends ArraySchemaBuilder - ? TEl extends ObjectSchemaBuilder - ? TEl +export type UnwrapNavSchema = TProp extends { + introspect(): { elementSchema: infer TEl }; +} + ? NonNullable extends ObjectSchemaBuilder< + any, + any, + any, + any, + any, + any, + any + > + ? NonNullable + : never + : TProp extends ObjectSchemaBuilder + ? TProp + : TProp extends SchemaBuilder + ? T extends ObjectSchemaBuilder + ? T : never - : TProp extends ObjectSchemaBuilder - ? TProp - : TProp extends SchemaBuilder - ? T extends ObjectSchemaBuilder - ? T - : never - : never; + : never; /** * Merge type for a single polymorphic variant branch: variant schema fields diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index 97bb3743..dacf6292 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -12,6 +12,17 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer. ## When to use this library +### Input and output views + +`toJsonSchema(schema, { mode: 'input' })` describes input before decoding/defaults; +`mode: 'output'` (the default) describes validated output. +`withStandardJsonSchema(schema)` exposes the same distinction through +`jsonSchema.input()` and `jsonSchema.output()`. Converters are never executed. +Their arbitrary logic is not representable in JSON Schema. + +Named reference wrappers preserve use-site annotations, optionality and nullability +without changing the shared definition. See the [composition guide](../schema/BOUNDARIES.md). + - **Consuming external APIs** — you have a JSON Schema from an OpenAPI spec or a third-party service and want to validate incoming data with full TypeScript type inference. diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts new file mode 100644 index 00000000..404978f9 --- /dev/null +++ b/libs/schema-json/src/boundaries.test.ts @@ -0,0 +1,108 @@ +import { + array, + decode, + lazy, + number, + object, + record, + schemaRef, + string, + tuple, + union +} from '@cleverbrush/schema'; +import { describe, expect, it, vi } from 'vitest'; +import { withStandardJsonSchema } from './standardJsonSchema.js'; +import { toJsonSchema } from './toJsonSchema.js'; + +describe('boundary JSON Schema views', () => { + it('does not transfer output nullability or output defaults to input', () => { + const schema = decode(string(), number().nullable(), () => null); + expect(toJsonSchema(schema, { $schema: false, mode: 'input' })).toEqual( + { allOf: [{ type: 'string' }] } + ); + expect(toJsonSchema(schema, { $schema: false }).allOf).toEqual([ + { type: ['integer', 'null'] } + ]); + const withDefault = decode(string(), number(), Number).default(3); + expect( + toJsonSchema(withDefault, { mode: 'input' }).default + ).toBeUndefined(); + expect(toJsonSchema(withDefault).default).toBe(3); + }); + it('exports declared input/output without running converters', () => { + const converter = vi.fn(Number); + const size = decode( + string().minLength(1), + number().isInteger().min(1), + converter + ); + expect(toJsonSchema(size, { $schema: false, mode: 'input' })).toEqual({ + allOf: [{ type: 'string', minLength: 1 }] + }); + expect(toJsonSchema(size, { $schema: false })).toEqual({ + allOf: [{ type: 'integer', minimum: 1 }] + }); + const standard = withStandardJsonSchema(size)['~standard'].jsonSchema; + expect(standard.input({ target: 'draft-2020-12' })).not.toEqual( + standard.output({ target: 'draft-2020-12' }) + ); + expect(converter).not.toHaveBeenCalled(); + }); + + it('tracks input defaults separately from required output', () => { + const size = decode(string().default('1'), number(), Number); + const schema = object({ size, title: string().default('new') }); + expect( + toJsonSchema(schema, { mode: 'input' }).required + ).toBeUndefined(); + expect(toJsonSchema(schema).required).toEqual(['size', 'title']); + }); + + it.each([ + '2020-12', + '07' + ] as const)('keeps ref annotations outside the definition in draft %s', draft => { + const user = object({ name: string() }).schemaName('User'); + const schema = object({ + user: schemaRef(user), + previous: schemaRef(user) + .nullable() + .optional() + .describe('Previous user') + .example(null) + }); + const json = toJsonSchema(schema, { + draft, + $schema: false, + nameResolver: s => (s === user ? 'User' : null) + }) as any; + expect(json.required).toEqual(['user']); + expect(json.properties.user).toEqual({ + allOf: [{ $ref: '#/components/schemas/User' }] + }); + expect(json.properties.previous).toMatchObject({ + description: 'Previous user', + examples: [null], + anyOf: [ + { allOf: [{ $ref: '#/components/schemas/User' }] }, + { type: 'null' } + ] + }); + expect(user.introspect().description).toBeUndefined(); + }); + + it('projects nested containers and lazy wrappers', () => { + const size = decode(string(), number(), Number); + const schema = object({ + list: array(size), + pair: tuple([size]), + values: record(string(), size), + option: union(size).or(string()), + later: lazy(() => size) + }); + const input = JSON.stringify(toJsonSchema(schema, { mode: 'input' })); + const output = JSON.stringify(toJsonSchema(schema)); + expect(input).not.toContain('"type":"integer"'); + expect(output).toContain('"type":"integer"'); + }); +}); diff --git a/libs/schema-json/src/standardJsonSchema.ts b/libs/schema-json/src/standardJsonSchema.ts index b5e41e18..fb3ddc74 100644 --- a/libs/schema-json/src/standardJsonSchema.ts +++ b/libs/schema-json/src/standardJsonSchema.ts @@ -1,4 +1,8 @@ -import type { SchemaBuilder } from '@cleverbrush/schema'; +import type { + InferInput, + InferOutput, + SchemaBuilder +} from '@cleverbrush/schema'; import type { StandardJSONSchemaV1 } from '@standard-schema/spec'; import { toJsonSchema } from './toJsonSchema.js'; @@ -31,9 +35,8 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): { * [Standard JSON Schema v1](https://standardschema.dev/) interface. * * The returned schema object's `~standard` property is enriched with a - * `jsonSchema` converter. Because `@cleverbrush/schema` does not - * distinguish between input and output types, both `input()` and `output()` - * produce the same JSON Schema document. + * `jsonSchema` converter. Input describes values before decoding/defaults; + * output describes validated values. Converter functions are never executed. * * **Note:** this mutates the schema instance by overriding the `~standard` * property. The returned reference is the same schema object. @@ -56,13 +59,19 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): { */ export function withStandardJsonSchema< T extends SchemaBuilder ->(schema: T): T & StandardJSONSchemaV1 { +>(schema: T): T & StandardJSONSchemaV1, InferOutput> { const converter: StandardJSONSchemaV1.Converter = { input(options: StandardJSONSchemaV1.Options): Record { - return toJsonSchema(schema, targetToOptions(options.target)); + return toJsonSchema(schema, { + ...targetToOptions(options.target), + mode: 'input' + }); }, output(options: StandardJSONSchemaV1.Options): Record { - return toJsonSchema(schema, targetToOptions(options.target)); + return toJsonSchema(schema, { + ...targetToOptions(options.target), + mode: 'output' + }); } }; @@ -85,5 +94,5 @@ export function withStandardJsonSchema< enumerable: false }); - return schema as T & StandardJSONSchemaV1; + return schema as T & StandardJSONSchemaV1, InferOutput>; } diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index a322e59b..fdb460b8 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -11,19 +11,56 @@ function escapeJsonPointerSegment(s: string): string { return s.replace(/~/g, '~0').replace(/\//g, '~1'); } +/** Whether a property must be present on the selected side of a boundary. */ +function isRequiredInMode( + schema: SchemaBuilder, + mode: 'input' | 'output' +): boolean { + const info = schema.introspect() as any; + if (mode === 'input' && info.hasDefault) return false; + if (info.type === 'decode' || info.type === 'reference') { + if (info.presence !== undefined) return info.presence; + return isRequiredInMode( + mode === 'input' ? info.inputSchema : info.outputSchema, + mode + ); + } + return info.isRequired !== false; +} + type Resolver = | ((schema: SchemaBuilder) => string | null) | undefined; function convertNodeInner( schema: SchemaBuilder, - resolver: Resolver + resolver: Resolver, + mode: 'input' | 'output' ): Out { const info = schema.introspect() as any; const ext: Record = info.extensions ?? {}; const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {}; switch (info.type) { + case 'reference': + case 'decode': { + const target = + mode === 'input' ? info.inputSchema : info.outputSchema; + const out: Out = { allOf: [convertNode(target, resolver, mode)] }; + if (info.nullability === false) + (out.allOf as Out[]).push({ not: { type: 'null' } }); + return out; + } + case 'record': + return { + type: 'object', + propertyNames: convertNode(info.keySchema, resolver, mode), + additionalProperties: convertNode( + info.valueSchema, + resolver, + mode + ) + }; case 'string': { if (info.equalsTo !== undefined) return { ...readOnly, const: info.equalsTo }; @@ -95,7 +132,7 @@ function convertNodeInner( case 'array': { const out: Out = { ...readOnly, type: 'array' }; if (info.elementSchema) - out['items'] = convertNode(info.elementSchema, resolver); + out['items'] = convertNode(info.elementSchema, resolver, mode); if (info.minLength !== undefined) out['minItems'] = info.minLength; if (info.maxLength !== undefined) out['maxItems'] = info.maxLength; if (ext['nonempty'] === true && out['minItems'] === undefined) @@ -108,11 +145,11 @@ function convertNodeInner( info.elements ?? []; const out: Out = { type: 'array', - prefixItems: elements.map(e => convertNode(e, resolver)), + prefixItems: elements.map(e => convertNode(e, resolver, mode)), minItems: elements.length }; if (info.restSchema) { - out['items'] = convertNode(info.restSchema, resolver); + out['items'] = convertNode(info.restSchema, resolver, mode); } else { out['items'] = false; out['maxItems'] = elements.length; @@ -129,9 +166,8 @@ function convertNodeInner( const outProps: Record = {}; const required: string[] = []; for (const [key, propSchema] of Object.entries(props)) { - outProps[key] = convertNode(propSchema, resolver); - if ((propSchema.introspect() as any).isRequired !== false) - required.push(key); + outProps[key] = convertNode(propSchema, resolver, mode); + if (isRequiredInMode(propSchema, mode)) required.push(key); } out['properties'] = outProps; if (required.length > 0) out['required'] = required; @@ -164,7 +200,7 @@ function convertNodeInner( } if (allConst) return { ...readOnly, enum: enumValues }; - const converted = options.map(o => convertNode(o, resolver)); + const converted = options.map(o => convertNode(o, resolver, mode)); const out: Out = { ...readOnly, anyOf: converted @@ -217,8 +253,8 @@ function convertNodeInner( return { ...readOnly, allOf: [ - convertNode(left, resolver), - convertNode(right, resolver) + convertNode(left, resolver, mode), + convertNode(right, resolver, mode) ] }; } @@ -230,7 +266,7 @@ function convertNodeInner( // Recursive schemas without a registered name will cause infinite // recursion here; callers must use .schemaName() to break the cycle. const resolved: SchemaBuilder = info.getter(); - return convertNode(resolved, resolver); + return convertNode(resolved, resolver, mode); } default: @@ -240,7 +276,8 @@ function convertNodeInner( function convertNode( schema: SchemaBuilder, - resolver: Resolver + resolver: Resolver, + mode: 'input' | 'output' ): Out { if (resolver) { const name = resolver(schema); @@ -250,7 +287,7 @@ function convertNode( }; } } - const out = convertNodeInner(schema, resolver); + const out = convertNodeInner(schema, resolver, mode); const info = schema.introspect() as any; if (typeof info.description === 'string' && info.description !== '') out['description'] = info.description; @@ -263,6 +300,10 @@ function convertNode( // Emit default for serializable primitives (not factory functions) if ( info.hasDefault === true && + !( + mode === 'input' && + (info.type === 'decode' || info.type === 'reference') + ) && info.defaultValue !== undefined && typeof info.defaultValue !== 'function' ) { @@ -270,7 +311,11 @@ function convertNode( } // Handle nullable — JSON Schema 2020-12 style: type becomes an array - if (info.isNullable === true) { + if ( + info.type === 'reference' || info.type === 'decode' + ? info.nullability === true + : info.isNullable === true + ) { if (out['anyOf'] !== undefined) { // Union type — add { type: 'null' } to anyOf if not already present const anyOf = out['anyOf'] as Out[]; @@ -278,7 +323,11 @@ function convertNode( if (!hasNull) anyOf.push({ type: 'null' }); } else if (out['allOf'] !== undefined && out['type'] === undefined) { // Intersection type without a top-level type — wrap in oneOf with null - out['oneOf'] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }]; + out[ + info.type === 'reference' || info.type === 'decode' + ? 'anyOf' + : 'oneOf' + ] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }]; delete out['allOf']; } else if (out['enum'] !== undefined) { // Enum — add null to enum values if not already present @@ -362,7 +411,11 @@ export function toJsonSchema( schema: SchemaBuilder, opts?: ToJsonSchemaOptions ): Record { - const body = convertNode(schema, opts?.nameResolver); + const body = convertNode( + schema, + opts?.nameResolver, + opts?.mode ?? 'output' + ); if (opts?.$schema === false) return body; const draft = opts?.draft ?? '2020-12'; const uri = diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index 089b9748..afb1f950 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -137,6 +137,8 @@ export type InferFromJsonSchema = S extends { readonly const: infer V } /** Options accepted by {@link toJsonSchema}. */ export type ToJsonSchemaOptions = { + /** Declared input before decoding/defaults, or validated output. @default 'output' */ + mode?: 'input' | 'output'; /** * JSON Schema draft version to reference in the `$schema` header. * @default '2020-12' diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md new file mode 100644 index 00000000..589b6544 --- /dev/null +++ b/libs/schema/BOUNDARIES.md @@ -0,0 +1,151 @@ +# Schemas across boundaries + +These APIs are additive. `InferType` continues to mean validated output; +existing builder generic arguments and legacy preprocessors keep their meaning. + +## Optional fallbacks + +Before, consumers needed a separate safeParse helper for each optional scalar. +Now a fallback can use the optional or nullable schema's full output type: + +```ts +import { boolean, object, string } from '@cleverbrush/schema'; + +const optionalText = string().optional().catch(undefined); +const ExternalRecord = object({ + name: optionalText, + enabled: boolean().optional().catch(() => undefined) +}); +ExternalRecord.parse({ name: 42, enabled: 'unknown' }); +// { name: undefined, enabled: undefined } +``` + +Fallbacks are opt-in. A malformed required root object still fails. Arrays do +not silently discard invalid elements. A fallback factory runs only on failure. + +**Compatibility limitation:** legacy optional schemas also accept `null` at +runtime, even though their inferred type does not include it. This release does +not change that behavior. Consequently, `.optional().catch(undefined)` leaves +`null` unchanged. Normalize it explicitly when the application requires this: + +```ts +const normalizedText = string().optional() + .addPreprocessor(value => value == null ? undefined : value) + .catch(undefined); +``` + +Preprocessors can return optional/nullable values, including asynchronously. +Their existing callback parameter typing is preserved; it is not a guarantee +that unknown runtime input already has that type. Use explicit guards or +`decode` at untrusted boundaries. Global strict null rejection is a separate, +compatibility-sensitive follow-up. + +## One named definition, many annotated references + +Before, cloning `User.schemaName('User')` with `.optional()` created another +named instance and conflicted during OpenAPI generation. + +```ts +import { number, object, schemaRef, string } from '@cleverbrush/schema'; + +const User = object({ id: number(), name: string() }).schemaName('User'); +const History = object({ + current: schemaRef(User), + previous: schemaRef(User).nullable().optional() + .describe('The previous user, when known.') +}); +``` + +`schemaRef` requires a named target. Its local modifiers do not rename, clone, +or mutate that target. References delegate validation and preserve nested +property selectors/errors. Independent schemas sharing a name still conflict; +there is no name-only deduplication. Use-site optionality controls omission; +nullability controls null acceptance for the wrapper independently. + +JSON Schema/OpenAPI keeps one definition and uses reference composition for +local annotations, examples and nullability, including Draft 07 references. + +## Declare both sides of a conversion + +```ts +import { decode, type InferInput, type InferOutput, number, object, string } + from '@cleverbrush/schema'; + +const PageSize = decode( + string(), + number().isInteger().min(1).max(100), + value => Number(value) +); +const SearchInput = object({ + pageSize: PageSize, + term: string().default('') +}); + +type Editable = InferInput; // pageSize is string +type Validated = InferOutput; // pageSize is number +const request = SearchInput.parse({ pageSize: '20' }); +``` + +Parsing validates the input, invokes the converter once, then validates its +output. Converter exceptions/rejections become validation failures. Asynchronous +converters require `parseAsync`/`validateAsync`; synchronous parsing rejects +Promise-returning conversion. Runtime parsing always validates unknown values. + +Nested objects, arrays, tuples, records, unions, intersections, references and +lazy schemas retain input/output inference. Recursive schemas still require +explicit TypeScript annotations. Standard Schema exposes the corresponding +input/output types. `InferOutput` is an alias for `InferType`. + +An input-schema default runs before conversion. A default on the boundary itself +is an **output** default, validated without conversion. Optional/nullable +use-site modifiers bypass conversion for their allowed sentinel values. + +### Application-agnostic example + +```ts +const Priority = decode( + string().oneOf('low', 'normal', 'high'), + number().min(1).max(3), + value => ({ low: 1, normal: 2, high: 3 })[value] +); +const Ticket = object({ title: string().minLength(1), priority: Priority }); +Ticket.parse({ title: 'Improve documentation', priority: 'high' }); +// { title: 'Improve documentation', priority: 3 } +``` + +Conversion policy remains application-owned: trimming, empty strings, numeric +precision, date formats and intentional data loss are not global defaults. + +## JSON Schema and OpenAPI + +```ts +import { toJsonSchema, withStandardJsonSchema } from '@cleverbrush/schema-json'; + +toJsonSchema(PageSize, { mode: 'input' }); // declared string shape +toJsonSchema(PageSize, { mode: 'output' }); // constrained numeric shape +toJsonSchema(PageSize); // output remains the default + +const standard = withStandardJsonSchema(PageSize)['~standard']; +standard.jsonSchema.input({ target: 'draft-2020-12' }); +standard.jsonSchema.output({ target: 'draft-2020-12' }); +``` + +Converters are never executed by schema export. JSON Schema describes the +declared shapes, not arbitrary converter logic or opaque custom validators. + +OpenAPI uses input views for requests and output views for responses. Named +schemas with different views receive `NameInput` and `NameOutput` components; +unchanged views retain their original name. Generated-name collisions throw, +including collisions propagated through nested named or recursive references. +AsyncAPI incoming/outgoing payloads use the same directional model. + +## Adoption boundary + +This release does **not** redesign React form state or typed HTTP client request +inference. Those consumers may still assume input equals output. Do not treat a +decoder as a drop-in shared form/endpoint contract for those APIs. + +Until that adoption, keep an explicit wire schema in shared contracts and decode +inside the application boundary/handler, then return the validated output DTO. +A decoder is not an encoder: no reverse conversion is inferred for requests, +URLs, response serialization, database writes or editable form state. diff --git a/libs/schema/README.md b/libs/schema/README.md index 0fc779c0..f925db2a 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -1,5 +1,14 @@ # @cleverbrush/schema +## Schemas across boundaries + +Use optional-aware `catch(undefined)`, `schemaRef(namedSchema)` for per-use +annotations, and `decode(inputSchema, outputSchema, converter)` for explicit +conversion. `InferInput` describes editable input; `InferOutput` and the existing +`InferType` describe validated output. See the [boundary composition guide](./BOUNDARIES.md) +for examples, null-compatibility limitations, and the separate form/client +adoption boundary. + [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml) [![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/) diff --git a/libs/schema/package.json b/libs/schema/package.json index c89f8fcc..dce29b43 100644 --- a/libs/schema/package.json +++ b/libs/schema/package.json @@ -6,7 +6,8 @@ }, "description": "Schema Definition And Validation library, that allows to define and validate objects of any complexity", "files": [ - "dist" + "dist", + "BOUNDARIES.md" ], "homepage": "https://schema.cleverbrush.com/docs/getting-started", "keywords": [ diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts new file mode 100644 index 00000000..be8378e4 --- /dev/null +++ b/libs/schema/src/boundaries-docs.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +describe('published boundary documentation', () => { + it('preserves summaries for new APIs, types and public boundary members', () => { + const missing: string[] = []; + for (const [path, names] of [ + [ + '../dist/builders/BoundarySchemaBuilder.d.ts', + ['BoundarySchemaBuilder', 'schemaRef', 'decode'] + ], + [ + '../dist/builders/SchemaBuilder.d.ts', + ['InferInput', 'InferOutput'] + ], + ['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']] + ] as const) { + const text = readFileSync(new URL(path, import.meta.url), 'utf8'); + const source = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true + ); + const hasSummary = (node: ts.Node) => + (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc?.some( + doc => doc.comment + ); + for (const node of source.statements) { + const name = + 'name' in node && node.name + ? (node.name as ts.Identifier).text + : ''; + if (!(names as readonly string[]).includes(name)) continue; + if (!hasSummary(node)) missing.push(name); + if (ts.isClassDeclaration(node)) + for (const member of node.members) { + if ( + ts.getCombinedModifierFlags(member) & + (ts.ModifierFlags.Private | + ts.ModifierFlags.Protected) + ) + continue; + if (member.name && ts.isPrivateIdentifier(member.name)) + continue; + if ( + ts + .getJSDocTags(member) + .some(tag => tag.tagName.text === 'internal') + ) + continue; + if (!hasSummary(member)) + missing.push( + name + '.' + member.name?.getText(source) + ); + } + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts index a044538b..bcbd575f 100644 --- a/libs/schema/src/builders/ArraySchemaBuilder.ts +++ b/libs/schema/src/builders/ArraySchemaBuilder.ts @@ -5,6 +5,7 @@ import type { import { type BRAND, createHybridErrorArray, + type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -101,8 +102,8 @@ export class ArraySchemaBuilder< TResult = TExplicitType extends undefined ? TElementSchema extends undefined ? Array - : TElementSchema extends SchemaBuilder - ? Array>> + : TElementSchema extends SchemaBuilder + ? Array> : never : TExplicitType > extends SchemaBuilder< @@ -110,7 +111,8 @@ export class ArraySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + Array> > { #minLength?: number; #defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider< diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts new file mode 100644 index 00000000..b78c1b60 --- /dev/null +++ b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + array, + boolean, + decode, + number, + object, + schemaRef, + string +} from '../index.js'; + +describe('schema boundaries', () => { + it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => { + const text = string().optional().catch(undefined); + expect(text.parse(42)).toBeUndefined(); + expect(await text.parseAsync(false)).toBeUndefined(); + expect(text.parse(null)).toBeNull(); + expect(string().nullable().catch(null).parse(42)).toBeNull(); + const normalize = string() + .optional() + .addPreprocessor(v => (v == null ? undefined : v)) + .catch(undefined); + expect(normalize.parse(null)).toBeUndefined(); + expect(object({ text }).safeParse(null).valid).toBe(false); + expect( + object({ + text, + flag: boolean() + .optional() + .catch(() => undefined) + }).parse({ text: {}, flag: 1 }) + ).toEqual({ text: undefined, flag: undefined }); + }); + + it('validates both sides and converts once', () => { + const convert = vi.fn(Number); + const size = decode( + string(), + number().isInteger().min(1).max(100), + convert + ); + expect(size.parse('12')).toBe(12); + expect(convert).toHaveBeenCalledTimes(1); + expect(size.safeParse(12).valid).toBe(false); + expect(convert).toHaveBeenCalledTimes(1); + expect(size.safeParse('0').valid).toBe(false); + expect(size.safeParse('nope').valid).toBe(false); + expect(object({ size }).parse({ size: '5' })).toEqual({ size: 5 }); + expect(array(size).parse(['2', '3'])).toEqual([2, 3]); + }); + + it('supports async conversion and captures converter failures', async () => { + const size = decode(string(), number(), async value => Number(value)); + expect(() => size.parse('1')).toThrow(/parseAsync/); + expect(await size.parseAsync('2')).toBe(2); + expect(await size['~standard'].validate('3')).toEqual({ value: 3 }); + const broken = decode(string(), number(), () => { + throw new Error('not convertible'); + }); + expect(broken.safeParse('x')).toMatchObject({ + valid: false, + errors: [{ message: 'Decoder failed: not convertible' }] + }); + const rejected = decode(string(), number(), async () => { + throw new Error('rejected'); + }); + expect((await rejected.safeParseAsync('x')).valid).toBe(false); + }); + + it('handles input defaults, output defaults and use-site modifiers', () => { + const size = decode(string().default('2'), number(), Number); + expect(size.parse(undefined)).toBe(2); + expect(size.optional().parse(undefined)).toBeUndefined(); + expect(size.nullable().parse(null)).toBeNull(); + expect(size.default(4).parse(undefined)).toBe(4); + expect(size.default(4).clearDefault().parse(undefined)).toBe(2); + expect(size.optional().required().safeParse(undefined).valid).toBe( + false + ); + }); + + it('preserves one named definition and local metadata', () => { + const user = object({ name: string().minLength(1) }).schemaName('User'); + const ref = schemaRef(user); + const previous = ref.nullable().optional().describe('Previous user'); + expect(ref.introspect().outputSchema).toBe(user); + expect(previous.introspect().outputSchema).toBe(user); + expect(user.introspect().description).toBeUndefined(); + expect(ref.safeParse(null).valid).toBe(false); + expect(previous.parse(null)).toBeNull(); + expect(previous.parse(undefined)).toBeUndefined(); + expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' }); + expect(() => schemaRef(string())).toThrow(/named schema/); + }); + + it('preserves nested reference errors and selectors', () => { + const user = object({ name: string().minLength(1) }).schemaName('User'); + const result = object({ user: schemaRef(user) }).validate({ + user: { name: '' } + }); + expect(result.valid).toBe(false); + expect( + result.getErrorsFor(t => t.user.name).errors.length + ).toBeGreaterThan(0); + expect( + result.getInvalidProperties().map(p => p.descriptor.toJsonPointer()) + ).toContain('/user/name'); + }); +}); + +describe('decoding boundary edge cases', () => { + it('uses output defaults and never re-runs conversion for catch', async () => { + const convert = vi.fn(() => undefined); + const schema = decode(string(), number().default(8), convert); + expect(schema.parse('missing')).toBe(8); + expect(convert).toHaveBeenCalledTimes(1); + const fail = vi.fn(() => { + throw new Error('conversion'); + }); + const fallback = decode(string(), number(), fail).catch(() => 4); + expect(fallback.parse('x')).toBe(4); + expect(await fallback.parseAsync('y')).toBe(4); + expect(fail).toHaveBeenCalledTimes(2); + }); + + it('keeps required messages and sync/async rules', async () => { + const schema = decode(string(), number(), Number).optional(); + expect( + schema.required('Provide a size').safeParse(undefined).errors?.[0] + .message + ).toBe('Provide a size'); + const asyncRequired = schema.required(async () => 'Async required'); + expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/); + expect( + (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message + ).toBe('Async required'); + }); + + it('retains nested errors from both decoding stages', () => { + const schema = object({ + item: decode( + object({ input: object({ value: string().minLength(1) }) }), + object({ output: object({ count: number().min(1) }) }), + input => ({ output: { count: Number(input.input.value) } }) + ) + }); + const inputFailure = schema.validate({ + item: { input: { value: '' } } + } as any); + expect( + inputFailure + .getInvalidProperties() + .map(p => p.descriptor.toJsonPointer()) + ).toContain('/item/input/value'); + const outputFailure = schema.validate({ + item: { input: { value: '0' } } + } as any); + expect( + outputFailure.getErrorsFor(t => t.item.output.count).errors.length + ).toBeGreaterThan(0); + expect( + outputFailure + .getInvalidProperties() + .map(p => p.descriptor.toJsonPointer()) + ).toContain('/item/output/count'); + }); +}); diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.ts b/libs/schema/src/builders/BoundarySchemaBuilder.ts new file mode 100644 index 00000000..c9eac67b --- /dev/null +++ b/libs/schema/src/builders/BoundarySchemaBuilder.ts @@ -0,0 +1,528 @@ +import { + type BRAND, + type InferInput, + type InferOutput, + SchemaBuilder, + type SchemaBuilderProps, + SYMBOL_HAS_PROPERTIES, + type ValidationContext, + type ValidationErrorMessageProvider, + type ValidationResult +} from './SchemaBuilder.js'; + +type AnySchema = SchemaBuilder; +type BoundaryOutput< + TOutputSchema, + TRequired extends boolean, + TNullable extends boolean, + TExplicitType +> = + | (TRequired extends true + ? Exclude< + TExplicitType extends undefined + ? InferOutput + : TExplicitType, + undefined + > + : + | (TExplicitType extends undefined + ? InferOutput + : TExplicitType) + | undefined) + | (TNullable extends true ? null : never); + +type BoundaryProps = Partial> & { + type: 'reference' | 'decode'; + inputSchema: AnySchema; + outputSchema: AnySchema; + converter?: (value: any) => any; + presence?: boolean; + nullability?: boolean; +}; + +/** + * Immutable schema boundary returned by {@link schemaRef} and {@link decode}. + * Use the factories rather than constructing this class. Local modifiers never + * mutate the input/output schemas or their named definitions. + * @typeParam TInputSchema - Schema validating values before conversion. + * @typeParam TOutputSchema - Schema validating converted values. + * @typeParam TRequired - Whether the output includes undefined. + * @typeParam TNullable - Whether null is allowed by a use-site modifier. + * @typeParam THasDefault - Whether the wrapper supplies an output default. + * @typeParam TExplicitType - Optional static output override. + * @typeParam TInput - Declared input, including use-site presence modifiers. + */ +export class BoundarySchemaBuilder< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TRequired extends boolean = true, + TNullable extends boolean = false, + THasDefault extends boolean = false, + TExplicitType = undefined, + TInput = InferInput +> extends SchemaBuilder< + BoundaryOutput, + TRequired, + TNullable, + THasDefault, + {}, + InferInput, + TInput | (THasDefault extends true ? undefined : never) +> { + readonly #boundary: BoundaryProps; + /** @internal Enables nested property descriptors without subclassing objects. */ + readonly [SYMBOL_HAS_PROPERTIES] = true; + + /** @internal Factory configuration is exposed for immutable reconstruction. */ + constructor(props: BoundaryProps) { + super({ preprocessors: [], validators: [], ...props }); + this.#boundary = props; + } + + /** Describes both sides without evaluating the converter. */ + public introspect() { + return { + ...super.introspect(), + inputSchema: this.#boundary.inputSchema as TInputSchema, + outputSchema: this.#boundary.outputSchema as TOutputSchema, + converter: this.#boundary.converter, + presence: this.#boundary.presence, + nullability: this.#boundary.nullability, + // Object consumers can preserve real descriptor schemas through refs. + properties: + this.type === 'reference' + ? (this.#boundary.outputSchema.introspect() as any) + .properties + : undefined + }; + } + + /** @internal Rebuilds the wrapper while retaining the same target instances. */ + protected createFromProps(props: BoundaryProps): this { + return new BoundarySchemaBuilder(props) as this; + } + + #early(value: unknown): ValidationResult | undefined { + if (value === undefined && this.hasDefault) return undefined; + if (value === undefined && this.#boundary.presence !== undefined) { + return this.#boundary.presence + ? { valid: false, errors: [{ message: 'is required' }] } + : { valid: true, object: undefined }; + } + if (value === null && this.#boundary.nullability !== undefined) { + return this.#boundary.nullability + ? { valid: true, object: null } + : { valid: false, errors: [{ message: 'must not be null' }] }; + } + return undefined; + } + + #converterFailure(error: unknown): ValidationResult { + return { + valid: false, + errors: [ + { + message: `Decoder failed: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + + #stageFailure( + result: ValidationResult, + schema: AnySchema + ): ValidationResult { + return Object.assign(result, { __boundaryErrorSchema: schema }); + } + + /** Validates unknown input; an output fallback is never decoded a second time. */ + public validate( + value: unknown, + context?: ValidationContext + ): ValidationResult< + BoundaryOutput + > { + const result = this._validate(value, context); + if (result.valid || !this.hasCatch) return result; + const fallback = this.resolveCatchValue(); + const checked = this.#boundary.outputSchema.validate(fallback, context); + return checked.valid ? checked : { valid: true, object: fallback }; + } + + /** Async validation with the same output-fallback and error semantics as validate. */ + public async validateAsync( + value: unknown, + context?: ValidationContext + ): Promise< + ValidationResult< + BoundaryOutput + > + > { + const result = await this._validateAsync(value, context); + if (result.valid || !this.hasCatch) return result; + const fallback = this.resolveCatchValue(); + const checked = await this.#boundary.outputSchema.validateAsync( + fallback, + context + ); + return checked.valid ? checked : { valid: true, object: fallback }; + } + + /** @internal Validates input, converts once, and validates output. */ + protected _validate( + value: unknown, + context?: ValidationContext + ): ValidationResult { + const early = this.#early(value); + if (early) { + if (!early.valid && value === undefined) + early.errors = [ + { + message: this.getValidationErrorMessageSync( + this.requiredErrorMessage, + value as any + ) + } + ]; + return early; + } + const usesDefault = value === undefined && this.hasDefault; + const input = usesDefault + ? { valid: true, object: this.resolveDefaultValue() } + : this.#boundary.inputSchema.validate(value, context); + if (!input.valid) + return this.#stageFailure(input, this.#boundary.inputSchema); + let converted = input.object; + if (!usesDefault && this.#boundary.converter) { + try { + converted = this.#boundary.converter(converted); + } catch (error) { + return this.#converterFailure(error); + } + if (converted != null && typeof converted.then === 'function') { + throw new Error( + 'Decoder returned a Promise. Use validateAsync() or parseAsync().' + ); + } + } + const output = + this.type === 'reference' && !usesDefault + ? input + : this.#boundary.outputSchema.validate(converted, context); + if (!output.valid) + return this.#stageFailure(output, this.#boundary.outputSchema); + if (output.object === null && this.#boundary.nullability === false) { + return { valid: false, errors: [{ message: 'must not be null' }] }; + } + const prepared = this.preValidateSync(output.object, context); + if (!prepared.valid) return { valid: false, errors: prepared.errors }; + const preparedValue = prepared.transaction!.object.validatedObject; + if (this.preprocessors.length === 0) return output; + return this.#boundary.outputSchema.validate(preparedValue, context); + } + + /** @internal Async counterpart; converter rejections are validation failures. */ + protected async _validateAsync( + value: unknown, + context?: ValidationContext + ): Promise> { + const early = this.#early(value); + if (early) { + if (!early.valid && value === undefined) + early.errors = [ + { + message: await this.getValidationErrorMessage( + this.requiredErrorMessage, + value as any + ) + } + ]; + return early; + } + const usesDefault = value === undefined && this.hasDefault; + const input = usesDefault + ? { valid: true, object: this.resolveDefaultValue() } + : await this.#boundary.inputSchema.validateAsync(value, context); + if (!input.valid) + return this.#stageFailure(input, this.#boundary.inputSchema); + let converted = input.object; + if (!usesDefault && this.#boundary.converter) { + try { + converted = await this.#boundary.converter(converted); + } catch (error) { + return this.#converterFailure(error); + } + } + const output = + this.type === 'reference' && !usesDefault + ? input + : await this.#boundary.outputSchema.validateAsync( + converted, + context + ); + if (!output.valid) + return this.#stageFailure(output, this.#boundary.outputSchema); + if (output.object === null && this.#boundary.nullability === false) { + return { valid: false, errors: [{ message: 'must not be null' }] }; + } + const prepared = await this.preValidateAsync(output.object, context); + if (!prepared.valid) return { valid: false, errors: prepared.errors }; + const preparedValue = prepared.transaction!.object.validatedObject; + if (this.preprocessors.length === 0) return output; + return this.#boundary.outputSchema.validateAsync( + preparedValue, + context + ); + } + + /** Overrides only the static output type; runtime validation is unchanged. */ + public hasType( + _notUsed?: T + ): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + TNullable, + THasDefault, + T, + TInput + > { + return this.createFromProps(this.#props()) as any; + } + + /** Restores output inference from the output schema. */ + public clearHasType(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + TNullable, + THasDefault, + undefined, + TInput + > { + return this.createFromProps(this.#props()) as any; + } + + #props(): BoundaryProps { + return { + ...this.#boundary, + ...this.introspect(), + type: this.#boundary.type, + preprocessors: [...this.preprocessors], + validators: [...this.validators] + }; + } + + /** Rejects missing input at this use site. */ + public required( + errorMessage?: ValidationErrorMessageProvider + ): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + true, + TNullable, + THasDefault, + TExplicitType, + Exclude + > { + return this.createFromProps({ + ...this.#props(), + isRequired: true, + presence: true, + requiredValidationErrorMessageProvider: errorMessage + }) as any; + } + + /** Allows omitted input at this use site, without invoking the boundary. */ + public optional(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + false, + TNullable, + THasDefault, + TExplicitType, + TInput | undefined + > { + return this.createFromProps({ + ...this.#props(), + isRequired: false, + presence: false + }) as any; + } + + /** Allows null at this use site, independently of optionality. */ + public nullable(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + true, + THasDefault, + TExplicitType, + TInput | null + > { + return this.createFromProps({ + ...this.#props(), + isNullable: true, + nullability: true + }) as any; + } + + /** Rejects null at this use site without changing the referenced definition. */ + public notNullable(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + false, + THasDefault, + Exclude< + TExplicitType extends undefined + ? InferOutput + : TExplicitType, + null + >, + Exclude + > { + return this.createFromProps({ + ...this.#props(), + isNullable: false, + nullability: false + }) as any; + } + + /** Supplies an output default for missing input; validates it without decoding. */ + public default( + value: InferOutput | (() => InferOutput) + ): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + true, + TNullable, + true, + TExplicitType, + TInput + > { + return this.createFromProps({ + ...this.#props(), + defaultValue: value, + isRequired: true + }) as any; + } + + /** Removes the use-site default; defaults on the target are unchanged. */ + public clearDefault(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + TNullable, + false, + TExplicitType, + TInput + > { + return this.createFromProps({ + ...this.#props(), + defaultValue: undefined + }) as any; + } + + /** Brands the output type only; it does not alter runtime validation. */ + public brand( + _name?: B + ): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + TNullable, + THasDefault, + (TExplicitType extends undefined + ? InferOutput + : TExplicitType) & { + readonly [K in BRAND]: B; + }, + TInput + > { + return super.brand(_name); + } + + /** Makes the output type readonly without freezing runtime values. */ + public readonly(): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + TRequired, + TNullable, + THasDefault, + Readonly< + TExplicitType extends undefined + ? InferOutput + : TExplicitType + >, + TInput + > { + return super.readonly(); + } +} + +type RequiredOf = undefined extends InferOutput ? false : true; +type NullableOf = null extends InferOutput ? true : false; + +/** + * References one named definition with independent use-site annotations. + * @param schema - A reused schema constant with a nonempty schemaName. + * @returns An immutable reference wrapper; the named target is not cloned. + * @throws Error if the target has no name. + * @example + * const User = object({ name: string() }).schemaName('User'); + * const previous = schemaRef(User).nullable().optional().describe('Previous user'); + */ +export function schemaRef( + schema: S +): BoundarySchemaBuilder, NullableOf> { + const info = schema.introspect(); + if (!info.schemaName?.trim()) + throw new Error('schemaRef requires a named schema.'); + return new BoundarySchemaBuilder({ + type: 'reference', + inputSchema: schema, + outputSchema: schema, + isRequired: info.isRequired, + isNullable: info.isNullable + }); +} + +/** + * Composes input validation, an explicit conversion, and output validation. + * @param inputSchema - Validates external input before the converter runs. + * @param outputSchema - Validates the converted result. + * @param converter - Receives validated input; may return a Promise. + * @returns A schema with distinct inferred input/output types. + * @remarks Converter exceptions become validation failures. Async converters + * require parseAsync/validateAsync. JSON Schema documents declared shapes only, + * not arbitrary conversion logic. This is not a bidirectional codec. + * @example + * const PageSize = decode(string(), number().isInteger().min(1), Number); + * PageSize.parse('12'); // 12 + */ +export function decode< + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema +>( + inputSchema: TInputSchema, + outputSchema: TOutputSchema, + converter: ( + input: InferOutput + ) => InferInput | Promise> +): BoundarySchemaBuilder< + TInputSchema, + TOutputSchema, + RequiredOf, + NullableOf +> { + const info = outputSchema.introspect(); + return new BoundarySchemaBuilder({ + type: 'decode', + inputSchema, + outputSchema, + converter, + isRequired: info.isRequired, + isNullable: info.isNullable + }); +} diff --git a/libs/schema/src/builders/ExternSchemaBuilder.ts b/libs/schema/src/builders/ExternSchemaBuilder.ts index 6d4fb12d..76fb55ec 100644 --- a/libs/schema/src/builders/ExternSchemaBuilder.ts +++ b/libs/schema/src/builders/ExternSchemaBuilder.ts @@ -100,7 +100,8 @@ export class ExternSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + StandardSchemaV1.InferInput > { #standardSchema: TStandardSchema; diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts index 6d746c6a..47488a6b 100644 --- a/libs/schema/src/builders/IntersectionSchemaBuilder.ts +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts @@ -1,5 +1,6 @@ import { type BRAND, + type InferInput, type InferType, SchemaBuilder, type ValidationContext, @@ -36,7 +37,8 @@ export class IntersectionSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + InferInput & InferInput > { #left!: TLeft; #right!: TRight; diff --git a/libs/schema/src/builders/LazySchemaBuilder.ts b/libs/schema/src/builders/LazySchemaBuilder.ts index 12f0d7c5..cc0ede6c 100644 --- a/libs/schema/src/builders/LazySchemaBuilder.ts +++ b/libs/schema/src/builders/LazySchemaBuilder.ts @@ -1,5 +1,7 @@ import { type BRAND, + type InferInput, + type InferOutput, SchemaBuilder, type ValidationContext, type ValidationErrorMessageProvider, @@ -49,13 +51,15 @@ export class LazySchemaBuilder< TRequired extends boolean = true, TNullable extends boolean = false, THasDefault extends boolean = false, - TExtensions = {} + TExtensions = {}, + TInput = TResult > extends SchemaBuilder< TResult, TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + TInput > { #getter: () => SchemaBuilder; #resolvedSchema: SchemaBuilder | null = null; @@ -210,7 +214,7 @@ export class LazySchemaBuilder< */ public hasType( _notUsed?: T - ): LazySchemaBuilder & + ): LazySchemaBuilder & TExtensions { return this.createFromProps({ ...this.introspect() @@ -225,7 +229,8 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return this.createFromProps({ @@ -238,7 +243,14 @@ export class LazySchemaBuilder< */ public required( errorMessage?: ValidationErrorMessageProvider - ): LazySchemaBuilder & + ): LazySchemaBuilder< + TResult, + true, + TNullable, + THasDefault, + TExtensions, + TInput + > & TExtensions { return super.required(errorMessage); } @@ -251,7 +263,8 @@ export class LazySchemaBuilder< false, TNullable, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return super.optional(); @@ -262,7 +275,7 @@ export class LazySchemaBuilder< */ public default( value: TResult | (() => TResult) - ): LazySchemaBuilder & + ): LazySchemaBuilder & TExtensions { return super.default(value) as any; } @@ -275,7 +288,8 @@ export class LazySchemaBuilder< TRequired, TNullable, false, - TExtensions + TExtensions, + TInput > & TExtensions { return super.clearDefault() as any; @@ -291,7 +305,8 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return super.brand(_name); @@ -305,7 +320,8 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return super.readonly(); @@ -319,7 +335,8 @@ export class LazySchemaBuilder< TRequired, true, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return super.nullable() as any; @@ -333,7 +350,8 @@ export class LazySchemaBuilder< TRequired, false, THasDefault, - TExtensions + TExtensions, + TInput > & TExtensions { return super.notNullable() as any; @@ -372,6 +390,12 @@ export class LazySchemaBuilder< * }); * ``` */ +export function lazy>( + getter: () => S +): LazySchemaBuilder, true, false, false, {}, InferInput>; +export function lazy( + getter: () => SchemaBuilder +): LazySchemaBuilder; export function lazy( getter: () => SchemaBuilder ): LazySchemaBuilder { diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts index d78d89d8..4ce3529a 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -1,6 +1,7 @@ import { PropertyValidationResult } from './PropertyValidationResult.js'; import { type BRAND, + type InferInput, type InferType, type NestedValidationResult, type PreValidationResult, @@ -137,9 +138,9 @@ export type RespectPropsOptionality< type RespectPropsOptionalityForInput< T extends Record> > = { - [K in RequiredInputProps]: InferType; + [K in RequiredInputProps]: InferInput; } & { - [K in NotRequiredInputProps]?: InferType; + [K in NotRequiredInputProps]?: InferInput; }; type MakeChildrenRequired< @@ -376,7 +377,8 @@ export class ObjectSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + RespectPropsOptionalityForInput > { #properties: TProperties = {} as any; #acceptUnknownProps = false; @@ -1073,7 +1075,9 @@ export class ObjectSchemaBuilder< ObjectSchemaBuilder.#propagateNestedErrors( result as any, descriptor, - addErrorFor + addErrorFor, + (result as any).__boundaryErrorSchema ?? + this.#properties[key] ); // For extern schemas, also record errors on the extern // descriptor itself so getErrorsFor(t => t.extern) works, @@ -1842,7 +1846,8 @@ export class ObjectSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + RespectPropsOptionalityForInput > { return this.createFromProps({ ...this.introspect() @@ -2759,9 +2764,12 @@ export class ObjectSchemaBuilder< descriptor: any, message: string, parentDescriptor?: any - ) => void + ) => void, + schemaOverride?: SchemaBuilder ): void { const schema = + schemaOverride ?? + result.__boundaryErrorSchema ?? ObjectSchemaBuilder.#getSchemaForPropertyDescriptor(descriptor); const properties = (schema.introspect() as any).properties; @@ -2813,7 +2821,8 @@ export class ObjectSchemaBuilder< ObjectSchemaBuilder.#propagateNestedErrors( result, nestedPropertyDescriptor, - addErrorFor + addErrorFor, + nestedSchema ); } } @@ -3114,33 +3123,11 @@ type NotRequiredProps< type RequiredInputProps< T extends Record> > = keyof { - [k in keyof T as T[k] extends SchemaBuilder< - any, - infer TReq, - any, - infer THasDef - > - ? TReq extends true - ? THasDef extends true - ? never - : k - : never - : never]: T[k]; + [k in keyof T as undefined extends InferInput ? never : k]: T[k]; }; type NotRequiredInputProps< T extends Record> > = keyof { - [k in keyof T as T[k] extends SchemaBuilder< - any, - infer TReq, - any, - infer THasDef - > - ? TReq extends true - ? THasDef extends true - ? k - : never - : k - : never]: T[k]; + [k in keyof T as undefined extends InferInput ? k : never]: T[k]; }; diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts index f9e2ab63..5b0e306d 100644 --- a/libs/schema/src/builders/RecordSchemaBuilder.ts +++ b/libs/schema/src/builders/RecordSchemaBuilder.ts @@ -14,6 +14,7 @@ */ import { type BRAND, + type InferInput, type InferType, type PreValidationResult, SchemaBuilder, @@ -241,7 +242,8 @@ export class RecordSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + Record, InferInput> > { #keySchema!: TKeySchema; #valueSchema!: TValueSchema; diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 6b119bca..311e86ed 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -5,11 +5,12 @@ import { transaction } from '../utils/transaction.js'; import type { ArraySchemaBuilder } from './ArraySchemaBuilder.js'; -import type { ExternSchemaBuilder } from './ExternSchemaBuilder.js'; import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js'; /** @internal Symbol used as the key for the type brand on schema builders. */ declare const __type: unique symbol; +/** @internal Input type marker. */ +declare const __input: unique symbol; /** @internal */ export type SchemaTypeBrand = typeof __type; @@ -58,6 +59,18 @@ export type InferType = T extends { ? TType : T; +/** Validated output of a schema; compatibility alias for {@link InferType}. */ +export type InferOutput = InferType; + +/** + * Declared input before decoding and defaults. Parsers still validate unknown + * data at runtime. Legacy preprocessors do not infer a separate input type. + * @typeParam T - Schema whose input is required. + */ +export type InferInput = T extends { readonly [__input]: infer I } + ? I + : InferType; + /** * Represents a single validation error with a human-readable error message. * @@ -538,77 +551,85 @@ export type PropertyDescriptorTree< TParentPropertyDescriptor = undefined > = PropertyDescriptor & (TSchema extends ObjectSchemaBuilder - ? { - [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder< - any, - any, - any - > - ? PropertyDescriptorTree< - TProperties[K], - TRootSchema, - any, - PropertyDescriptor< + ? 0 extends 1 & TProperties + ? { [key: string]: any } + : { + [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder< + any, + any, + any + > + ? PropertyDescriptorTree< + TProperties[K], TRootSchema, - TSchema, - TParentPropertyDescriptor + any, + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + > > - > - : TProperties[K] extends ExternSchemaBuilder< - any, - any, - any, - any, - any, - any, - infer TExternResult - > - ? PropertyDescriptor< - TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > & - ExternOutputPropertyDescriptors< - TExternResult, + : TProperties[K] extends { + readonly [SYMBOL_HAS_PROPERTIES]: true; + } + ? PropertyDescriptor< TRootSchema, + TProperties[K], PropertyDescriptor< TRootSchema, - TProperties[K], + TSchema, + TParentPropertyDescriptor + >, + K & string + > & + ExternOutputPropertyDescriptors< + NonNullable>, + TRootSchema, PropertyDescriptor< TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string + TProperties[K], + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + >, + K & string + > > - > - : TProperties[K] extends ArraySchemaBuilder< - infer TArrayElement, - any, - any - > - ? TArrayElement extends ObjectSchemaBuilder< - any, - any, - any, - any, - any - > - ? PropertyDescriptor< - TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string + : TProperties[K] extends ArraySchemaBuilder< + infer TArrayElement, + any, + any + > + ? TArrayElement extends ObjectSchemaBuilder< + any, + any, + any, + any, + any > + ? PropertyDescriptor< + TRootSchema, + TProperties[K], + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + >, + K & string + > + : InferType extends TAssignableTo + ? PropertyDescriptor< + TRootSchema, + TProperties[K], + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + >, + K & string + > + : never : InferType extends TAssignableTo ? PropertyDescriptor< TRootSchema, @@ -620,20 +641,8 @@ export type PropertyDescriptorTree< >, K & string > - : never - : InferType extends TAssignableTo - ? PropertyDescriptor< - TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > - : never; - } + : never; + } : never); /** @@ -742,6 +751,8 @@ type ResolvedSchemaType< * **Note:** this class is not intended to be used directly, use one of the subclasses instead. * @typeparam TResult Type of the object that will be returned by `validate()` method. * @typeparam TRequired If `true`, object will be required. If `false`, object will be optional. + * @typeParam TInput - Declared input before decoding. Defaults to TResult so + * existing generic arguments retain their meaning. */ export abstract class SchemaBuilder< TResult = any, @@ -749,7 +760,11 @@ export abstract class SchemaBuilder< TNullable extends boolean = false, THasDefault extends boolean = false, // biome-ignore lint/correctness/noUnusedVariables: used in extensions - TExtensions = {} + TExtensions = {}, + TInput = TResult, + TResolvedInput = + | ResolvedSchemaType + | (THasDefault extends true ? undefined : never) > { #isRequired = true; #isNullable = false; @@ -777,6 +792,7 @@ export abstract class SchemaBuilder< */ #standardProps: | StandardSchemaV1.Props< + TResolvedInput, ResolvedSchemaType > | undefined; @@ -799,6 +815,9 @@ export abstract class SchemaBuilder< */ declare readonly [__hasDefault]: THasDefault; + /** @internal Input type, including omission when a default exists. */ + declare readonly [__input]: TResolvedInput; + /** * Standard Schema v1 interface. * @@ -807,13 +826,13 @@ export abstract class SchemaBuilder< * consumes the spec — including tRPC, TanStack Form, React Hook Form, T3 Env, * Hono, Elysia, next-safe-action, and 50+ other tools. * - * Every `SchemaBuilder` subclass (all 13 builders) inherits this property + * Every `SchemaBuilder` subclass inherits this property * automatically — no additional setup required. * * **Shape of the returned object:** * - `version` — always `1` (Standard Schema spec version) * - `vendor` — `'@cleverbrush/schema'` - * - `validate(value)` — synchronous; wraps this builder's own `.validate()` + * - `validate(value)` — asynchronous; wraps this builder's `.validateAsync()` * and converts its result to the Standard Schema `Result` format: * - Success: `{ value: }` * - Failure: `{ issues: [{ message: string }, …] }` @@ -849,6 +868,7 @@ export abstract class SchemaBuilder< * @see https://standardschema.dev/ */ get ['~standard'](): StandardSchemaV1.Props< + TResolvedInput, ResolvedSchemaType > { if (this.#standardProps) return this.#standardProps; @@ -1555,7 +1575,10 @@ export abstract class SchemaBuilder< * * When `.catch()` is set, {@link parse} and {@link parseAsync} will **never throw**. * - * @param value - the fallback value, or a factory function producing the fallback + * @param value - A value (or factory) compatible with the resolved output: + * optional schemas allow undefined; nullable schemas allow null. + * @remarks Legacy optional schemas also accept null at runtime, so + * catch(undefined) does not normalize null. Use an explicit preprocessor. * * @example * ```ts @@ -1584,7 +1607,11 @@ export abstract class SchemaBuilder< * c.validate(42); // { valid: true, object: 'anon' } ← also fires * ``` */ - public catch(value: TResult | (() => TResult)): this { + public catch( + value: + | ResolvedSchemaType + | (() => ResolvedSchemaType) + ): this { return this.createFromProps({ ...this.introspect(), catchValue: value, @@ -1750,10 +1777,20 @@ export abstract class SchemaBuilder< } /** - * Adds a `preprocessor` to a preprocessors list + * Adds an immutable preprocessing step. It may return the resolved value, + * including undefined for optional schemas and null for nullable schemas. + * @param preprocessor - Existing value-to-value conversion, optionally async. + * @param options - Whether the callback can mutate its argument. + * @returns A new builder preserving the original schema. + * @remarks Legacy callback parameter typing is preserved for compatibility. + * For a separately typed external input, use decode(input, output, fn). */ public addPreprocessor( - preprocessor: Preprocessor, + preprocessor: ( + object: TResult + ) => + | ResolvedSchemaType + | Promise>, options?: { mutates?: boolean } ): this { if (typeof preprocessor !== 'function') { diff --git a/libs/schema/src/builders/TupleSchemaBuilder.ts b/libs/schema/src/builders/TupleSchemaBuilder.ts index 8a5a6d76..d2d690ef 100644 --- a/libs/schema/src/builders/TupleSchemaBuilder.ts +++ b/libs/schema/src/builders/TupleSchemaBuilder.ts @@ -5,6 +5,7 @@ import type { import { type BRAND, createHybridErrorArray, + type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -160,7 +161,13 @@ export class TupleSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + TRestSchema extends SchemaBuilder + ? [ + ...{ [K in keyof TElements]: InferInput }, + ...Array> + ] + : { [K in keyof TElements]: InferInput } > { #elements!: TElements; #restSchema: diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts index 0560e4c4..9ac80679 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.ts @@ -5,6 +5,7 @@ import type { import { type BRAND, createHybridErrorArray, + type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -172,7 +173,8 @@ export class UnionSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions + TExtensions, + InferInput > { #options!: TOptions; #discriminatorKey: string | null = null; diff --git a/libs/schema/src/builders/boundaries.test-d.ts b/libs/schema/src/builders/boundaries.test-d.ts new file mode 100644 index 00000000..f9f9ef39 --- /dev/null +++ b/libs/schema/src/builders/boundaries.test-d.ts @@ -0,0 +1,102 @@ +import { expectTypeOf, test } from 'vitest'; +import { + array, + decode, + type InferInput, + type InferOutput, + type InferType, + intersection, + lazy, + number, + object, + record, + schemaRef, + string, + tuple, + union +} from '../index.js'; + +test('input/output inference composes without changing InferType', () => { + const size = decode(string(), number(), Number); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(size.parse('1')).toEqualTypeOf(); + const form = object({ size, title: string().default('untitled') }); + expectTypeOf>().toMatchTypeOf<{ + size: string; + title?: string; + }>(); + expectTypeOf<{ size: string }>().toMatchTypeOf>(); + expectTypeOf>().toMatchTypeOf<{ + size: number; + title: string; + }>(); + const list = array(size); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const pair = tuple([size, string()]); + expectTypeOf>().toEqualTypeOf<[string, string]>(); + const dictionary = record(string(), size); + expectTypeOf>().toEqualTypeOf< + Record + >(); + const choice = union(size).or(string()); + expectTypeOf>().toEqualTypeOf(); + const joined = intersection(object({ size }), object({ title: string() })); + expectTypeOf>().toMatchTypeOf<{ + size: string; + title: string; + }>(); + const ref = schemaRef(form.schemaName('Form')); + expectTypeOf>().toEqualTypeOf< + InferInput + >(); + const deferred = lazy(() => size); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error required schemas cannot fall back to undefined + string().catch(undefined); + // @ts-expect-error non-nullable schemas cannot fall back to null + string().catch(null); + // @ts-expect-error converter must return the output schema's input + decode(string(), number(), value => value); + string().optional().catch(undefined); + string() + .nullable() + .catch(() => null); + string() + .optional() + .addPreprocessor(() => undefined); +}); + +test('boundary presence belongs to each side independently', () => { + const nullableOutput = decode(string(), number().nullable(), () => null); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | null + >(); + const nullableInput = decode(string().nullable(), number(), value => + value === null ? 0 : Number(value) + ); + expectTypeOf>().toEqualTypeOf< + string | null + >(); + expectTypeOf>().toEqualTypeOf(); + const defaults = object({ + size: decode(string().default('1'), number(), Number) + }); + expectTypeOf<{}>().toMatchTypeOf>(); + const optimized = defaults.optimize(); + expectTypeOf>().toEqualTypeOf< + InferInput + >(); + const required = nullableInput.optional().required().notNullable(); + expectTypeOf>().toEqualTypeOf(); + const ref = schemaRef(object({ name: string() }).schemaName('User')) + .nullable() + .optional(); + const root = object({ user: ref }); + root.validate({ user: null }).getErrorsFor(t => t.user.name); + // @ts-expect-error concrete descriptor trees still reject unknown fields + root.validate({ user: null }).getErrorsFor(t => t.user.missing); +}); diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts index 023f3b3b..b0123534 100644 --- a/libs/schema/src/core.ts +++ b/libs/schema/src/core.ts @@ -12,6 +12,11 @@ export { BooleanSchemaBuilder, boolean } from './builders/BooleanSchemaBuilder.js'; +export { + BoundarySchemaBuilder, + decode, + schemaRef +} from './builders/BoundarySchemaBuilder.js'; export { DateSchemaBuilder, date } from './builders/DateSchemaBuilder.js'; export { ExternSchemaBuilder, @@ -51,6 +56,8 @@ export { export type { RecordSchemaValidationResult } from './builders/RecordSchemaBuilder.js'; export { RecordSchemaBuilder, record } from './builders/RecordSchemaBuilder.js'; export type { + InferInput, + InferOutput, NestedValidationResult, PropertyDescriptor, PropertyDescriptorInner, diff --git a/libs/schema/tsconfig.build.json b/libs/schema/tsconfig.build.json index 4efcd94c..14e29984 100644 --- a/libs/schema/tsconfig.build.json +++ b/libs/schema/tsconfig.build.json @@ -14,5 +14,5 @@ "declarationMap": false }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-d.ts"] } diff --git a/libs/schema/tsconfig.typecheck.json b/libs/schema/tsconfig.typecheck.json new file mode 100644 index 00000000..213d3043 --- /dev/null +++ b/libs/schema/tsconfig.typecheck.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/libs/schema/vitest.config.mts b/libs/schema/vitest.config.mts new file mode 100644 index 00000000..c2329925 --- /dev/null +++ b/libs/schema/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.ts'], + tsconfig: './tsconfig.typecheck.json' + } + } +}); diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 3b5e2cd3..84426fe1 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -7,6 +7,18 @@ OpenAPI 3.1 specification generation for [`@cleverbrush/server`](../server). Con ## Features +### Schema boundaries + +Requests use schema input views and responses use output views. A named schema +with different representations gets collision-checked `NameInput` and +`NameOutput` components; identical representations keep their original name. +AsyncAPI uses input for incoming messages and output for outgoing messages. + +Use `schemaRef(NamedSchema).optional().nullable().describe(...)` to annotate a +single use without cloning the named definition. Conflicting named definitions +still fail. See the [composition guide](../schema/BOUNDARIES.md), including the +separate adoption required for typed clients and editable form state. + - **`generateOpenApiSpec()`** — converts `@cleverbrush/server` endpoint registrations into an OpenAPI 3.1 document. - **`generateAsyncApiSpec()`** — converts `@cleverbrush/server` WebSocket subscription registrations into an AsyncAPI 3.0 document. - **`serveAsyncApi()`** — middleware that lazily generates and caches the AsyncAPI spec; serves it at a configurable path (default: `/asyncapi.json`). diff --git a/libs/server-openapi/src/boundaries.test.ts b/libs/server-openapi/src/boundaries.test.ts new file mode 100644 index 00000000..91421f28 --- /dev/null +++ b/libs/server-openapi/src/boundaries.test.ts @@ -0,0 +1,102 @@ +import { + array, + decode, + lazy, + number, + object, + type SchemaBuilder, + schemaRef, + string +} from '@cleverbrush/schema'; +import { endpoint } from '@cleverbrush/server'; +import { describe, expect, it } from 'vitest'; +import { generateOpenApiSpec } from './generateOpenApiSpec.js'; +import { SchemaRegistry, walkSchemas } from './schemaRegistry.js'; + +describe('schema boundaries in OpenAPI', () => { + it('uses input requests, output responses, and directional nested components', () => { + const size = decode(string(), number().isInteger(), Number).schemaName( + 'Size' + ); + const request = object({ size: schemaRef(size) }).schemaName( + 'Envelope' + ); + const contract = endpoint + .post('/items') + .body(request) + .responses({ 200: request }); + const spec = generateOpenApiSpec({ + registrations: [ + { endpoint: contract.introspect(), handler: () => {} } + ], + info: { title: 'Boundaries', version: '1' } + }) as any; + expect( + spec.paths['/items'].post.requestBody.content['application/json'] + .schema + ).toEqual({ $ref: '#/components/schemas/EnvelopeInput' }); + expect( + spec.paths['/items'].post.responses['200'].content[ + 'application/json' + ].schema + ).toEqual({ $ref: '#/components/schemas/EnvelopeOutput' }); + expect(spec.components.schemas.SizeInput).toEqual({ + allOf: [{ type: 'string' }] + }); + expect(spec.components.schemas.SizeOutput).toEqual({ + allOf: [{ type: 'integer' }] + }); + expect( + spec.components.schemas.EnvelopeInput.properties.size.allOf[0].$ref + ).toBe('#/components/schemas/SizeInput'); + }); + + it('registers one target with independently annotated references', () => { + const user = object({ name: string() }).schemaName('User'); + const root = object({ + user, + previous: schemaRef(user).nullable().optional().describe('Previous') + }); + const registry = new SchemaRegistry(); + walkSchemas(root, registry); + expect( + [...registry.directionalEntries()].map(([name]) => name) + ).toEqual(['User']); + expect(() => walkSchemas(user.optional(), registry)).toThrow( + /already registered/ + ); + }); + + it('rejects generated-name collisions regardless of registration order', () => { + const size = decode(string(), number(), Number).schemaName('Size'); + const collision = string().schemaName('SizeInput'); + for (const schemas of [ + [size, collision], + [collision, size] + ]) { + const registry = new SchemaRegistry(); + for (const schema of schemas) walkSchemas(schema, registry); + expect(() => [...registry.directionalEntries()]).toThrow( + /SizeInput/ + ); + } + }); + + it('terminates on named recursive references', () => { + type Node = { name: string; children: Node[] }; + const node: SchemaBuilder = object({ + name: string(), + children: array(lazy(() => schemaRef(node))) + }).schemaName('Node'); + const contract = endpoint.get('/nodes').responses({ 200: node }); + const spec = generateOpenApiSpec({ + registrations: [ + { endpoint: contract.introspect(), handler: () => {} } + ], + info: { title: 'Nodes', version: '1' } + }) as any; + expect( + spec.components.schemas.Node.properties.children.items.allOf[0].$ref + ).toBe('#/components/schemas/Node'); + }); +}); diff --git a/libs/server-openapi/src/generateAsyncApiSpec.ts b/libs/server-openapi/src/generateAsyncApiSpec.ts index 4f75278f..cdbf5fd1 100644 --- a/libs/server-openapi/src/generateAsyncApiSpec.ts +++ b/libs/server-openapi/src/generateAsyncApiSpec.ts @@ -256,7 +256,7 @@ export function generateAsyncApiSpec( messages['ClientMessage'] = { name: 'ClientMessage', ...(inInfo.description ? { summary: inInfo.description } : {}), - payload: convertSchema(m.incomingSchema, registry) + payload: convertSchema(m.incomingSchema, registry, 'input') }; } @@ -325,8 +325,19 @@ export function generateAsyncApiSpec( if (!registry.isEmpty) { const schemas: Record = {}; - for (const [name, schema] of registry.entries()) { - schemas[name] = convertSchema(schema, registry); + for (const [name, schema, mode] of registry.directionalEntries()) { + let rootInlined = false; + schemas[name] = convertSchema( + schema, + candidate => { + if (candidate === schema && !rootInlined) { + rootInlined = true; + return null; + } + return registry.getName(candidate, mode); + }, + mode + ); } doc.components = { schemas }; } diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts index f566eb3e..352722cb 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.ts @@ -161,6 +161,16 @@ function buildParameterObject( return param; } +/** Determines omission on the request side without executing conversions. */ +function isInputRequired(schema: SchemaBuilder): boolean { + const info = schema.introspect() as any; + if (info.hasDefault) return false; + if (info.type === 'reference' || info.type === 'decode') { + return info.presence ?? isInputRequired(info.inputSchema); + } + return info.isRequired !== false; +} + function buildRequestBody( bodySchema: SchemaBuilder, registry: SchemaRegistry, @@ -173,18 +183,18 @@ function buildRequestBody( ): Record { const bodyInfo = bodySchema.introspect() as any; const body: Record = { - required: bodyInfo.isRequired !== false + required: isInputRequired(bodySchema) }; // When file uploads are enabled, emit multipart/form-data if (fileUpload) { - const jsonSchema = convertSchema(bodySchema, registry); + const jsonSchema = convertSchema(bodySchema, registry, 'input'); const mediaType: Record = { schema: jsonSchema }; body['content'] = { 'multipart/form-data': mediaType }; } else { - const jsonSchema = convertSchema(bodySchema, registry); + const jsonSchema = convertSchema(bodySchema, registry, 'input'); const mediaType: Record = { schema: jsonSchema }; if (example != null) { mediaType['example'] = example; @@ -481,7 +491,7 @@ function buildOperation( > = queryInfo.properties ?? {}; for (const [name, propSchema] of Object.entries(props)) { const propInfo = propSchema.introspect() as any; - const isRequired = propInfo.isRequired !== false; + const isRequired = isInputRequired(propSchema); const description = typeof propInfo.description === 'string' && propInfo.description !== '' @@ -491,7 +501,7 @@ function buildOperation( buildParameterObject( name, 'query', - convertSchema(propSchema, registry), + convertSchema(propSchema, registry, 'input'), isRequired, description ) @@ -508,7 +518,7 @@ function buildOperation( > = headerInfo.properties ?? {}; for (const [name, propSchema] of Object.entries(props)) { const propInfo = propSchema.introspect() as any; - const isRequired = propInfo.isRequired !== false; + const isRequired = isInputRequired(propSchema); const description = typeof propInfo.description === 'string' && propInfo.description !== '' @@ -518,7 +528,7 @@ function buildOperation( buildParameterObject( name, 'header', - convertSchema(propSchema, registry), + convertSchema(propSchema, registry, 'input'), isRequired, description ) @@ -667,6 +677,13 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { if (meta.bodySchema) walkSchemas(meta.bodySchema, registry, visited); if (meta.responseSchema) walkSchemas(meta.responseSchema, registry, visited); + if (meta.responseHeaderSchema) + walkSchemas(meta.responseHeaderSchema, registry, visited); + if (meta.produces) { + for (const entry of Object.values(meta.produces)) { + if (entry.schema) walkSchemas(entry.schema, registry, visited); + } + } if (meta.responsesSchemas) { for (const schema of Object.values(meta.responsesSchemas)) { if (schema) walkSchemas(schema, registry, visited); @@ -707,7 +724,8 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { } const resolveComponentSchemaName = ( - rootSchema: SchemaBuilder + rootSchema: SchemaBuilder, + mode: 'input' | 'output' ) => { // The root schema must be inlined exactly once — for the component // definition itself. Any subsequent encounter (e.g. through a lazy @@ -721,7 +739,7 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { inlinedRoot = true; return undefined; // inline the root definition once } - return registry.getName(candidate) ?? undefined; + return registry.getName(candidate, mode) ?? undefined; }; }; @@ -813,13 +831,14 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { // Components — security schemes + named component schemas const componentSchemas: Record = {}; - for (const [name, schema] of registry.entries()) { + for (const [name, schema, mode] of registry.directionalEntries()) { // Inline the root schema to avoid a self-referential $ref, but resolve // nested named schemas through the shared registry so component // definitions can still deduplicate via $ref. componentSchemas[name] = convertSchema( schema, - resolveComponentSchemaName(schema) + resolveComponentSchemaName(schema, mode), + mode ); } const hasSchemas = Object.keys(componentSchemas).length > 0; diff --git a/libs/server-openapi/src/schemaConverter.ts b/libs/server-openapi/src/schemaConverter.ts index c0e83dfc..817c0219 100644 --- a/libs/server-openapi/src/schemaConverter.ts +++ b/libs/server-openapi/src/schemaConverter.ts @@ -18,10 +18,12 @@ type NameResolver = ( * * @param schema - The schema to convert, or `null`/`undefined`. * @param registry - Optional registry or resolver function for `$ref` deduplication. + * @param mode - Input before decoding/defaults, or validated output (default). */ export function convertSchema( schema: SchemaBuilder | null | undefined, - registry?: SchemaRegistry | NameResolver + registry?: SchemaRegistry | NameResolver, + mode: 'input' | 'output' = 'output' ): Record { if (schema == null) return {}; @@ -32,13 +34,14 @@ export function convertSchema( if (typeof registry === 'function') { nameResolver = s => registry(s) ?? null; } else { - nameResolver = s => registry.getName(s); + nameResolver = s => registry.getName(s, mode); } } return toJsonSchema(schema, { $schema: false, draft: '2020-12', - nameResolver + nameResolver, + mode }); } diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts index 009bfa45..b934a656 100644 --- a/libs/server-openapi/src/schemaRegistry.ts +++ b/libs/server-openapi/src/schemaRegistry.ts @@ -1,4 +1,5 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; +import { toJsonSchema } from '@cleverbrush/schema-json'; // --------------------------------------------------------------------------- // SchemaRegistry @@ -30,6 +31,9 @@ export class SchemaRegistry { >(); /** name → first-registered schema instance */ private readonly byName = new Map>(); + private directions: + | Map, { input: string; output: string }> + | undefined; /** * Attempts to register `schema` in the registry. @@ -64,6 +68,7 @@ export class SchemaRegistry { this.byInstance.set(schema, name); this.byName.set(name, schema); + this.directions = undefined; } /** @@ -73,8 +78,93 @@ export class SchemaRegistry { * @param schema - The schema builder to look up. * @returns The registered name, or `null`. */ - getName(schema: SchemaBuilder): string | null { - return this.byInstance.get(schema) ?? null; + getName( + schema: SchemaBuilder, + mode: 'input' | 'output' = 'output' + ): string | null { + if (!this.byInstance.has(schema)) return null; + this.prepareDirections(); + return this.directions!.get(schema)![mode]; + } + + /** + * Emits independent input/output definitions only when their declared + * representations differ. Derived names are collision checked. + * @throws Error when a generated component name is already reserved. + */ + *directionalEntries(): IterableIterator< + [string, SchemaBuilder, 'input' | 'output'] + > { + this.prepareDirections(); + for (const [schema, names] of this.directions!) { + if (names.input !== names.output) + yield [names.input, schema, 'input']; + yield [names.output, schema, 'output']; + } + } + + private prepareDirections(): void { + if (this.directions) return; + const different = new Set>(); + // A changed named child changes its parents' references. Iterate to a + // fixed point; named recursive edges are always references, not recursion. + let changed = true; + while (changed) { + changed = false; + for (const [, schema] of this.byName) { + if (different.has(schema)) continue; + const render = (mode: 'input' | 'output') => { + let rootInlined = false; + return JSON.stringify( + toJsonSchema(schema, { + $schema: false, + mode, + nameResolver: candidate => { + if (candidate === schema && !rootInlined) { + rootInlined = true; + return null; + } + const name = this.byInstance.get(candidate); + return name + ? name + + (different.has(candidate) + ? mode === 'input' + ? 'Input' + : 'Output' + : '') + : null; + } + }) + ); + }; + if (render('input') !== render('output')) { + different.add(schema); + changed = true; + } + } + } + const names = new Map>( + this.byName + ); + const directions = new Map< + SchemaBuilder, + { input: string; output: string } + >(); + for (const [name, schema] of this.byName) { + const pair = different.has(schema) + ? { input: name + 'Input', output: name + 'Output' } + : { input: name, output: name }; + for (const derived of new Set(Object.values(pair))) { + if (names.has(derived) && names.get(derived) !== schema) { + throw new Error( + `Schema component name "${derived}" conflicts with a generated input/output name.` + ); + } + names.set(derived, schema); + } + directions.set(schema, pair); + } + this.directions = directions; } /** @@ -126,6 +216,15 @@ export function walkSchemas( const info = schema.introspect() as any; switch (info.type) { + case 'reference': + case 'decode': + walkSchemas(info.inputSchema, registry, visited); + walkSchemas(info.outputSchema, registry, visited); + break; + case 'intersection': + walkSchemas(info.left, registry, visited); + walkSchemas(info.right, registry, visited); + break; case 'object': { const props = info.properties as | Record> diff --git a/websites/docs/app/server-openapi/page.tsx b/websites/docs/app/server-openapi/page.tsx index 0cd6f614..0d96da94 100644 --- a/websites/docs/app/server-openapi/page.tsx +++ b/websites/docs/app/server-openapi/page.tsx @@ -20,6 +20,19 @@ export default function ServerOpenApiPage() {

+
+

Named references and input/output views

+

+ Use schemaRef to annotate one use of a named definition + without cloning it. Requests use input views and + responses use output views; differing named + representations receive collision-checked Input and + Output suffixes. +

+ + Schema boundary composition and compatibility + +
{/* ── Installation ─────────────────────────────────── */} = { 'parse-string': ParseStringSection, 'object-constructors': ObjectConstructorsSection, 'schema-modifiers': SchemaModifiersSection, + 'schema-boundaries': SchemaBoundariesSection, extensions: ExtensionsSection, 'built-in-extensions': BuiltInExtensionsSection, 'generic-schemas': GenericSchemasSection, diff --git a/websites/schema/app/docs/sections/index.ts b/websites/schema/app/docs/sections/index.ts index 79e3d249..a86e09a5 100644 --- a/websites/schema/app/docs/sections/index.ts +++ b/websites/schema/app/docs/sections/index.ts @@ -19,6 +19,7 @@ export const SECTION_GROUPS = [ label: 'Composition', slugs: [ 'immutability', + 'schema-boundaries', 'discriminated-unions', 'recursive-schemas', 'parse-string', @@ -50,6 +51,11 @@ export const SECTION_GROUPS = [ ]; export const SCHEMA_SECTIONS: SchemaSection[] = [ + { + slug: 'schema-boundaries', + title: 'Schema Boundaries', + group: 'Composition' + }, { slug: 'why', title: 'Why @cleverbrush/schema?', group: 'Fundamentals' }, { slug: 'getting-started', diff --git a/websites/schema/app/docs/sections/schema-boundaries.tsx b/websites/schema/app/docs/sections/schema-boundaries.tsx new file mode 100644 index 00000000..5a3f8235 --- /dev/null +++ b/websites/schema/app/docs/sections/schema-boundaries.tsx @@ -0,0 +1,87 @@ +export default function SchemaBoundariesSection() { + return ( + <> +
+

Schemas Across Boundaries

+

+ Compose named references and explicit input-to-output + decoding without duplicating validation rules. +

+
+
+

Optional fallbacks

+
+                    {`const text = string().optional().catch(undefined);
+const nullableText = string().nullable().catch(null);`}
+                
+

+ Legacy optional schemas accept null at runtime. A fallback + does not replace a value that passed validation. Normalize + null explicitly if your application requires undefined; this + release does not change that compatibility behavior. +

+
+
+

One named definition

+
+                    {`const User = object({ name: string() }).schemaName('User');
+const History = object({
+    current: schemaRef(User),
+    previous: schemaRef(User).nullable().optional()
+        .describe('Previous user')
+});`}
+                
+

+ Reference modifiers apply at the use site. The original + named definition remains unchanged, and genuinely + conflicting definitions still fail registration. +

+
+
+

Declared input and output

+
+                    {`const PageSize = decode(
+    string(),
+    number().isInteger().min(1).max(100),
+    value => Number(value)
+);
+type Editable = InferInput; // string
+type Validated = InferOutput; // number
+PageSize.parse('20'); // 20`}
+                
+

+ The input is validated before conversion; the converted + result is validated against the output schema. Converter + errors become validation failures. Use parseAsync for + asynchronous converters. InferType remains an output alias. +

+

+ Nested schemas and Standard Schema retain both types. + Defaults on the input schema run before conversion; a + default on the boundary is an output default. +

+
+
+

Export and adoption

+
+                    {`toJsonSchema(PageSize, { mode: 'input' });
+toJsonSchema(PageSize, { mode: 'output' }); // default`}
+                
+

+ OpenAPI uses input for requests and output for responses, + splitting named components when their shapes differ. Export + describes declared shapes, not arbitrary conversion logic. +

+

+ Form state and typed-client request inference are separate + integrations. Until adopted there, keep explicit wire + contracts and decode at your application boundary. No + reverse conversion is inferred. +

+ + Read the full composition guide + +
+ + ); +} diff --git a/websites/schema/app/schema-json/page.tsx b/websites/schema/app/schema-json/page.tsx index 4f3120a4..23926606 100644 --- a/websites/schema/app/schema-json/page.tsx +++ b/websites/schema/app/schema-json/page.tsx @@ -32,6 +32,19 @@ export default function SchemaJsonPage() { ]} /> +
+

Input and output views

+

+ Pass mode: 'input' to describe values before + decoding and defaults, or mode: 'output' for + validated values (the default). Standard JSON Schema + input/output converters use the same views without + executing conversions. +

+ + Named references, decoding, and compatibility + +
{/* ── Installation ─────────────────────────────────── */} Date: Mon, 28 Sep 2026 20:26:26 +0000 Subject: [PATCH 2/5] fix(schema-json): honor final boundary output presence --- libs/schema-json/src/boundaries.test.ts | 15 +++++++++++++++ libs/schema-json/src/toJsonSchema.ts | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts index 404978f9..f0fbf6ac 100644 --- a/libs/schema-json/src/boundaries.test.ts +++ b/libs/schema-json/src/boundaries.test.ts @@ -58,6 +58,21 @@ describe('boundary JSON Schema views', () => { expect(toJsonSchema(schema).required).toEqual(['size', 'title']); }); + it('respects output presence when defaults follow optional modifiers', () => { + const size = decode(string(), number(), Number).optional().default(5); + const name = schemaRef(string().schemaName('Name')) + .optional() + .default('untitled'); + const schema = object({ size, name }); + expect(schema.parse({})).toEqual({ size: 5, name: 'untitled' }); + expect( + toJsonSchema(schema, { mode: 'input' }).required + ).toBeUndefined(); + expect(toJsonSchema(schema).required).toEqual(['size', 'name']); + const optionalOutput = object({ size: size.optional() }); + expect(toJsonSchema(optionalOutput).required).toBeUndefined(); + }); + it.each([ '2020-12', '07' diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index fdb460b8..fad5f28c 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -17,13 +17,14 @@ function isRequiredInMode( mode: 'input' | 'output' ): boolean { const info = schema.introspect() as any; - if (mode === 'input' && info.hasDefault) return false; + // Output presence follows the wrapper's final modifiers. In particular, + // optional().default(...) produces a required output despite the earlier + // explicit input-omission modifier. + if (mode === 'output') return info.isRequired !== false; + if (info.hasDefault) return false; if (info.type === 'decode' || info.type === 'reference') { if (info.presence !== undefined) return info.presence; - return isRequiredInMode( - mode === 'input' ? info.inputSchema : info.outputSchema, - mode - ); + return isRequiredInMode(info.inputSchema, mode); } return info.isRequired !== false; } From 1f0b254ceadeafe8619f3099c007d8f1dbf67e42 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Mon, 28 Sep 2026 21:43:15 +0000 Subject: [PATCH 3/5] refactor(schema): drop explicit input and output support from PR --- .changeset/schema-boundaries.md | 10 +- libs/knex-schema/src/entity.ts | 32 +- libs/schema-json/README.md | 8 +- libs/schema-json/src/boundaries.test.ts | 115 +--- libs/schema-json/src/standardJsonSchema.ts | 25 +- libs/schema-json/src/toJsonSchema.ts | 86 +-- libs/schema-json/src/types.ts | 2 - libs/schema/BOUNDARIES.md | 98 +--- libs/schema/README.md | 9 +- libs/schema/src/boundaries-docs.test.ts | 8 +- .../schema/src/builders/ArraySchemaBuilder.ts | 8 +- .../builders/BoundarySchemaBuilder.test.ts | 167 ------ .../src/builders/BoundarySchemaBuilder.ts | 528 ------------------ .../src/builders/ExternSchemaBuilder.ts | 3 +- .../src/builders/IntersectionSchemaBuilder.ts | 4 +- libs/schema/src/builders/LazySchemaBuilder.ts | 48 +- .../src/builders/ObjectSchemaBuilder.ts | 41 +- .../src/builders/RecordSchemaBuilder.ts | 4 +- .../builders/ReferenceSchemaBuilder.test.ts | 149 +++++ .../src/builders/ReferenceSchemaBuilder.ts | 387 +++++++++++++ libs/schema/src/builders/SchemaBuilder.ts | 31 +- .../schema/src/builders/TupleSchemaBuilder.ts | 9 +- .../schema/src/builders/UnionSchemaBuilder.ts | 4 +- libs/schema/src/builders/boundaries.test-d.ts | 102 ---- libs/schema/src/builders/references.test-d.ts | 47 ++ libs/schema/src/core.ts | 11 +- libs/server-openapi/README.md | 9 +- libs/server-openapi/src/boundaries.test.ts | 140 +++-- .../src/generateAsyncApiSpec.ts | 22 +- .../server-openapi/src/generateOpenApiSpec.ts | 41 +- libs/server-openapi/src/schemaConverter.ts | 9 +- libs/server-openapi/src/schemaRegistry.ts | 98 +--- websites/docs/app/server-openapi/page.tsx | 10 +- .../app/docs/sections/schema-boundaries.tsx | 48 +- websites/schema/app/schema-json/page.tsx | 13 +- 35 files changed, 866 insertions(+), 1460 deletions(-) delete mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.test.ts delete mode 100644 libs/schema/src/builders/BoundarySchemaBuilder.ts create mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.test.ts create mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.ts delete mode 100644 libs/schema/src/builders/boundaries.test-d.ts create mode 100644 libs/schema/src/builders/references.test-d.ts diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md index e5c45af5..4694b904 100644 --- a/.changeset/schema-boundaries.md +++ b/.changeset/schema-boundaries.md @@ -2,11 +2,9 @@ "@cleverbrush/schema": minor "@cleverbrush/schema-json": minor "@cleverbrush/server-openapi": minor -"@cleverbrush/knex-schema": minor --- -Add optional-aware fallbacks and preprocessing, immutable named schema references, -and typed input/output decoding with nested inference and Standard Schema support. -Generate directional JSON Schema/OpenAPI components with strict name collision -checks. Preserve existing InferType output semantics and legacy optional null -acceptance. Keep ORM navigation inference compatible with the richer schema types. +Add optional-aware fallbacks and preprocessing, and immutable named schema +references with local annotations. Preserve one canonical definition in JSON +Schema, OpenAPI and AsyncAPI with strict name collision checks. Keep existing +type inference and legacy optional null acceptance unchanged. diff --git a/libs/knex-schema/src/entity.ts b/libs/knex-schema/src/entity.ts index 4fbb5254..93ff5896 100644 --- a/libs/knex-schema/src/entity.ts +++ b/libs/knex-schema/src/entity.ts @@ -9,6 +9,7 @@ // extension (compatible with the existing query/include implementation). import { + type ArraySchemaBuilder, type InferType, ObjectSchemaBuilder, type PropertyDescriptorTree, @@ -128,27 +129,18 @@ export type EntityPropSelector< * * @public */ -export type UnwrapNavSchema = TProp extends { - introspect(): { elementSchema: infer TEl }; -} - ? NonNullable extends ObjectSchemaBuilder< - any, - any, - any, - any, - any, - any, - any - > - ? NonNullable - : never - : TProp extends ObjectSchemaBuilder - ? TProp - : TProp extends SchemaBuilder - ? T extends ObjectSchemaBuilder - ? T +export type UnwrapNavSchema = + TProp extends ArraySchemaBuilder + ? TEl extends ObjectSchemaBuilder + ? TEl : never - : never; + : TProp extends ObjectSchemaBuilder + ? TProp + : TProp extends SchemaBuilder + ? T extends ObjectSchemaBuilder + ? T + : never + : never; /** * Merge type for a single polymorphic variant branch: variant schema fields diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index dacf6292..e6d64655 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -12,13 +12,7 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer. ## When to use this library -### Input and output views - -`toJsonSchema(schema, { mode: 'input' })` describes input before decoding/defaults; -`mode: 'output'` (the default) describes validated output. -`withStandardJsonSchema(schema)` exposes the same distinction through -`jsonSchema.input()` and `jsonSchema.output()`. Converters are never executed. -Their arbitrary logic is not representable in JSON Schema. +### Named references Named reference wrappers preserve use-site annotations, optionality and nullability without changing the shared definition. See the [composition guide](../schema/BOUNDARIES.md). diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts index f0fbf6ac..ea57988e 100644 --- a/libs/schema-json/src/boundaries.test.ts +++ b/libs/schema-json/src/boundaries.test.ts @@ -1,82 +1,13 @@ -import { - array, - decode, - lazy, - number, - object, - record, - schemaRef, - string, - tuple, - union -} from '@cleverbrush/schema'; -import { describe, expect, it, vi } from 'vitest'; +import { object, schemaRef, string } from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; import { withStandardJsonSchema } from './standardJsonSchema.js'; import { toJsonSchema } from './toJsonSchema.js'; -describe('boundary JSON Schema views', () => { - it('does not transfer output nullability or output defaults to input', () => { - const schema = decode(string(), number().nullable(), () => null); - expect(toJsonSchema(schema, { $schema: false, mode: 'input' })).toEqual( - { allOf: [{ type: 'string' }] } - ); - expect(toJsonSchema(schema, { $schema: false }).allOf).toEqual([ - { type: ['integer', 'null'] } - ]); - const withDefault = decode(string(), number(), Number).default(3); - expect( - toJsonSchema(withDefault, { mode: 'input' }).default - ).toBeUndefined(); - expect(toJsonSchema(withDefault).default).toBe(3); - }); - it('exports declared input/output without running converters', () => { - const converter = vi.fn(Number); - const size = decode( - string().minLength(1), - number().isInteger().min(1), - converter - ); - expect(toJsonSchema(size, { $schema: false, mode: 'input' })).toEqual({ - allOf: [{ type: 'string', minLength: 1 }] - }); - expect(toJsonSchema(size, { $schema: false })).toEqual({ - allOf: [{ type: 'integer', minimum: 1 }] - }); - const standard = withStandardJsonSchema(size)['~standard'].jsonSchema; - expect(standard.input({ target: 'draft-2020-12' })).not.toEqual( - standard.output({ target: 'draft-2020-12' }) - ); - expect(converter).not.toHaveBeenCalled(); - }); - - it('tracks input defaults separately from required output', () => { - const size = decode(string().default('1'), number(), Number); - const schema = object({ size, title: string().default('new') }); - expect( - toJsonSchema(schema, { mode: 'input' }).required - ).toBeUndefined(); - expect(toJsonSchema(schema).required).toEqual(['size', 'title']); - }); - - it('respects output presence when defaults follow optional modifiers', () => { - const size = decode(string(), number(), Number).optional().default(5); - const name = schemaRef(string().schemaName('Name')) - .optional() - .default('untitled'); - const schema = object({ size, name }); - expect(schema.parse({})).toEqual({ size: 5, name: 'untitled' }); - expect( - toJsonSchema(schema, { mode: 'input' }).required - ).toBeUndefined(); - expect(toJsonSchema(schema).required).toEqual(['size', 'name']); - const optionalOutput = object({ size: size.optional() }); - expect(toJsonSchema(optionalOutput).required).toBeUndefined(); - }); - +describe('named reference JSON Schema', () => { it.each([ '2020-12', '07' - ] as const)('keeps ref annotations outside the definition in draft %s', draft => { + ] as const)('keeps annotations outside the definition in draft %s', draft => { const user = object({ name: string() }).schemaName('User'); const schema = object({ user: schemaRef(user), @@ -106,18 +37,30 @@ describe('boundary JSON Schema views', () => { expect(user.introspect().description).toBeUndefined(); }); - it('projects nested containers and lazy wrappers', () => { - const size = decode(string(), number(), Number); - const schema = object({ - list: array(size), - pair: tuple([size]), - values: record(string(), size), - option: union(size).or(string()), - later: lazy(() => size) - }); - const input = JSON.stringify(toJsonSchema(schema, { mode: 'input' })); - const output = JSON.stringify(toJsonSchema(schema)); - expect(input).not.toContain('"type":"integer"'); - expect(output).toContain('"type":"integer"'); + it('keeps final local default and nullability modifiers', () => { + const target = string().nullable().schemaName('Name'); + const ref = schemaRef(target).notNullable().optional().default('new'); + const schema = object({ name: ref }); + const json = toJsonSchema(schema) as any; + expect(schema.parse({})).toEqual({ name: 'new' }); + expect(json.required).toEqual(['name']); + expect(json.properties.name.default).toBe('new'); + expect(json.properties.name.allOf).toEqual([ + { type: ['string', 'null'] }, + { not: { type: 'null' } } + ]); + const nullableAgain = toJsonSchema(ref.nullable()); + expect(nullableAgain.anyOf).toBeDefined(); + expect(toJsonSchema(ref.readonly()).readOnly).toBe(true); + }); + + it('retains identical Standard JSON Schema views', () => { + const ref = schemaRef(string().schemaName('Name')) + .optional() + .describe('A name'); + const standard = withStandardJsonSchema(ref)['~standard'].jsonSchema; + expect(standard.input({ target: 'draft-2020-12' })).toEqual( + standard.output({ target: 'draft-2020-12' }) + ); }); }); diff --git a/libs/schema-json/src/standardJsonSchema.ts b/libs/schema-json/src/standardJsonSchema.ts index fb3ddc74..b5e41e18 100644 --- a/libs/schema-json/src/standardJsonSchema.ts +++ b/libs/schema-json/src/standardJsonSchema.ts @@ -1,8 +1,4 @@ -import type { - InferInput, - InferOutput, - SchemaBuilder -} from '@cleverbrush/schema'; +import type { SchemaBuilder } from '@cleverbrush/schema'; import type { StandardJSONSchemaV1 } from '@standard-schema/spec'; import { toJsonSchema } from './toJsonSchema.js'; @@ -35,8 +31,9 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): { * [Standard JSON Schema v1](https://standardschema.dev/) interface. * * The returned schema object's `~standard` property is enriched with a - * `jsonSchema` converter. Input describes values before decoding/defaults; - * output describes validated values. Converter functions are never executed. + * `jsonSchema` converter. Because `@cleverbrush/schema` does not + * distinguish between input and output types, both `input()` and `output()` + * produce the same JSON Schema document. * * **Note:** this mutates the schema instance by overriding the `~standard` * property. The returned reference is the same schema object. @@ -59,19 +56,13 @@ function targetToOptions(target: StandardJSONSchemaV1.Target): { */ export function withStandardJsonSchema< T extends SchemaBuilder ->(schema: T): T & StandardJSONSchemaV1, InferOutput> { +>(schema: T): T & StandardJSONSchemaV1 { const converter: StandardJSONSchemaV1.Converter = { input(options: StandardJSONSchemaV1.Options): Record { - return toJsonSchema(schema, { - ...targetToOptions(options.target), - mode: 'input' - }); + return toJsonSchema(schema, targetToOptions(options.target)); }, output(options: StandardJSONSchemaV1.Options): Record { - return toJsonSchema(schema, { - ...targetToOptions(options.target), - mode: 'output' - }); + return toJsonSchema(schema, targetToOptions(options.target)); } }; @@ -94,5 +85,5 @@ export function withStandardJsonSchema< enumerable: false }); - return schema as T & StandardJSONSchemaV1, InferOutput>; + return schema as T & StandardJSONSchemaV1; } diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index fad5f28c..b5bbccaf 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -11,57 +11,28 @@ function escapeJsonPointerSegment(s: string): string { return s.replace(/~/g, '~0').replace(/\//g, '~1'); } -/** Whether a property must be present on the selected side of a boundary. */ -function isRequiredInMode( - schema: SchemaBuilder, - mode: 'input' | 'output' -): boolean { - const info = schema.introspect() as any; - // Output presence follows the wrapper's final modifiers. In particular, - // optional().default(...) produces a required output despite the earlier - // explicit input-omission modifier. - if (mode === 'output') return info.isRequired !== false; - if (info.hasDefault) return false; - if (info.type === 'decode' || info.type === 'reference') { - if (info.presence !== undefined) return info.presence; - return isRequiredInMode(info.inputSchema, mode); - } - return info.isRequired !== false; -} - type Resolver = | ((schema: SchemaBuilder) => string | null) | undefined; function convertNodeInner( schema: SchemaBuilder, - resolver: Resolver, - mode: 'input' | 'output' + resolver: Resolver ): Out { const info = schema.introspect() as any; const ext: Record = info.extensions ?? {}; const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {}; switch (info.type) { - case 'reference': - case 'decode': { - const target = - mode === 'input' ? info.inputSchema : info.outputSchema; - const out: Out = { allOf: [convertNode(target, resolver, mode)] }; + case 'reference': { + const out: Out = { + ...readOnly, + allOf: [convertNode(info.targetSchema, resolver)] + }; if (info.nullability === false) (out.allOf as Out[]).push({ not: { type: 'null' } }); return out; } - case 'record': - return { - type: 'object', - propertyNames: convertNode(info.keySchema, resolver, mode), - additionalProperties: convertNode( - info.valueSchema, - resolver, - mode - ) - }; case 'string': { if (info.equalsTo !== undefined) return { ...readOnly, const: info.equalsTo }; @@ -133,7 +104,7 @@ function convertNodeInner( case 'array': { const out: Out = { ...readOnly, type: 'array' }; if (info.elementSchema) - out['items'] = convertNode(info.elementSchema, resolver, mode); + out['items'] = convertNode(info.elementSchema, resolver); if (info.minLength !== undefined) out['minItems'] = info.minLength; if (info.maxLength !== undefined) out['maxItems'] = info.maxLength; if (ext['nonempty'] === true && out['minItems'] === undefined) @@ -146,11 +117,11 @@ function convertNodeInner( info.elements ?? []; const out: Out = { type: 'array', - prefixItems: elements.map(e => convertNode(e, resolver, mode)), + prefixItems: elements.map(e => convertNode(e, resolver)), minItems: elements.length }; if (info.restSchema) { - out['items'] = convertNode(info.restSchema, resolver, mode); + out['items'] = convertNode(info.restSchema, resolver); } else { out['items'] = false; out['maxItems'] = elements.length; @@ -167,8 +138,9 @@ function convertNodeInner( const outProps: Record = {}; const required: string[] = []; for (const [key, propSchema] of Object.entries(props)) { - outProps[key] = convertNode(propSchema, resolver, mode); - if (isRequiredInMode(propSchema, mode)) required.push(key); + outProps[key] = convertNode(propSchema, resolver); + if ((propSchema.introspect() as any).isRequired !== false) + required.push(key); } out['properties'] = outProps; if (required.length > 0) out['required'] = required; @@ -201,7 +173,7 @@ function convertNodeInner( } if (allConst) return { ...readOnly, enum: enumValues }; - const converted = options.map(o => convertNode(o, resolver, mode)); + const converted = options.map(o => convertNode(o, resolver)); const out: Out = { ...readOnly, anyOf: converted @@ -254,8 +226,8 @@ function convertNodeInner( return { ...readOnly, allOf: [ - convertNode(left, resolver, mode), - convertNode(right, resolver, mode) + convertNode(left, resolver), + convertNode(right, resolver) ] }; } @@ -267,7 +239,7 @@ function convertNodeInner( // Recursive schemas without a registered name will cause infinite // recursion here; callers must use .schemaName() to break the cycle. const resolved: SchemaBuilder = info.getter(); - return convertNode(resolved, resolver, mode); + return convertNode(resolved, resolver); } default: @@ -277,8 +249,7 @@ function convertNodeInner( function convertNode( schema: SchemaBuilder, - resolver: Resolver, - mode: 'input' | 'output' + resolver: Resolver ): Out { if (resolver) { const name = resolver(schema); @@ -288,7 +259,7 @@ function convertNode( }; } } - const out = convertNodeInner(schema, resolver, mode); + const out = convertNodeInner(schema, resolver); const info = schema.introspect() as any; if (typeof info.description === 'string' && info.description !== '') out['description'] = info.description; @@ -301,10 +272,6 @@ function convertNode( // Emit default for serializable primitives (not factory functions) if ( info.hasDefault === true && - !( - mode === 'input' && - (info.type === 'decode' || info.type === 'reference') - ) && info.defaultValue !== undefined && typeof info.defaultValue !== 'function' ) { @@ -313,7 +280,7 @@ function convertNode( // Handle nullable — JSON Schema 2020-12 style: type becomes an array if ( - info.type === 'reference' || info.type === 'decode' + info.type === 'reference' ? info.nullability === true : info.isNullable === true ) { @@ -324,11 +291,10 @@ function convertNode( if (!hasNull) anyOf.push({ type: 'null' }); } else if (out['allOf'] !== undefined && out['type'] === undefined) { // Intersection type without a top-level type — wrap in oneOf with null - out[ - info.type === 'reference' || info.type === 'decode' - ? 'anyOf' - : 'oneOf' - ] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }]; + out[info.type === 'reference' ? 'anyOf' : 'oneOf'] = [ + { allOf: out['allOf'] as Out[] }, + { type: 'null' } + ]; delete out['allOf']; } else if (out['enum'] !== undefined) { // Enum — add null to enum values if not already present @@ -412,11 +378,7 @@ export function toJsonSchema( schema: SchemaBuilder, opts?: ToJsonSchemaOptions ): Record { - const body = convertNode( - schema, - opts?.nameResolver, - opts?.mode ?? 'output' - ); + const body = convertNode(schema, opts?.nameResolver); if (opts?.$schema === false) return body; const draft = opts?.draft ?? '2020-12'; const uri = diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index afb1f950..089b9748 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -137,8 +137,6 @@ export type InferFromJsonSchema = S extends { readonly const: infer V } /** Options accepted by {@link toJsonSchema}. */ export type ToJsonSchemaOptions = { - /** Declared input before decoding/defaults, or validated output. @default 'output' */ - mode?: 'input' | 'output'; /** * JSON Schema draft version to reference in the `$schema` header. * @default '2020-12' diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md index 589b6544..0fe12617 100644 --- a/libs/schema/BOUNDARIES.md +++ b/libs/schema/BOUNDARIES.md @@ -37,8 +37,8 @@ const normalizedText = string().optional() Preprocessors can return optional/nullable values, including asynchronously. Their existing callback parameter typing is preserved; it is not a guarantee that unknown runtime input already has that type. Use explicit guards or -`decode` at untrusted boundaries. Global strict null rejection is a separate, -compatibility-sensitive follow-up. +preprocessing/conversion followed by validation at untrusted boundaries. +Global strict null rejection is a separate, compatibility-sensitive follow-up. ## One named definition, many annotated references @@ -65,87 +65,21 @@ nullability controls null acceptance for the wrapper independently. JSON Schema/OpenAPI keeps one definition and uses reference composition for local annotations, examples and nullability, including Draft 07 references. -## Declare both sides of a conversion +## Defaults and validation -```ts -import { decode, type InferInput, type InferOutput, number, object, string } - from '@cleverbrush/schema'; - -const PageSize = decode( - string(), - number().isInteger().min(1).max(100), - value => Number(value) -); -const SearchInput = object({ - pageSize: PageSize, - term: string().default('') -}); - -type Editable = InferInput; // pageSize is string -type Validated = InferOutput; // pageSize is number -const request = SearchInput.parse({ pageSize: '20' }); -``` - -Parsing validates the input, invokes the converter once, then validates its -output. Converter exceptions/rejections become validation failures. Asynchronous -converters require `parseAsync`/`validateAsync`; synchronous parsing rejects -Promise-returning conversion. Runtime parsing always validates unknown values. - -Nested objects, arrays, tuples, records, unions, intersections, references and -lazy schemas retain input/output inference. Recursive schemas still require -explicit TypeScript annotations. Standard Schema exposes the corresponding -input/output types. `InferOutput` is an alias for `InferType`. - -An input-schema default runs before conversion. A default on the boundary itself -is an **output** default, validated without conversion. Optional/nullable -use-site modifiers bypass conversion for their allowed sentinel values. - -### Application-agnostic example - -```ts -const Priority = decode( - string().oneOf('low', 'normal', 'high'), - number().min(1).max(3), - value => ({ low: 1, normal: 2, high: 3 })[value] -); -const Ticket = object({ title: string().minLength(1), priority: Priority }); -Ticket.parse({ title: 'Improve documentation', priority: 'high' }); -// { title: 'Improve documentation', priority: 3 } -``` - -Conversion policy remains application-owned: trimming, empty strings, numeric -precision, date formats and intentional data loss are not global defaults. - -## JSON Schema and OpenAPI - -```ts -import { toJsonSchema, withStandardJsonSchema } from '@cleverbrush/schema-json'; - -toJsonSchema(PageSize, { mode: 'input' }); // declared string shape -toJsonSchema(PageSize, { mode: 'output' }); // constrained numeric shape -toJsonSchema(PageSize); // output remains the default - -const standard = withStandardJsonSchema(PageSize)['~standard']; -standard.jsonSchema.input({ target: 'draft-2020-12' }); -standard.jsonSchema.output({ target: 'draft-2020-12' }); -``` - -Converters are never executed by schema export. JSON Schema describes the -declared shapes, not arbitrary converter logic or opaque custom validators. - -OpenAPI uses input views for requests and output views for responses. Named -schemas with different views receive `NameInput` and `NameOutput` components; -unchanged views retain their original name. Generated-name collisions throw, -including collisions propagated through nested named or recursive references. -AsyncAPI incoming/outgoing payloads use the same directional model. +A default on the reference overrides missing values at that use site and is +validated by the target. Clearing it leaves any target default intact. Local +fallbacks use the existing catch semantics. Async target validators and local +callbacks require `parseAsync`/`validateAsync`. -## Adoption boundary +Use `InferType` for schema inference and the existing `hasType` method when an +explicit static override is needed. Static overrides and casts do not change +runtime validation or convert values. -This release does **not** redesign React form state or typed HTTP client request -inference. Those consumers may still assume input equals output. Do not treat a -decoder as a drop-in shared form/endpoint contract for those APIs. +## API documents -Until that adoption, keep an explicit wire schema in shared contracts and decode -inside the application boundary/handler, then return the validated output DTO. -A decoder is not an encoder: no reverse conversion is inferred for requests, -URLs, response serialization, database writes or editable form state. +JSON Schema, OpenAPI and AsyncAPI retain one canonical component per named +target. Reference annotations compose around that definition rather than +changing it. Named recursive references are supported; independently rebuilt +schemas with the same name still conflict. Standard JSON Schema `input()` and +`output()` retain their existing identical representation. diff --git a/libs/schema/README.md b/libs/schema/README.md index f925db2a..d602cdab 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -2,12 +2,9 @@ ## Schemas across boundaries -Use optional-aware `catch(undefined)`, `schemaRef(namedSchema)` for per-use -annotations, and `decode(inputSchema, outputSchema, converter)` for explicit -conversion. `InferInput` describes editable input; `InferOutput` and the existing -`InferType` describe validated output. See the [boundary composition guide](./BOUNDARIES.md) -for examples, null-compatibility limitations, and the separate form/client -adoption boundary. +Use optional-aware `catch(undefined)` and `schemaRef(namedSchema)` for per-use +annotations without cloning named definitions. See the +[composition guide](./BOUNDARIES.md) for examples and null-compatibility limits. [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml) [![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/) diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts index be8378e4..d023d40f 100644 --- a/libs/schema/src/boundaries-docs.test.ts +++ b/libs/schema/src/boundaries-docs.test.ts @@ -7,12 +7,8 @@ describe('published boundary documentation', () => { const missing: string[] = []; for (const [path, names] of [ [ - '../dist/builders/BoundarySchemaBuilder.d.ts', - ['BoundarySchemaBuilder', 'schemaRef', 'decode'] - ], - [ - '../dist/builders/SchemaBuilder.d.ts', - ['InferInput', 'InferOutput'] + '../dist/builders/ReferenceSchemaBuilder.d.ts', + ['ReferenceSchemaBuilder', 'schemaRef'] ], ['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']] ] as const) { diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts index bcbd575f..a044538b 100644 --- a/libs/schema/src/builders/ArraySchemaBuilder.ts +++ b/libs/schema/src/builders/ArraySchemaBuilder.ts @@ -5,7 +5,6 @@ import type { import { type BRAND, createHybridErrorArray, - type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -102,8 +101,8 @@ export class ArraySchemaBuilder< TResult = TExplicitType extends undefined ? TElementSchema extends undefined ? Array - : TElementSchema extends SchemaBuilder - ? Array> + : TElementSchema extends SchemaBuilder + ? Array>> : never : TExplicitType > extends SchemaBuilder< @@ -111,8 +110,7 @@ export class ArraySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - Array> + TExtensions > { #minLength?: number; #defaultMinLengthErrorMessageProvider: ValidationErrorMessageProvider< diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts b/libs/schema/src/builders/BoundarySchemaBuilder.test.ts deleted file mode 100644 index b78c1b60..00000000 --- a/libs/schema/src/builders/BoundarySchemaBuilder.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - array, - boolean, - decode, - number, - object, - schemaRef, - string -} from '../index.js'; - -describe('schema boundaries', () => { - it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => { - const text = string().optional().catch(undefined); - expect(text.parse(42)).toBeUndefined(); - expect(await text.parseAsync(false)).toBeUndefined(); - expect(text.parse(null)).toBeNull(); - expect(string().nullable().catch(null).parse(42)).toBeNull(); - const normalize = string() - .optional() - .addPreprocessor(v => (v == null ? undefined : v)) - .catch(undefined); - expect(normalize.parse(null)).toBeUndefined(); - expect(object({ text }).safeParse(null).valid).toBe(false); - expect( - object({ - text, - flag: boolean() - .optional() - .catch(() => undefined) - }).parse({ text: {}, flag: 1 }) - ).toEqual({ text: undefined, flag: undefined }); - }); - - it('validates both sides and converts once', () => { - const convert = vi.fn(Number); - const size = decode( - string(), - number().isInteger().min(1).max(100), - convert - ); - expect(size.parse('12')).toBe(12); - expect(convert).toHaveBeenCalledTimes(1); - expect(size.safeParse(12).valid).toBe(false); - expect(convert).toHaveBeenCalledTimes(1); - expect(size.safeParse('0').valid).toBe(false); - expect(size.safeParse('nope').valid).toBe(false); - expect(object({ size }).parse({ size: '5' })).toEqual({ size: 5 }); - expect(array(size).parse(['2', '3'])).toEqual([2, 3]); - }); - - it('supports async conversion and captures converter failures', async () => { - const size = decode(string(), number(), async value => Number(value)); - expect(() => size.parse('1')).toThrow(/parseAsync/); - expect(await size.parseAsync('2')).toBe(2); - expect(await size['~standard'].validate('3')).toEqual({ value: 3 }); - const broken = decode(string(), number(), () => { - throw new Error('not convertible'); - }); - expect(broken.safeParse('x')).toMatchObject({ - valid: false, - errors: [{ message: 'Decoder failed: not convertible' }] - }); - const rejected = decode(string(), number(), async () => { - throw new Error('rejected'); - }); - expect((await rejected.safeParseAsync('x')).valid).toBe(false); - }); - - it('handles input defaults, output defaults and use-site modifiers', () => { - const size = decode(string().default('2'), number(), Number); - expect(size.parse(undefined)).toBe(2); - expect(size.optional().parse(undefined)).toBeUndefined(); - expect(size.nullable().parse(null)).toBeNull(); - expect(size.default(4).parse(undefined)).toBe(4); - expect(size.default(4).clearDefault().parse(undefined)).toBe(2); - expect(size.optional().required().safeParse(undefined).valid).toBe( - false - ); - }); - - it('preserves one named definition and local metadata', () => { - const user = object({ name: string().minLength(1) }).schemaName('User'); - const ref = schemaRef(user); - const previous = ref.nullable().optional().describe('Previous user'); - expect(ref.introspect().outputSchema).toBe(user); - expect(previous.introspect().outputSchema).toBe(user); - expect(user.introspect().description).toBeUndefined(); - expect(ref.safeParse(null).valid).toBe(false); - expect(previous.parse(null)).toBeNull(); - expect(previous.parse(undefined)).toBeUndefined(); - expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' }); - expect(() => schemaRef(string())).toThrow(/named schema/); - }); - - it('preserves nested reference errors and selectors', () => { - const user = object({ name: string().minLength(1) }).schemaName('User'); - const result = object({ user: schemaRef(user) }).validate({ - user: { name: '' } - }); - expect(result.valid).toBe(false); - expect( - result.getErrorsFor(t => t.user.name).errors.length - ).toBeGreaterThan(0); - expect( - result.getInvalidProperties().map(p => p.descriptor.toJsonPointer()) - ).toContain('/user/name'); - }); -}); - -describe('decoding boundary edge cases', () => { - it('uses output defaults and never re-runs conversion for catch', async () => { - const convert = vi.fn(() => undefined); - const schema = decode(string(), number().default(8), convert); - expect(schema.parse('missing')).toBe(8); - expect(convert).toHaveBeenCalledTimes(1); - const fail = vi.fn(() => { - throw new Error('conversion'); - }); - const fallback = decode(string(), number(), fail).catch(() => 4); - expect(fallback.parse('x')).toBe(4); - expect(await fallback.parseAsync('y')).toBe(4); - expect(fail).toHaveBeenCalledTimes(2); - }); - - it('keeps required messages and sync/async rules', async () => { - const schema = decode(string(), number(), Number).optional(); - expect( - schema.required('Provide a size').safeParse(undefined).errors?.[0] - .message - ).toBe('Provide a size'); - const asyncRequired = schema.required(async () => 'Async required'); - expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/); - expect( - (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message - ).toBe('Async required'); - }); - - it('retains nested errors from both decoding stages', () => { - const schema = object({ - item: decode( - object({ input: object({ value: string().minLength(1) }) }), - object({ output: object({ count: number().min(1) }) }), - input => ({ output: { count: Number(input.input.value) } }) - ) - }); - const inputFailure = schema.validate({ - item: { input: { value: '' } } - } as any); - expect( - inputFailure - .getInvalidProperties() - .map(p => p.descriptor.toJsonPointer()) - ).toContain('/item/input/value'); - const outputFailure = schema.validate({ - item: { input: { value: '0' } } - } as any); - expect( - outputFailure.getErrorsFor(t => t.item.output.count).errors.length - ).toBeGreaterThan(0); - expect( - outputFailure - .getInvalidProperties() - .map(p => p.descriptor.toJsonPointer()) - ).toContain('/item/output/count'); - }); -}); diff --git a/libs/schema/src/builders/BoundarySchemaBuilder.ts b/libs/schema/src/builders/BoundarySchemaBuilder.ts deleted file mode 100644 index c9eac67b..00000000 --- a/libs/schema/src/builders/BoundarySchemaBuilder.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { - type BRAND, - type InferInput, - type InferOutput, - SchemaBuilder, - type SchemaBuilderProps, - SYMBOL_HAS_PROPERTIES, - type ValidationContext, - type ValidationErrorMessageProvider, - type ValidationResult -} from './SchemaBuilder.js'; - -type AnySchema = SchemaBuilder; -type BoundaryOutput< - TOutputSchema, - TRequired extends boolean, - TNullable extends boolean, - TExplicitType -> = - | (TRequired extends true - ? Exclude< - TExplicitType extends undefined - ? InferOutput - : TExplicitType, - undefined - > - : - | (TExplicitType extends undefined - ? InferOutput - : TExplicitType) - | undefined) - | (TNullable extends true ? null : never); - -type BoundaryProps = Partial> & { - type: 'reference' | 'decode'; - inputSchema: AnySchema; - outputSchema: AnySchema; - converter?: (value: any) => any; - presence?: boolean; - nullability?: boolean; -}; - -/** - * Immutable schema boundary returned by {@link schemaRef} and {@link decode}. - * Use the factories rather than constructing this class. Local modifiers never - * mutate the input/output schemas or their named definitions. - * @typeParam TInputSchema - Schema validating values before conversion. - * @typeParam TOutputSchema - Schema validating converted values. - * @typeParam TRequired - Whether the output includes undefined. - * @typeParam TNullable - Whether null is allowed by a use-site modifier. - * @typeParam THasDefault - Whether the wrapper supplies an output default. - * @typeParam TExplicitType - Optional static output override. - * @typeParam TInput - Declared input, including use-site presence modifiers. - */ -export class BoundarySchemaBuilder< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema, - TRequired extends boolean = true, - TNullable extends boolean = false, - THasDefault extends boolean = false, - TExplicitType = undefined, - TInput = InferInput -> extends SchemaBuilder< - BoundaryOutput, - TRequired, - TNullable, - THasDefault, - {}, - InferInput, - TInput | (THasDefault extends true ? undefined : never) -> { - readonly #boundary: BoundaryProps; - /** @internal Enables nested property descriptors without subclassing objects. */ - readonly [SYMBOL_HAS_PROPERTIES] = true; - - /** @internal Factory configuration is exposed for immutable reconstruction. */ - constructor(props: BoundaryProps) { - super({ preprocessors: [], validators: [], ...props }); - this.#boundary = props; - } - - /** Describes both sides without evaluating the converter. */ - public introspect() { - return { - ...super.introspect(), - inputSchema: this.#boundary.inputSchema as TInputSchema, - outputSchema: this.#boundary.outputSchema as TOutputSchema, - converter: this.#boundary.converter, - presence: this.#boundary.presence, - nullability: this.#boundary.nullability, - // Object consumers can preserve real descriptor schemas through refs. - properties: - this.type === 'reference' - ? (this.#boundary.outputSchema.introspect() as any) - .properties - : undefined - }; - } - - /** @internal Rebuilds the wrapper while retaining the same target instances. */ - protected createFromProps(props: BoundaryProps): this { - return new BoundarySchemaBuilder(props) as this; - } - - #early(value: unknown): ValidationResult | undefined { - if (value === undefined && this.hasDefault) return undefined; - if (value === undefined && this.#boundary.presence !== undefined) { - return this.#boundary.presence - ? { valid: false, errors: [{ message: 'is required' }] } - : { valid: true, object: undefined }; - } - if (value === null && this.#boundary.nullability !== undefined) { - return this.#boundary.nullability - ? { valid: true, object: null } - : { valid: false, errors: [{ message: 'must not be null' }] }; - } - return undefined; - } - - #converterFailure(error: unknown): ValidationResult { - return { - valid: false, - errors: [ - { - message: `Decoder failed: ${error instanceof Error ? error.message : String(error)}` - } - ] - }; - } - - #stageFailure( - result: ValidationResult, - schema: AnySchema - ): ValidationResult { - return Object.assign(result, { __boundaryErrorSchema: schema }); - } - - /** Validates unknown input; an output fallback is never decoded a second time. */ - public validate( - value: unknown, - context?: ValidationContext - ): ValidationResult< - BoundaryOutput - > { - const result = this._validate(value, context); - if (result.valid || !this.hasCatch) return result; - const fallback = this.resolveCatchValue(); - const checked = this.#boundary.outputSchema.validate(fallback, context); - return checked.valid ? checked : { valid: true, object: fallback }; - } - - /** Async validation with the same output-fallback and error semantics as validate. */ - public async validateAsync( - value: unknown, - context?: ValidationContext - ): Promise< - ValidationResult< - BoundaryOutput - > - > { - const result = await this._validateAsync(value, context); - if (result.valid || !this.hasCatch) return result; - const fallback = this.resolveCatchValue(); - const checked = await this.#boundary.outputSchema.validateAsync( - fallback, - context - ); - return checked.valid ? checked : { valid: true, object: fallback }; - } - - /** @internal Validates input, converts once, and validates output. */ - protected _validate( - value: unknown, - context?: ValidationContext - ): ValidationResult { - const early = this.#early(value); - if (early) { - if (!early.valid && value === undefined) - early.errors = [ - { - message: this.getValidationErrorMessageSync( - this.requiredErrorMessage, - value as any - ) - } - ]; - return early; - } - const usesDefault = value === undefined && this.hasDefault; - const input = usesDefault - ? { valid: true, object: this.resolveDefaultValue() } - : this.#boundary.inputSchema.validate(value, context); - if (!input.valid) - return this.#stageFailure(input, this.#boundary.inputSchema); - let converted = input.object; - if (!usesDefault && this.#boundary.converter) { - try { - converted = this.#boundary.converter(converted); - } catch (error) { - return this.#converterFailure(error); - } - if (converted != null && typeof converted.then === 'function') { - throw new Error( - 'Decoder returned a Promise. Use validateAsync() or parseAsync().' - ); - } - } - const output = - this.type === 'reference' && !usesDefault - ? input - : this.#boundary.outputSchema.validate(converted, context); - if (!output.valid) - return this.#stageFailure(output, this.#boundary.outputSchema); - if (output.object === null && this.#boundary.nullability === false) { - return { valid: false, errors: [{ message: 'must not be null' }] }; - } - const prepared = this.preValidateSync(output.object, context); - if (!prepared.valid) return { valid: false, errors: prepared.errors }; - const preparedValue = prepared.transaction!.object.validatedObject; - if (this.preprocessors.length === 0) return output; - return this.#boundary.outputSchema.validate(preparedValue, context); - } - - /** @internal Async counterpart; converter rejections are validation failures. */ - protected async _validateAsync( - value: unknown, - context?: ValidationContext - ): Promise> { - const early = this.#early(value); - if (early) { - if (!early.valid && value === undefined) - early.errors = [ - { - message: await this.getValidationErrorMessage( - this.requiredErrorMessage, - value as any - ) - } - ]; - return early; - } - const usesDefault = value === undefined && this.hasDefault; - const input = usesDefault - ? { valid: true, object: this.resolveDefaultValue() } - : await this.#boundary.inputSchema.validateAsync(value, context); - if (!input.valid) - return this.#stageFailure(input, this.#boundary.inputSchema); - let converted = input.object; - if (!usesDefault && this.#boundary.converter) { - try { - converted = await this.#boundary.converter(converted); - } catch (error) { - return this.#converterFailure(error); - } - } - const output = - this.type === 'reference' && !usesDefault - ? input - : await this.#boundary.outputSchema.validateAsync( - converted, - context - ); - if (!output.valid) - return this.#stageFailure(output, this.#boundary.outputSchema); - if (output.object === null && this.#boundary.nullability === false) { - return { valid: false, errors: [{ message: 'must not be null' }] }; - } - const prepared = await this.preValidateAsync(output.object, context); - if (!prepared.valid) return { valid: false, errors: prepared.errors }; - const preparedValue = prepared.transaction!.object.validatedObject; - if (this.preprocessors.length === 0) return output; - return this.#boundary.outputSchema.validateAsync( - preparedValue, - context - ); - } - - /** Overrides only the static output type; runtime validation is unchanged. */ - public hasType( - _notUsed?: T - ): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - TNullable, - THasDefault, - T, - TInput - > { - return this.createFromProps(this.#props()) as any; - } - - /** Restores output inference from the output schema. */ - public clearHasType(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - TNullable, - THasDefault, - undefined, - TInput - > { - return this.createFromProps(this.#props()) as any; - } - - #props(): BoundaryProps { - return { - ...this.#boundary, - ...this.introspect(), - type: this.#boundary.type, - preprocessors: [...this.preprocessors], - validators: [...this.validators] - }; - } - - /** Rejects missing input at this use site. */ - public required( - errorMessage?: ValidationErrorMessageProvider - ): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - true, - TNullable, - THasDefault, - TExplicitType, - Exclude - > { - return this.createFromProps({ - ...this.#props(), - isRequired: true, - presence: true, - requiredValidationErrorMessageProvider: errorMessage - }) as any; - } - - /** Allows omitted input at this use site, without invoking the boundary. */ - public optional(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - false, - TNullable, - THasDefault, - TExplicitType, - TInput | undefined - > { - return this.createFromProps({ - ...this.#props(), - isRequired: false, - presence: false - }) as any; - } - - /** Allows null at this use site, independently of optionality. */ - public nullable(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - true, - THasDefault, - TExplicitType, - TInput | null - > { - return this.createFromProps({ - ...this.#props(), - isNullable: true, - nullability: true - }) as any; - } - - /** Rejects null at this use site without changing the referenced definition. */ - public notNullable(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - false, - THasDefault, - Exclude< - TExplicitType extends undefined - ? InferOutput - : TExplicitType, - null - >, - Exclude - > { - return this.createFromProps({ - ...this.#props(), - isNullable: false, - nullability: false - }) as any; - } - - /** Supplies an output default for missing input; validates it without decoding. */ - public default( - value: InferOutput | (() => InferOutput) - ): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - true, - TNullable, - true, - TExplicitType, - TInput - > { - return this.createFromProps({ - ...this.#props(), - defaultValue: value, - isRequired: true - }) as any; - } - - /** Removes the use-site default; defaults on the target are unchanged. */ - public clearDefault(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - TNullable, - false, - TExplicitType, - TInput - > { - return this.createFromProps({ - ...this.#props(), - defaultValue: undefined - }) as any; - } - - /** Brands the output type only; it does not alter runtime validation. */ - public brand( - _name?: B - ): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - TNullable, - THasDefault, - (TExplicitType extends undefined - ? InferOutput - : TExplicitType) & { - readonly [K in BRAND]: B; - }, - TInput - > { - return super.brand(_name); - } - - /** Makes the output type readonly without freezing runtime values. */ - public readonly(): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - TRequired, - TNullable, - THasDefault, - Readonly< - TExplicitType extends undefined - ? InferOutput - : TExplicitType - >, - TInput - > { - return super.readonly(); - } -} - -type RequiredOf = undefined extends InferOutput ? false : true; -type NullableOf = null extends InferOutput ? true : false; - -/** - * References one named definition with independent use-site annotations. - * @param schema - A reused schema constant with a nonempty schemaName. - * @returns An immutable reference wrapper; the named target is not cloned. - * @throws Error if the target has no name. - * @example - * const User = object({ name: string() }).schemaName('User'); - * const previous = schemaRef(User).nullable().optional().describe('Previous user'); - */ -export function schemaRef( - schema: S -): BoundarySchemaBuilder, NullableOf> { - const info = schema.introspect(); - if (!info.schemaName?.trim()) - throw new Error('schemaRef requires a named schema.'); - return new BoundarySchemaBuilder({ - type: 'reference', - inputSchema: schema, - outputSchema: schema, - isRequired: info.isRequired, - isNullable: info.isNullable - }); -} - -/** - * Composes input validation, an explicit conversion, and output validation. - * @param inputSchema - Validates external input before the converter runs. - * @param outputSchema - Validates the converted result. - * @param converter - Receives validated input; may return a Promise. - * @returns A schema with distinct inferred input/output types. - * @remarks Converter exceptions become validation failures. Async converters - * require parseAsync/validateAsync. JSON Schema documents declared shapes only, - * not arbitrary conversion logic. This is not a bidirectional codec. - * @example - * const PageSize = decode(string(), number().isInteger().min(1), Number); - * PageSize.parse('12'); // 12 - */ -export function decode< - TInputSchema extends AnySchema, - TOutputSchema extends AnySchema ->( - inputSchema: TInputSchema, - outputSchema: TOutputSchema, - converter: ( - input: InferOutput - ) => InferInput | Promise> -): BoundarySchemaBuilder< - TInputSchema, - TOutputSchema, - RequiredOf, - NullableOf -> { - const info = outputSchema.introspect(); - return new BoundarySchemaBuilder({ - type: 'decode', - inputSchema, - outputSchema, - converter, - isRequired: info.isRequired, - isNullable: info.isNullable - }); -} diff --git a/libs/schema/src/builders/ExternSchemaBuilder.ts b/libs/schema/src/builders/ExternSchemaBuilder.ts index 76fb55ec..6d4fb12d 100644 --- a/libs/schema/src/builders/ExternSchemaBuilder.ts +++ b/libs/schema/src/builders/ExternSchemaBuilder.ts @@ -100,8 +100,7 @@ export class ExternSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - StandardSchemaV1.InferInput + TExtensions > { #standardSchema: TStandardSchema; diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts index 47488a6b..6d746c6a 100644 --- a/libs/schema/src/builders/IntersectionSchemaBuilder.ts +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts @@ -1,6 +1,5 @@ import { type BRAND, - type InferInput, type InferType, SchemaBuilder, type ValidationContext, @@ -37,8 +36,7 @@ export class IntersectionSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - InferInput & InferInput + TExtensions > { #left!: TLeft; #right!: TRight; diff --git a/libs/schema/src/builders/LazySchemaBuilder.ts b/libs/schema/src/builders/LazySchemaBuilder.ts index cc0ede6c..12f0d7c5 100644 --- a/libs/schema/src/builders/LazySchemaBuilder.ts +++ b/libs/schema/src/builders/LazySchemaBuilder.ts @@ -1,7 +1,5 @@ import { type BRAND, - type InferInput, - type InferOutput, SchemaBuilder, type ValidationContext, type ValidationErrorMessageProvider, @@ -51,15 +49,13 @@ export class LazySchemaBuilder< TRequired extends boolean = true, TNullable extends boolean = false, THasDefault extends boolean = false, - TExtensions = {}, - TInput = TResult + TExtensions = {} > extends SchemaBuilder< TResult, TRequired, TNullable, THasDefault, - TExtensions, - TInput + TExtensions > { #getter: () => SchemaBuilder; #resolvedSchema: SchemaBuilder | null = null; @@ -214,7 +210,7 @@ export class LazySchemaBuilder< */ public hasType( _notUsed?: T - ): LazySchemaBuilder & + ): LazySchemaBuilder & TExtensions { return this.createFromProps({ ...this.introspect() @@ -229,8 +225,7 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return this.createFromProps({ @@ -243,14 +238,7 @@ export class LazySchemaBuilder< */ public required( errorMessage?: ValidationErrorMessageProvider - ): LazySchemaBuilder< - TResult, - true, - TNullable, - THasDefault, - TExtensions, - TInput - > & + ): LazySchemaBuilder & TExtensions { return super.required(errorMessage); } @@ -263,8 +251,7 @@ export class LazySchemaBuilder< false, TNullable, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return super.optional(); @@ -275,7 +262,7 @@ export class LazySchemaBuilder< */ public default( value: TResult | (() => TResult) - ): LazySchemaBuilder & + ): LazySchemaBuilder & TExtensions { return super.default(value) as any; } @@ -288,8 +275,7 @@ export class LazySchemaBuilder< TRequired, TNullable, false, - TExtensions, - TInput + TExtensions > & TExtensions { return super.clearDefault() as any; @@ -305,8 +291,7 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return super.brand(_name); @@ -320,8 +305,7 @@ export class LazySchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return super.readonly(); @@ -335,8 +319,7 @@ export class LazySchemaBuilder< TRequired, true, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return super.nullable() as any; @@ -350,8 +333,7 @@ export class LazySchemaBuilder< TRequired, false, THasDefault, - TExtensions, - TInput + TExtensions > & TExtensions { return super.notNullable() as any; @@ -390,12 +372,6 @@ export class LazySchemaBuilder< * }); * ``` */ -export function lazy>( - getter: () => S -): LazySchemaBuilder, true, false, false, {}, InferInput>; -export function lazy( - getter: () => SchemaBuilder -): LazySchemaBuilder; export function lazy( getter: () => SchemaBuilder ): LazySchemaBuilder { diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts index 4ce3529a..5563d7f0 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -1,7 +1,6 @@ import { PropertyValidationResult } from './PropertyValidationResult.js'; import { type BRAND, - type InferInput, type InferType, type NestedValidationResult, type PreValidationResult, @@ -138,9 +137,9 @@ export type RespectPropsOptionality< type RespectPropsOptionalityForInput< T extends Record> > = { - [K in RequiredInputProps]: InferInput; + [K in RequiredInputProps]: InferType; } & { - [K in NotRequiredInputProps]?: InferInput; + [K in NotRequiredInputProps]?: InferType; }; type MakeChildrenRequired< @@ -377,8 +376,7 @@ export class ObjectSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - RespectPropsOptionalityForInput + TExtensions > { #properties: TProperties = {} as any; #acceptUnknownProps = false; @@ -1076,8 +1074,7 @@ export class ObjectSchemaBuilder< result as any, descriptor, addErrorFor, - (result as any).__boundaryErrorSchema ?? - this.#properties[key] + this.#properties[key] ); // For extern schemas, also record errors on the extern // descriptor itself so getErrorsFor(t => t.extern) works, @@ -1846,8 +1843,7 @@ export class ObjectSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - RespectPropsOptionalityForInput + TExtensions > { return this.createFromProps({ ...this.introspect() @@ -2769,7 +2765,6 @@ export class ObjectSchemaBuilder< ): void { const schema = schemaOverride ?? - result.__boundaryErrorSchema ?? ObjectSchemaBuilder.#getSchemaForPropertyDescriptor(descriptor); const properties = (schema.introspect() as any).properties; @@ -3123,11 +3118,33 @@ type NotRequiredProps< type RequiredInputProps< T extends Record> > = keyof { - [k in keyof T as undefined extends InferInput ? never : k]: T[k]; + [k in keyof T as T[k] extends SchemaBuilder< + any, + infer TReq, + any, + infer THasDef + > + ? TReq extends true + ? THasDef extends true + ? never + : k + : never + : never]: T[k]; }; type NotRequiredInputProps< T extends Record> > = keyof { - [k in keyof T as undefined extends InferInput ? k : never]: T[k]; + [k in keyof T as T[k] extends SchemaBuilder< + any, + infer TReq, + any, + infer THasDef + > + ? TReq extends true + ? THasDef extends true + ? k + : never + : k + : never]: T[k]; }; diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts index 5b0e306d..f9e2ab63 100644 --- a/libs/schema/src/builders/RecordSchemaBuilder.ts +++ b/libs/schema/src/builders/RecordSchemaBuilder.ts @@ -14,7 +14,6 @@ */ import { type BRAND, - type InferInput, type InferType, type PreValidationResult, SchemaBuilder, @@ -242,8 +241,7 @@ export class RecordSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - Record, InferInput> + TExtensions > { #keySchema!: TKeySchema; #valueSchema!: TValueSchema; diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts new file mode 100644 index 00000000..01ea34f4 --- /dev/null +++ b/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { array, boolean, number, object, schemaRef, string } from '../index.js'; + +describe('optional fallbacks', () => { + it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => { + const text = string().optional().catch(undefined); + expect(text.parse(42)).toBeUndefined(); + expect(await text.parseAsync(false)).toBeUndefined(); + expect(text.parse(null)).toBeNull(); + expect(string().nullable().catch(null).parse(42)).toBeNull(); + const normalize = string() + .optional() + .addPreprocessor(v => (v == null ? undefined : v)) + .catch(undefined); + expect(normalize.parse(null)).toBeUndefined(); + expect(object({ text }).safeParse(null).valid).toBe(false); + expect( + object({ + text, + flag: boolean() + .optional() + .catch(() => undefined) + }).parse({ text: {}, flag: 1 }) + ).toEqual({ text: undefined, flag: undefined }); + }); + + it('supports async optional preprocessing without dropping array entries', async () => { + const fallback = vi.fn(() => undefined); + const text = string().optional().catch(fallback); + expect(array(text).parse(['ok', 42])).toEqual(['ok', undefined]); + expect(fallback).toHaveBeenCalledTimes(1); + const normalized = string() + .optional() + .addPreprocessor(async v => (v == null ? undefined : v)); + expect(await normalized.parseAsync(null)).toBeUndefined(); + expect(() => normalized.parse(null)).toThrow(/validateAsync/); + }); +}); + +describe('named schema references', () => { + it('preserves one target definition and independent local metadata', () => { + const user = object({ name: string().minLength(1) }).schemaName('User'); + const ref = schemaRef(user); + const previous = ref + .nullable() + .optional() + .describe('Previous user') + .example(null); + expect(ref.introspect().targetSchema).toBe(user); + expect(previous.introspect().targetSchema).toBe(user); + expect(user.introspect().description).toBeUndefined(); + expect(user.introspect().isRequired).toBe(true); + expect(ref.safeParse(null).valid).toBe(false); + expect(previous.parse(null)).toBeNull(); + expect(previous.parse(undefined)).toBeUndefined(); + expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' }); + expect(() => schemaRef(string())).toThrow(/named schema/); + expect(() => schemaRef(string().schemaName(' '))).toThrow( + /named schema/ + ); + }); + + it('preserves deeply nested reference errors and selectors', async () => { + const address = object({ city: string().minLength(1) }).schemaName( + 'Address' + ); + const user = object({ address: schemaRef(address) }).schemaName('User'); + const root = object({ user: schemaRef(user) }); + for (const result of [ + root.validate({ user: { address: { city: '' } } }), + await root.validateAsync({ user: { address: { city: '' } } }) + ]) { + expect(result.valid).toBe(false); + expect( + result.getErrorsFor(t => t.user.address.city).errors.length + ).toBeGreaterThan(0); + expect( + result + .getInvalidProperties() + .map(p => p.descriptor.toJsonPointer()) + ).toContain('/user/address/city'); + } + }); + + it('keeps target defaults, local defaults and fallbacks independent', async () => { + const target = number().default(2).schemaName('Size'); + const ref = schemaRef(target); + expect(ref.parse(undefined)).toBe(2); + expect(ref.optional().parse(undefined)).toBeUndefined(); + expect(ref.optional().default(4).parse(undefined)).toBe(4); + expect(ref.default(4).clearDefault().parse(undefined)).toBe(2); + expect(target.parse(undefined)).toBe(2); + const fallback = vi.fn(() => 8); + const caught = ref.catch(fallback); + expect(caught.parse('invalid')).toBe(8); + expect(await caught.parseAsync('invalid')).toBe(8); + expect(fallback).toHaveBeenCalledTimes(2); + expect(ref.optional().catch(undefined).parse({})).toBeUndefined(); + }); + + it('keeps local optionality, nullability and required error messages distinct', async () => { + const ref = schemaRef( + string().nullable().optional().schemaName('Text') + ); + expect(ref.parse(null)).toBeNull(); + expect(ref.notNullable().safeParse(null).valid).toBe(false); + expect(ref.notNullable().parse(undefined)).toBeUndefined(); + const required = ref.required('Provide text'); + expect(required.safeParse(undefined).errors?.[0].message).toBe( + 'Provide text' + ); + expect(required.parse(null)).toBeNull(); + const asyncRequired = ref.required(async () => 'Async required'); + expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/); + expect( + (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message + ).toBe('Async required'); + }); + + it('delegates async target validation and runs local callbacks', async () => { + const target = string() + .addPreprocessor(async v => v.trim()) + .schemaName('Trimmed'); + const ref = schemaRef(target); + expect(() => ref.parse(' text ')).toThrow(/validateAsync/); + expect(await ref.parseAsync(' text ')).toBe('text'); + expect(await ref['~standard'].validate(' text ')).toEqual({ + value: 'text' + }); + const local = schemaRef(number().schemaName('Count')) + .addPreprocessor(v => v + 1) + .addValidator(v => ({ valid: v < 5 })); + expect(local.parse(2)).toBe(3); + expect(local.safeParse(5).valid).toBe(false); + expect( + schemaRef(string().schemaName('OptionalText')) + .optional() + .addPreprocessor(() => undefined) + .parse('text') + ).toBeUndefined(); + }); + + it('retains existing hasType behavior without changing runtime validation', () => { + const ref = schemaRef(number().schemaName('Count')).hasType(); + expect(ref.parse(3)).toBe(3); + expect(ref.safeParse('three').valid).toBe(false); + expect(ref.clearHasType().parse(4)).toBe(4); + }); +}); diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.ts new file mode 100644 index 00000000..fa11afc2 --- /dev/null +++ b/libs/schema/src/builders/ReferenceSchemaBuilder.ts @@ -0,0 +1,387 @@ +import { + type BRAND, + type InferType, + SchemaBuilder, + type SchemaBuilderProps, + SYMBOL_HAS_PROPERTIES, + type ValidationContext, + type ValidationErrorMessageProvider, + type ValidationResult +} from './SchemaBuilder.js'; + +type AnySchema = SchemaBuilder; +type ReferenceValue< + TSchema, + TRequired extends boolean, + TNullable extends boolean, + TExplicitType +> = + | (TRequired extends true + ? Exclude< + TExplicitType extends undefined + ? InferType + : TExplicitType, + undefined + > + : + | (TExplicitType extends undefined + ? InferType + : TExplicitType) + | undefined) + | (TNullable extends true ? null : never); +type ReferenceProps = Partial> & { + targetSchema: AnySchema; + presence?: boolean; + nullability?: boolean; +}; + +/** + * Immutable use-site wrapper around one named schema. Create it with schemaRef. + * Local modifiers never clone or mutate the target definition. + * @typeParam TSchema - Referenced schema. + * @typeParam TRequired - Whether a value is required at this use site. + * @typeParam TNullable - Whether null is allowed at this use site. + * @typeParam THasDefault - Whether the wrapper supplies a default. + * @typeParam TExplicitType - Optional static type override. + */ +export class ReferenceSchemaBuilder< + TSchema extends AnySchema, + TRequired extends boolean = true, + TNullable extends boolean = false, + THasDefault extends boolean = false, + TExplicitType = undefined +> extends SchemaBuilder< + ReferenceValue, + TRequired, + TNullable, + THasDefault +> { + readonly #reference: ReferenceProps; + /** @internal Enables nested property descriptors for referenced objects. */ + readonly [SYMBOL_HAS_PROPERTIES] = true; + + /** @internal Use schemaRef rather than constructing a reference directly. */ + constructor(props: ReferenceProps) { + super({ + preprocessors: [], + validators: [], + ...props, + type: 'reference' + }); + this.#reference = props; + } + + /** Describes the target and local modifiers without changing the target. */ + public introspect() { + return { + ...super.introspect(), + targetSchema: this.#reference.targetSchema as TSchema, + presence: this.#reference.presence, + nullability: this.#reference.nullability, + properties: (this.#reference.targetSchema.introspect() as any) + .properties + }; + } + + /** @internal Reconstructs an immutable wrapper around the same target. */ + protected createFromProps(props: ReferenceProps): this { + return new ReferenceSchemaBuilder(props) as this; + } + + /** Validates through the target and retains the inferred result type. */ + public validate( + value: unknown, + context?: ValidationContext + ): ValidationResult< + ReferenceValue + > { + return super.validate(value, context); + } + + /** Async validation with the same inference and fallback behavior. */ + public validateAsync( + value: unknown, + context?: ValidationContext + ): Promise< + ValidationResult< + ReferenceValue + > + > { + return super.validateAsync(value, context); + } + + #props(): ReferenceProps { + return { + ...this.#reference, + ...this.introspect(), + preprocessors: [...this.preprocessors], + validators: [...this.validators] + }; + } + + #early(value: unknown): ValidationResult | undefined { + if (value === undefined && this.hasDefault) return undefined; + if (value === undefined && this.#reference.presence !== undefined) { + return this.#reference.presence + ? { valid: false, errors: [{ message: 'is required' }] } + : { valid: true, object: undefined }; + } + if (value === null && this.#reference.nullability !== undefined) { + return this.#reference.nullability + ? { valid: true, object: null } + : { valid: false, errors: [{ message: 'must not be null' }] }; + } + return undefined; + } + + #checkNull(result: ValidationResult): ValidationResult { + if ( + result.valid && + result.object === null && + this.#reference.nullability === false + ) + return { valid: false, errors: [{ message: 'must not be null' }] }; + return result; + } + + /** @internal Delegates validation while applying local presence and validators. */ + protected _validate( + value: unknown, + context?: ValidationContext + ): ValidationResult { + const early = this.#early(value); + if (early) { + if (!early.valid && value === undefined) + early.errors = [ + { + message: this.getValidationErrorMessageSync( + this.requiredErrorMessage, + value as any + ) + } + ]; + return early; + } + const result = this.#checkNull( + this.#reference.targetSchema.validate( + value === undefined && this.hasDefault + ? this.resolveDefaultValue() + : value, + context + ) + ); + if (!result.valid) return result; + const prepared = this.preValidateSync(result.object, context); + if (!prepared.valid) return { valid: false, errors: prepared.errors }; + if (this.preprocessors.length === 0) return result; + const preparedValue = prepared.transaction!.object.validatedObject; + return this.#checkNull( + this.#early(preparedValue) ?? + this.#reference.targetSchema.validate(preparedValue, context) + ); + } + + /** @internal Async counterpart, including target validators and local callbacks. */ + protected async _validateAsync( + value: unknown, + context?: ValidationContext + ): Promise> { + const early = this.#early(value); + if (early) { + if (!early.valid && value === undefined) + early.errors = [ + { + message: await this.getValidationErrorMessage( + this.requiredErrorMessage, + value as any + ) + } + ]; + return early; + } + const result = this.#checkNull( + await this.#reference.targetSchema.validateAsync( + value === undefined && this.hasDefault + ? this.resolveDefaultValue() + : value, + context + ) + ); + if (!result.valid) return result; + const prepared = await this.preValidateAsync(result.object, context); + if (!prepared.valid) return { valid: false, errors: prepared.errors }; + if (this.preprocessors.length === 0) return result; + const preparedValue = prepared.transaction!.object.validatedObject; + return this.#checkNull( + this.#early(preparedValue) ?? + (await this.#reference.targetSchema.validateAsync( + preparedValue, + context + )) + ); + } + + /** Overrides the static type only; target validation remains unchanged. */ + public hasType( + _notUsed?: T + ): ReferenceSchemaBuilder { + return this.createFromProps(this.#props()) as any; + } + + /** Restores inference from the referenced schema. */ + public clearHasType(): ReferenceSchemaBuilder< + TSchema, + TRequired, + TNullable, + THasDefault + > { + return this.createFromProps(this.#props()) as any; + } + + /** Rejects omitted values at this use site, unless a local default exists. */ + public required( + errorMessage?: ValidationErrorMessageProvider + ): ReferenceSchemaBuilder< + TSchema, + true, + TNullable, + THasDefault, + TExplicitType + > { + return this.createFromProps({ + ...this.#props(), + isRequired: true, + presence: true, + requiredValidationErrorMessageProvider: errorMessage + }) as any; + } + + /** Allows omission without invoking the target, unless a local default exists. */ + public optional(): ReferenceSchemaBuilder< + TSchema, + false, + TNullable, + THasDefault, + TExplicitType + > { + return this.createFromProps({ + ...this.#props(), + isRequired: false, + presence: false + }) as any; + } + + /** Allows null independently of omission at this use site. */ + public nullable(): ReferenceSchemaBuilder< + TSchema, + TRequired, + true, + THasDefault, + TExplicitType + > { + return this.createFromProps({ + ...this.#props(), + isNullable: true, + nullability: true + }) as any; + } + + /** Rejects null without modifying the target definition. */ + public notNullable(): ReferenceSchemaBuilder< + TSchema, + TRequired, + false, + THasDefault, + Exclude< + TExplicitType extends undefined + ? InferType + : TExplicitType, + null + > + > { + return this.createFromProps({ + ...this.#props(), + isNullable: false, + nullability: false + }) as any; + } + + /** Supplies a local default for omission, validated by the target. */ + public default( + value: InferType | (() => InferType) + ): ReferenceSchemaBuilder { + return this.createFromProps({ + ...this.#props(), + defaultValue: value, + isRequired: true + }) as any; + } + + /** Removes the local default; target defaults are unchanged. */ + public clearDefault(): ReferenceSchemaBuilder< + TSchema, + TRequired, + TNullable, + false, + TExplicitType + > { + return this.createFromProps({ + ...this.#props(), + defaultValue: undefined + }) as any; + } + + /** Brands the inferred type without changing runtime validation. */ + public brand( + _name?: B + ): ReferenceSchemaBuilder< + TSchema, + TRequired, + TNullable, + THasDefault, + (TExplicitType extends undefined + ? InferType + : TExplicitType) & { readonly [K in BRAND]: B } + > { + return super.brand(_name); + } + + /** Makes the inferred type readonly without freezing runtime values. */ + public readonly(): ReferenceSchemaBuilder< + TSchema, + TRequired, + TNullable, + THasDefault, + Readonly< + TExplicitType extends undefined ? InferType : TExplicitType + > + > { + return super.readonly(); + } +} + +/** + * References one named definition with independent use-site annotations. + * @param schema - A reused schema constant with a nonempty schemaName. + * @returns An immutable reference wrapper; the target is neither cloned nor mutated. + * @throws Error if the target has no name. + * @example + * const User = object({ name: string() }).schemaName('User'); + * const previous = schemaRef(User).nullable().optional().describe('Previous user'); + */ +export function schemaRef( + schema: TSchema +): ReferenceSchemaBuilder< + TSchema, + undefined extends InferType ? false : true, + null extends InferType ? true : false +> { + const info = schema.introspect(); + if (!info.schemaName?.trim()) + throw new Error('schemaRef requires a named schema.'); + return new ReferenceSchemaBuilder({ + targetSchema: schema, + isRequired: info.isRequired, + isNullable: info.isNullable + }); +} diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 311e86ed..5fdc8671 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -9,8 +9,6 @@ import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js'; /** @internal Symbol used as the key for the type brand on schema builders. */ declare const __type: unique symbol; -/** @internal Input type marker. */ -declare const __input: unique symbol; /** @internal */ export type SchemaTypeBrand = typeof __type; @@ -59,18 +57,6 @@ export type InferType = T extends { ? TType : T; -/** Validated output of a schema; compatibility alias for {@link InferType}. */ -export type InferOutput = InferType; - -/** - * Declared input before decoding and defaults. Parsers still validate unknown - * data at runtime. Legacy preprocessors do not infer a separate input type. - * @typeParam T - Schema whose input is required. - */ -export type InferInput = T extends { readonly [__input]: infer I } - ? I - : InferType; - /** * Represents a single validation error with a human-readable error message. * @@ -583,7 +569,7 @@ export type PropertyDescriptorTree< K & string > & ExternOutputPropertyDescriptors< - NonNullable>, + NonNullable>, TRootSchema, PropertyDescriptor< TRootSchema, @@ -751,8 +737,6 @@ type ResolvedSchemaType< * **Note:** this class is not intended to be used directly, use one of the subclasses instead. * @typeparam TResult Type of the object that will be returned by `validate()` method. * @typeparam TRequired If `true`, object will be required. If `false`, object will be optional. - * @typeParam TInput - Declared input before decoding. Defaults to TResult so - * existing generic arguments retain their meaning. */ export abstract class SchemaBuilder< TResult = any, @@ -760,11 +744,7 @@ export abstract class SchemaBuilder< TNullable extends boolean = false, THasDefault extends boolean = false, // biome-ignore lint/correctness/noUnusedVariables: used in extensions - TExtensions = {}, - TInput = TResult, - TResolvedInput = - | ResolvedSchemaType - | (THasDefault extends true ? undefined : never) + TExtensions = {} > { #isRequired = true; #isNullable = false; @@ -792,7 +772,6 @@ export abstract class SchemaBuilder< */ #standardProps: | StandardSchemaV1.Props< - TResolvedInput, ResolvedSchemaType > | undefined; @@ -815,9 +794,6 @@ export abstract class SchemaBuilder< */ declare readonly [__hasDefault]: THasDefault; - /** @internal Input type, including omission when a default exists. */ - declare readonly [__input]: TResolvedInput; - /** * Standard Schema v1 interface. * @@ -868,7 +844,6 @@ export abstract class SchemaBuilder< * @see https://standardschema.dev/ */ get ['~standard'](): StandardSchemaV1.Props< - TResolvedInput, ResolvedSchemaType > { if (this.#standardProps) return this.#standardProps; @@ -1783,7 +1758,7 @@ export abstract class SchemaBuilder< * @param options - Whether the callback can mutate its argument. * @returns A new builder preserving the original schema. * @remarks Legacy callback parameter typing is preserved for compatibility. - * For a separately typed external input, use decode(input, output, fn). + * Unknown external data still needs runtime validation. */ public addPreprocessor( preprocessor: ( diff --git a/libs/schema/src/builders/TupleSchemaBuilder.ts b/libs/schema/src/builders/TupleSchemaBuilder.ts index d2d690ef..8a5a6d76 100644 --- a/libs/schema/src/builders/TupleSchemaBuilder.ts +++ b/libs/schema/src/builders/TupleSchemaBuilder.ts @@ -5,7 +5,6 @@ import type { import { type BRAND, createHybridErrorArray, - type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -161,13 +160,7 @@ export class TupleSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - TRestSchema extends SchemaBuilder - ? [ - ...{ [K in keyof TElements]: InferInput }, - ...Array> - ] - : { [K in keyof TElements]: InferInput } + TExtensions > { #elements!: TElements; #restSchema: diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts index 9ac80679..0560e4c4 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.ts @@ -5,7 +5,6 @@ import type { import { type BRAND, createHybridErrorArray, - type InferInput, type InferType, type NestedValidationResult, type PropertyDescriptor, @@ -173,8 +172,7 @@ export class UnionSchemaBuilder< TRequired, TNullable, THasDefault, - TExtensions, - InferInput + TExtensions > { #options!: TOptions; #discriminatorKey: string | null = null; diff --git a/libs/schema/src/builders/boundaries.test-d.ts b/libs/schema/src/builders/boundaries.test-d.ts deleted file mode 100644 index f9f9ef39..00000000 --- a/libs/schema/src/builders/boundaries.test-d.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { expectTypeOf, test } from 'vitest'; -import { - array, - decode, - type InferInput, - type InferOutput, - type InferType, - intersection, - lazy, - number, - object, - record, - schemaRef, - string, - tuple, - union -} from '../index.js'; - -test('input/output inference composes without changing InferType', () => { - const size = decode(string(), number(), Number); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf(size.parse('1')).toEqualTypeOf(); - const form = object({ size, title: string().default('untitled') }); - expectTypeOf>().toMatchTypeOf<{ - size: string; - title?: string; - }>(); - expectTypeOf<{ size: string }>().toMatchTypeOf>(); - expectTypeOf>().toMatchTypeOf<{ - size: number; - title: string; - }>(); - const list = array(size); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf(); - const pair = tuple([size, string()]); - expectTypeOf>().toEqualTypeOf<[string, string]>(); - const dictionary = record(string(), size); - expectTypeOf>().toEqualTypeOf< - Record - >(); - const choice = union(size).or(string()); - expectTypeOf>().toEqualTypeOf(); - const joined = intersection(object({ size }), object({ title: string() })); - expectTypeOf>().toMatchTypeOf<{ - size: string; - title: string; - }>(); - const ref = schemaRef(form.schemaName('Form')); - expectTypeOf>().toEqualTypeOf< - InferInput - >(); - const deferred = lazy(() => size); - expectTypeOf>().toEqualTypeOf(); - // @ts-expect-error required schemas cannot fall back to undefined - string().catch(undefined); - // @ts-expect-error non-nullable schemas cannot fall back to null - string().catch(null); - // @ts-expect-error converter must return the output schema's input - decode(string(), number(), value => value); - string().optional().catch(undefined); - string() - .nullable() - .catch(() => null); - string() - .optional() - .addPreprocessor(() => undefined); -}); - -test('boundary presence belongs to each side independently', () => { - const nullableOutput = decode(string(), number().nullable(), () => null); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf< - number | null - >(); - const nullableInput = decode(string().nullable(), number(), value => - value === null ? 0 : Number(value) - ); - expectTypeOf>().toEqualTypeOf< - string | null - >(); - expectTypeOf>().toEqualTypeOf(); - const defaults = object({ - size: decode(string().default('1'), number(), Number) - }); - expectTypeOf<{}>().toMatchTypeOf>(); - const optimized = defaults.optimize(); - expectTypeOf>().toEqualTypeOf< - InferInput - >(); - const required = nullableInput.optional().required().notNullable(); - expectTypeOf>().toEqualTypeOf(); - const ref = schemaRef(object({ name: string() }).schemaName('User')) - .nullable() - .optional(); - const root = object({ user: ref }); - root.validate({ user: null }).getErrorsFor(t => t.user.name); - // @ts-expect-error concrete descriptor trees still reject unknown fields - root.validate({ user: null }).getErrorsFor(t => t.user.missing); -}); diff --git a/libs/schema/src/builders/references.test-d.ts b/libs/schema/src/builders/references.test-d.ts new file mode 100644 index 00000000..1f8ca4a9 --- /dev/null +++ b/libs/schema/src/builders/references.test-d.ts @@ -0,0 +1,47 @@ +import { expectTypeOf, test } from 'vitest'; +import { type InferType, number, object, schemaRef, string } from '../index.js'; + +test('optional-aware fallback and preprocessing types remain checked', () => { + // @ts-expect-error required schemas cannot fall back to undefined + string().catch(undefined); + // @ts-expect-error non-nullable schemas cannot fall back to null + string().catch(null); + string().optional().catch(undefined); + string() + .nullable() + .catch(() => null); + string() + .optional() + .addPreprocessor(() => undefined); + string() + .nullable() + .addPreprocessor(async () => null); +}); + +test('references preserve inferred types, local modifiers and selectors', () => { + const user = object({ name: string() }).schemaName('User'); + const ref = schemaRef(user); + expectTypeOf>().toEqualTypeOf< + InferType + >(); + const optional = ref.optional().nullable(); + expectTypeOf>().toEqualTypeOf< + InferType | undefined | null + >(); + const required = optional.required().notNullable(); + expectTypeOf>().toEqualTypeOf< + InferType + >(); + const withDefault = ref.optional().default({ name: 'Ada' }); + expectTypeOf>().toEqualTypeOf< + InferType + >(); + const root = object({ user: optional }); + root.validate({ user: null }).getErrorsFor(t => t.user.name); + // @ts-expect-error concrete descriptor trees still reject unknown fields + root.validate({ user: null }).getErrorsFor(t => t.user.missing); + const override = schemaRef(number().schemaName('Number')).hasType(); + expectTypeOf>().toEqualTypeOf(); + const cleared = override.clearHasType(); + expectTypeOf>().toEqualTypeOf(); +}); diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts index b0123534..c27c368c 100644 --- a/libs/schema/src/core.ts +++ b/libs/schema/src/core.ts @@ -12,11 +12,6 @@ export { BooleanSchemaBuilder, boolean } from './builders/BooleanSchemaBuilder.js'; -export { - BoundarySchemaBuilder, - decode, - schemaRef -} from './builders/BoundarySchemaBuilder.js'; export { DateSchemaBuilder, date } from './builders/DateSchemaBuilder.js'; export { ExternSchemaBuilder, @@ -55,9 +50,11 @@ export { } from './builders/PromiseSchemaBuilder.js'; export type { RecordSchemaValidationResult } from './builders/RecordSchemaBuilder.js'; export { RecordSchemaBuilder, record } from './builders/RecordSchemaBuilder.js'; +export { + ReferenceSchemaBuilder, + schemaRef +} from './builders/ReferenceSchemaBuilder.js'; export type { - InferInput, - InferOutput, NestedValidationResult, PropertyDescriptor, PropertyDescriptorInner, diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 84426fe1..f44f40cd 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -9,15 +9,10 @@ OpenAPI 3.1 specification generation for [`@cleverbrush/server`](../server). Con ### Schema boundaries -Requests use schema input views and responses use output views. A named schema -with different representations gets collision-checked `NameInput` and -`NameOutput` components; identical representations keep their original name. -AsyncAPI uses input for incoming messages and output for outgoing messages. - Use `schemaRef(NamedSchema).optional().nullable().describe(...)` to annotate a single use without cloning the named definition. Conflicting named definitions -still fail. See the [composition guide](../schema/BOUNDARIES.md), including the -separate adoption required for typed clients and editable form state. +still fail. OpenAPI and AsyncAPI retain one canonical component per named target. +See the [composition guide](../schema/BOUNDARIES.md). - **`generateOpenApiSpec()`** — converts `@cleverbrush/server` endpoint registrations into an OpenAPI 3.1 document. - **`generateAsyncApiSpec()`** — converts `@cleverbrush/server` WebSocket subscription registrations into an AsyncAPI 3.0 document. diff --git a/libs/server-openapi/src/boundaries.test.ts b/libs/server-openapi/src/boundaries.test.ts index 91421f28..28c0702d 100644 --- a/libs/server-openapi/src/boundaries.test.ts +++ b/libs/server-openapi/src/boundaries.test.ts @@ -1,8 +1,6 @@ import { array, - decode, lazy, - number, object, type SchemaBuilder, schemaRef, @@ -10,93 +8,117 @@ import { } from '@cleverbrush/schema'; import { endpoint } from '@cleverbrush/server'; import { describe, expect, it } from 'vitest'; +import { generateAsyncApiSpec } from './generateAsyncApiSpec.js'; import { generateOpenApiSpec } from './generateOpenApiSpec.js'; import { SchemaRegistry, walkSchemas } from './schemaRegistry.js'; -describe('schema boundaries in OpenAPI', () => { - it('uses input requests, output responses, and directional nested components', () => { - const size = decode(string(), number().isInteger(), Number).schemaName( - 'Size' - ); - const request = object({ size: schemaRef(size) }).schemaName( - 'Envelope' - ); +describe('named schema references in API documents', () => { + it('uses one canonical component for requests, responses and annotated references', () => { + const user = object({ name: string() }).schemaName('User'); + const history = object({ + current: schemaRef(user), + previous: schemaRef(user).nullable().optional().describe('Previous') + }); const contract = endpoint - .post('/items') - .body(request) - .responses({ 200: request }); + .post('/history') + .body(history) + .responses({ 200: history }); const spec = generateOpenApiSpec({ registrations: [ { endpoint: contract.introspect(), handler: () => {} } ], - info: { title: 'Boundaries', version: '1' } + info: { title: 'References', version: '1' } }) as any; - expect( - spec.paths['/items'].post.requestBody.content['application/json'] - .schema - ).toEqual({ $ref: '#/components/schemas/EnvelopeInput' }); - expect( - spec.paths['/items'].post.responses['200'].content[ + expect(Object.keys(spec.components.schemas)).toEqual(['User']); + const request = + spec.paths['/history'].post.requestBody.content['application/json'] + .schema; + const response = + spec.paths['/history'].post.responses['200'].content[ 'application/json' - ].schema - ).toEqual({ $ref: '#/components/schemas/EnvelopeOutput' }); - expect(spec.components.schemas.SizeInput).toEqual({ - allOf: [{ type: 'string' }] - }); - expect(spec.components.schemas.SizeOutput).toEqual({ - allOf: [{ type: 'integer' }] + ].schema; + expect(request).toEqual(response); + expect(request.required).toEqual(['current']); + expect(request.properties.current.allOf[0].$ref).toBe( + '#/components/schemas/User' + ); + expect(request.properties.previous.description).toBe('Previous'); + expect(spec.components.schemas.User).toMatchObject({ + type: 'object', + required: ['name'] }); - expect( - spec.components.schemas.EnvelopeInput.properties.size.allOf[0].$ref - ).toBe('#/components/schemas/SizeInput'); }); - it('registers one target with independently annotated references', () => { + it('preserves strict instance-based naming conflicts', () => { const user = object({ name: string() }).schemaName('User'); - const root = object({ - user, - previous: schemaRef(user).nullable().optional().describe('Previous') - }); const registry = new SchemaRegistry(); - walkSchemas(root, registry); - expect( - [...registry.directionalEntries()].map(([name]) => name) - ).toEqual(['User']); + walkSchemas( + object({ + current: user, + previous: schemaRef(user).nullable().optional() + }), + registry + ); + expect([...registry.entries()].map(([name]) => name)).toEqual(['User']); expect(() => walkSchemas(user.optional(), registry)).toThrow( /already registered/ ); + expect(() => + walkSchemas(string().schemaName('User'), registry) + ).toThrow(/already registered/); }); - it('rejects generated-name collisions regardless of registration order', () => { - const size = decode(string(), number(), Number).schemaName('Size'); - const collision = string().schemaName('SizeInput'); - for (const schemas of [ - [size, collision], - [collision, size] - ]) { - const registry = new SchemaRegistry(); - for (const schema of schemas) walkSchemas(schema, registry); - expect(() => [...registry.directionalEntries()]).toThrow( - /SizeInput/ - ); - } - }); - - it('terminates on named recursive references', () => { + it('terminates on named recursive references in OpenAPI and AsyncAPI', () => { type Node = { name: string; children: Node[] }; const node: SchemaBuilder = object({ name: string(), children: array(lazy(() => schemaRef(node))) }).schemaName('Node'); const contract = endpoint.get('/nodes').responses({ 200: node }); - const spec = generateOpenApiSpec({ + const openapi = generateOpenApiSpec({ registrations: [ { endpoint: contract.introspect(), handler: () => {} } ], info: { title: 'Nodes', version: '1' } }) as any; - expect( - spec.components.schemas.Node.properties.children.items.allOf[0].$ref - ).toBe('#/components/schemas/Node'); + const asyncapi = generateAsyncApiSpec({ + subscriptions: [ + { + endpoint: { + protocol: 'subscription', + basePath: '/ws', + pathTemplate: '/nodes', + incomingSchema: schemaRef(node), + outgoingSchema: schemaRef(node), + querySchema: null, + headerSchema: null, + serviceSchemas: null, + authRoles: null, + summary: null, + description: null, + tags: [], + operationId: null, + deprecated: false, + externalDocs: null + }, + handler: async function* () {} + } + ], + info: { title: 'Nodes', version: '1' } + }) as any; + for (const spec of [openapi, asyncapi]) { + expect(Object.keys(spec.components.schemas)).toEqual(['Node']); + expect( + spec.components.schemas.Node.properties.children.items.allOf[0] + .$ref + ).toBe('#/components/schemas/Node'); + } + const channels = Object.values(asyncapi.channels) as any[]; + expect(channels).toHaveLength(1); + expect(channels[0].address).toBe('/ws/nodes'); + const messages = channels[0].messages; + expect(messages.ClientMessage.payload).toEqual( + messages.ServerEvent.payload + ); }); }); diff --git a/libs/server-openapi/src/generateAsyncApiSpec.ts b/libs/server-openapi/src/generateAsyncApiSpec.ts index cdbf5fd1..9bf6c091 100644 --- a/libs/server-openapi/src/generateAsyncApiSpec.ts +++ b/libs/server-openapi/src/generateAsyncApiSpec.ts @@ -256,7 +256,7 @@ export function generateAsyncApiSpec( messages['ClientMessage'] = { name: 'ClientMessage', ...(inInfo.description ? { summary: inInfo.description } : {}), - payload: convertSchema(m.incomingSchema, registry, 'input') + payload: convertSchema(m.incomingSchema, registry) }; } @@ -325,19 +325,15 @@ export function generateAsyncApiSpec( if (!registry.isEmpty) { const schemas: Record = {}; - for (const [name, schema, mode] of registry.directionalEntries()) { + for (const [name, schema] of registry.entries()) { let rootInlined = false; - schemas[name] = convertSchema( - schema, - candidate => { - if (candidate === schema && !rootInlined) { - rootInlined = true; - return null; - } - return registry.getName(candidate, mode); - }, - mode - ); + schemas[name] = convertSchema(schema, candidate => { + if (candidate === schema && !rootInlined) { + rootInlined = true; + return null; + } + return registry.getName(candidate); + }); } doc.components = { schemas }; } diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts index 352722cb..f566eb3e 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.ts @@ -161,16 +161,6 @@ function buildParameterObject( return param; } -/** Determines omission on the request side without executing conversions. */ -function isInputRequired(schema: SchemaBuilder): boolean { - const info = schema.introspect() as any; - if (info.hasDefault) return false; - if (info.type === 'reference' || info.type === 'decode') { - return info.presence ?? isInputRequired(info.inputSchema); - } - return info.isRequired !== false; -} - function buildRequestBody( bodySchema: SchemaBuilder, registry: SchemaRegistry, @@ -183,18 +173,18 @@ function buildRequestBody( ): Record { const bodyInfo = bodySchema.introspect() as any; const body: Record = { - required: isInputRequired(bodySchema) + required: bodyInfo.isRequired !== false }; // When file uploads are enabled, emit multipart/form-data if (fileUpload) { - const jsonSchema = convertSchema(bodySchema, registry, 'input'); + const jsonSchema = convertSchema(bodySchema, registry); const mediaType: Record = { schema: jsonSchema }; body['content'] = { 'multipart/form-data': mediaType }; } else { - const jsonSchema = convertSchema(bodySchema, registry, 'input'); + const jsonSchema = convertSchema(bodySchema, registry); const mediaType: Record = { schema: jsonSchema }; if (example != null) { mediaType['example'] = example; @@ -491,7 +481,7 @@ function buildOperation( > = queryInfo.properties ?? {}; for (const [name, propSchema] of Object.entries(props)) { const propInfo = propSchema.introspect() as any; - const isRequired = isInputRequired(propSchema); + const isRequired = propInfo.isRequired !== false; const description = typeof propInfo.description === 'string' && propInfo.description !== '' @@ -501,7 +491,7 @@ function buildOperation( buildParameterObject( name, 'query', - convertSchema(propSchema, registry, 'input'), + convertSchema(propSchema, registry), isRequired, description ) @@ -518,7 +508,7 @@ function buildOperation( > = headerInfo.properties ?? {}; for (const [name, propSchema] of Object.entries(props)) { const propInfo = propSchema.introspect() as any; - const isRequired = isInputRequired(propSchema); + const isRequired = propInfo.isRequired !== false; const description = typeof propInfo.description === 'string' && propInfo.description !== '' @@ -528,7 +518,7 @@ function buildOperation( buildParameterObject( name, 'header', - convertSchema(propSchema, registry, 'input'), + convertSchema(propSchema, registry), isRequired, description ) @@ -677,13 +667,6 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { if (meta.bodySchema) walkSchemas(meta.bodySchema, registry, visited); if (meta.responseSchema) walkSchemas(meta.responseSchema, registry, visited); - if (meta.responseHeaderSchema) - walkSchemas(meta.responseHeaderSchema, registry, visited); - if (meta.produces) { - for (const entry of Object.values(meta.produces)) { - if (entry.schema) walkSchemas(entry.schema, registry, visited); - } - } if (meta.responsesSchemas) { for (const schema of Object.values(meta.responsesSchemas)) { if (schema) walkSchemas(schema, registry, visited); @@ -724,8 +707,7 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { } const resolveComponentSchemaName = ( - rootSchema: SchemaBuilder, - mode: 'input' | 'output' + rootSchema: SchemaBuilder ) => { // The root schema must be inlined exactly once — for the component // definition itself. Any subsequent encounter (e.g. through a lazy @@ -739,7 +721,7 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { inlinedRoot = true; return undefined; // inline the root definition once } - return registry.getName(candidate, mode) ?? undefined; + return registry.getName(candidate) ?? undefined; }; }; @@ -831,14 +813,13 @@ export function generateOpenApiSpec(options: OpenApiOptions): OpenApiDocument { // Components — security schemes + named component schemas const componentSchemas: Record = {}; - for (const [name, schema, mode] of registry.directionalEntries()) { + for (const [name, schema] of registry.entries()) { // Inline the root schema to avoid a self-referential $ref, but resolve // nested named schemas through the shared registry so component // definitions can still deduplicate via $ref. componentSchemas[name] = convertSchema( schema, - resolveComponentSchemaName(schema, mode), - mode + resolveComponentSchemaName(schema) ); } const hasSchemas = Object.keys(componentSchemas).length > 0; diff --git a/libs/server-openapi/src/schemaConverter.ts b/libs/server-openapi/src/schemaConverter.ts index 817c0219..c0e83dfc 100644 --- a/libs/server-openapi/src/schemaConverter.ts +++ b/libs/server-openapi/src/schemaConverter.ts @@ -18,12 +18,10 @@ type NameResolver = ( * * @param schema - The schema to convert, or `null`/`undefined`. * @param registry - Optional registry or resolver function for `$ref` deduplication. - * @param mode - Input before decoding/defaults, or validated output (default). */ export function convertSchema( schema: SchemaBuilder | null | undefined, - registry?: SchemaRegistry | NameResolver, - mode: 'input' | 'output' = 'output' + registry?: SchemaRegistry | NameResolver ): Record { if (schema == null) return {}; @@ -34,14 +32,13 @@ export function convertSchema( if (typeof registry === 'function') { nameResolver = s => registry(s) ?? null; } else { - nameResolver = s => registry.getName(s, mode); + nameResolver = s => registry.getName(s); } } return toJsonSchema(schema, { $schema: false, draft: '2020-12', - nameResolver, - mode + nameResolver }); } diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts index b934a656..f2610a17 100644 --- a/libs/server-openapi/src/schemaRegistry.ts +++ b/libs/server-openapi/src/schemaRegistry.ts @@ -1,5 +1,4 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; -import { toJsonSchema } from '@cleverbrush/schema-json'; // --------------------------------------------------------------------------- // SchemaRegistry @@ -31,9 +30,6 @@ export class SchemaRegistry { >(); /** name → first-registered schema instance */ private readonly byName = new Map>(); - private directions: - | Map, { input: string; output: string }> - | undefined; /** * Attempts to register `schema` in the registry. @@ -68,7 +64,6 @@ export class SchemaRegistry { this.byInstance.set(schema, name); this.byName.set(name, schema); - this.directions = undefined; } /** @@ -78,93 +73,8 @@ export class SchemaRegistry { * @param schema - The schema builder to look up. * @returns The registered name, or `null`. */ - getName( - schema: SchemaBuilder, - mode: 'input' | 'output' = 'output' - ): string | null { - if (!this.byInstance.has(schema)) return null; - this.prepareDirections(); - return this.directions!.get(schema)![mode]; - } - - /** - * Emits independent input/output definitions only when their declared - * representations differ. Derived names are collision checked. - * @throws Error when a generated component name is already reserved. - */ - *directionalEntries(): IterableIterator< - [string, SchemaBuilder, 'input' | 'output'] - > { - this.prepareDirections(); - for (const [schema, names] of this.directions!) { - if (names.input !== names.output) - yield [names.input, schema, 'input']; - yield [names.output, schema, 'output']; - } - } - - private prepareDirections(): void { - if (this.directions) return; - const different = new Set>(); - // A changed named child changes its parents' references. Iterate to a - // fixed point; named recursive edges are always references, not recursion. - let changed = true; - while (changed) { - changed = false; - for (const [, schema] of this.byName) { - if (different.has(schema)) continue; - const render = (mode: 'input' | 'output') => { - let rootInlined = false; - return JSON.stringify( - toJsonSchema(schema, { - $schema: false, - mode, - nameResolver: candidate => { - if (candidate === schema && !rootInlined) { - rootInlined = true; - return null; - } - const name = this.byInstance.get(candidate); - return name - ? name + - (different.has(candidate) - ? mode === 'input' - ? 'Input' - : 'Output' - : '') - : null; - } - }) - ); - }; - if (render('input') !== render('output')) { - different.add(schema); - changed = true; - } - } - } - const names = new Map>( - this.byName - ); - const directions = new Map< - SchemaBuilder, - { input: string; output: string } - >(); - for (const [name, schema] of this.byName) { - const pair = different.has(schema) - ? { input: name + 'Input', output: name + 'Output' } - : { input: name, output: name }; - for (const derived of new Set(Object.values(pair))) { - if (names.has(derived) && names.get(derived) !== schema) { - throw new Error( - `Schema component name "${derived}" conflicts with a generated input/output name.` - ); - } - names.set(derived, schema); - } - directions.set(schema, pair); - } - this.directions = directions; + getName(schema: SchemaBuilder): string | null { + return this.byInstance.get(schema) ?? null; } /** @@ -217,9 +127,7 @@ export function walkSchemas( switch (info.type) { case 'reference': - case 'decode': - walkSchemas(info.inputSchema, registry, visited); - walkSchemas(info.outputSchema, registry, visited); + walkSchemas(info.targetSchema, registry, visited); break; case 'intersection': walkSchemas(info.left, registry, visited); diff --git a/websites/docs/app/server-openapi/page.tsx b/websites/docs/app/server-openapi/page.tsx index 0d96da94..784a18fb 100644 --- a/websites/docs/app/server-openapi/page.tsx +++ b/websites/docs/app/server-openapi/page.tsx @@ -21,13 +21,13 @@ export default function ServerOpenApiPage() {
-

Named references and input/output views

+

Named references and local annotations

Use schemaRef to annotate one use of a named definition - without cloning it. Requests use input views and - responses use output views; differing named - representations receive collision-checked Input and - Output suffixes. + without cloning it. Optionality, nullability and + descriptions remain local, while the target has one + canonical component. Conflicting named definitions still + fail registration.

Schema boundary composition and compatibility diff --git a/websites/schema/app/docs/sections/schema-boundaries.tsx b/websites/schema/app/docs/sections/schema-boundaries.tsx index 5a3f8235..8d94821d 100644 --- a/websites/schema/app/docs/sections/schema-boundaries.tsx +++ b/websites/schema/app/docs/sections/schema-boundaries.tsx @@ -4,8 +4,8 @@ export default function SchemaBoundariesSection() {

Schemas Across Boundaries

- Compose named references and explicit input-to-output - decoding without duplicating validation rules. + Compose optional fallbacks and named references without + duplicating validation rules.

@@ -38,45 +38,13 @@ const History = object({

-

Declared input and output

-
-                    {`const PageSize = decode(
-    string(),
-    number().isInteger().min(1).max(100),
-    value => Number(value)
-);
-type Editable = InferInput; // string
-type Validated = InferOutput; // number
-PageSize.parse('20'); // 20`}
-                
-

- The input is validated before conversion; the converted - result is validated against the output schema. Converter - errors become validation failures. Use parseAsync for - asynchronous converters. InferType remains an output alias. -

-

- Nested schemas and Standard Schema retain both types. - Defaults on the input schema run before conversion; a - default on the boundary is an output default. -

-
-
-

Export and adoption

-
-                    {`toJsonSchema(PageSize, { mode: 'input' });
-toJsonSchema(PageSize, { mode: 'output' }); // default`}
-                
-

- OpenAPI uses input for requests and output for responses, - splitting named components when their shapes differ. Export - describes declared shapes, not arbitrary conversion logic. -

+

API documents

- Form state and typed-client request inference are separate - integrations. Until adopted there, keep explicit wire - contracts and decode at your application boundary. No - reverse conversion is inferred. + JSON Schema, OpenAPI and AsyncAPI preserve one canonical + named definition with local reference annotations. + Independent definitions with the same name still fail + registration. Existing inference and static type overrides + are unchanged.

Read the full composition guide diff --git a/websites/schema/app/schema-json/page.tsx b/websites/schema/app/schema-json/page.tsx index 23926606..1b936ffc 100644 --- a/websites/schema/app/schema-json/page.tsx +++ b/websites/schema/app/schema-json/page.tsx @@ -33,16 +33,15 @@ export default function SchemaJsonPage() { /> {/* ── Installation ─────────────────────────────────── */} From cbc2f14acf44f0dc47821a14cd9ffc30694fbe0f Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 29 Sep 2026 09:44:26 +0000 Subject: [PATCH 4/5] refactor(schema): make named references automatic --- .changeset/schema-boundaries.md | 7 +- libs/schema-json/README.md | 6 +- libs/schema-json/src/boundaries.test.ts | 85 +++- libs/schema-json/src/toJsonSchema.ts | 37 +- libs/schema/BOUNDARIES.md | 57 ++- libs/schema/README.md | 7 +- libs/schema/package.json | 3 +- libs/schema/src/boundaries-docs.test.ts | 64 ++- libs/schema/src/builders/AnySchemaBuilder.ts | 18 +- .../schema/src/builders/ArraySchemaBuilder.ts | 30 +- .../src/builders/BooleanSchemaBuilder.ts | 22 +- libs/schema/src/builders/DateSchemaBuilder.ts | 46 ++- .../src/builders/ExternSchemaBuilder.ts | 18 +- .../src/builders/FunctionSchemaBuilder.ts | 22 +- .../src/builders/GenericSchemaBuilder.ts | 18 +- .../src/builders/IntersectionSchemaBuilder.ts | 18 +- libs/schema/src/builders/LazySchemaBuilder.ts | 18 +- libs/schema/src/builders/NullSchemaBuilder.ts | 18 +- .../src/builders/NumberSchemaBuilder.ts | 44 +- .../src/builders/ObjectSchemaBuilder.ts | 73 ++-- .../src/builders/ParseStringSchemaBuilder.ts | 18 +- .../src/builders/PromiseSchemaBuilder.ts | 20 +- .../src/builders/RecordSchemaBuilder.ts | 18 +- .../builders/ReferenceSchemaBuilder.test.ts | 149 ------- .../src/builders/ReferenceSchemaBuilder.ts | 387 ------------------ libs/schema/src/builders/SchemaBuilder.ts | 314 ++++++++------ .../src/builders/StringSchemaBuilder.ts | 42 +- .../schema/src/builders/TupleSchemaBuilder.ts | 22 +- .../schema/src/builders/UnionSchemaBuilder.ts | 24 +- libs/schema/src/builders/namedSchemas.test.ts | 325 +++++++++++++++ libs/schema/src/builders/references.test-d.ts | 16 +- libs/schema/src/core.ts | 4 - libs/server-openapi/README.md | 8 +- libs/server-openapi/src/boundaries.test.ts | 68 ++- libs/server-openapi/src/schemaRegistry.ts | 20 +- websites/docs/app/server-openapi/page.tsx | 11 +- .../app/docs/sections/schema-boundaries.tsx | 13 +- websites/schema/app/schema-json/page.tsx | 10 +- 38 files changed, 1099 insertions(+), 981 deletions(-) delete mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.test.ts delete mode 100644 libs/schema/src/builders/ReferenceSchemaBuilder.ts create mode 100644 libs/schema/src/builders/namedSchemas.test.ts diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md index 4694b904..c4c0d255 100644 --- a/.changeset/schema-boundaries.md +++ b/.changeset/schema-boundaries.md @@ -4,7 +4,10 @@ "@cleverbrush/server-openapi": minor --- -Add optional-aware fallbacks and preprocessing, and immutable named schema -references with local annotations. Preserve one canonical definition in JSON +Add optional-aware fallbacks and preprocessing, and automatic named schema +references through ordinary immutable use-site modifiers, without a wrapper API. +Shape, validation-rule, default, fallback and extension changes clear inherited +names; apply schemaName after those edits to establish a new named definition. +Preserve one canonical definition in JSON Schema, OpenAPI and AsyncAPI with strict name collision checks. Keep existing type inference and legacy optional null acceptance unchanged. diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index e6d64655..d6c137ff 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -14,8 +14,10 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer. ### Named references -Named reference wrappers preserve use-site annotations, optionality and nullability -without changing the shared definition. See the [composition guide](../schema/BOUNDARIES.md). +Ordinary modifiers such as `User.optional().nullable().describe('Previous')` +preserve a named definition automatically. Shape and validation-rule changes +become unnamed derivatives; apply `schemaName` last to name a new definition. +See the [composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md). - **Consuming external APIs** — you have a JSON Schema from an OpenAPI spec or a third-party service and want to validate incoming data with full TypeScript diff --git a/libs/schema-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts index ea57988e..c79286aa 100644 --- a/libs/schema-json/src/boundaries.test.ts +++ b/libs/schema-json/src/boundaries.test.ts @@ -1,4 +1,4 @@ -import { object, schemaRef, string } from '@cleverbrush/schema'; +import { object, string } from '@cleverbrush/schema'; import { describe, expect, it } from 'vitest'; import { withStandardJsonSchema } from './standardJsonSchema.js'; import { toJsonSchema } from './toJsonSchema.js'; @@ -10,8 +10,8 @@ describe('named reference JSON Schema', () => { ] as const)('keeps annotations outside the definition in draft %s', draft => { const user = object({ name: string() }).schemaName('User'); const schema = object({ - user: schemaRef(user), - previous: schemaRef(user) + user: user, + previous: user .nullable() .optional() .describe('Previous user') @@ -24,7 +24,7 @@ describe('named reference JSON Schema', () => { }) as any; expect(json.required).toEqual(['user']); expect(json.properties.user).toEqual({ - allOf: [{ $ref: '#/components/schemas/User' }] + $ref: '#/components/schemas/User' }); expect(json.properties.previous).toMatchObject({ description: 'Previous user', @@ -39,28 +39,85 @@ describe('named reference JSON Schema', () => { it('keeps final local default and nullability modifiers', () => { const target = string().nullable().schemaName('Name'); - const ref = schemaRef(target).notNullable().optional().default('new'); + const ref = target.notNullable().optional().default('new'); const schema = object({ name: ref }); const json = toJsonSchema(schema) as any; expect(schema.parse({})).toEqual({ name: 'new' }); - expect(json.required).toEqual(['name']); + expect(json.required).toBeUndefined(); expect(json.properties.name.default).toBe('new'); - expect(json.properties.name.allOf).toEqual([ - { type: ['string', 'null'] }, - { not: { type: 'null' } } - ]); + expect(json.properties.name.type).toBe('string'); + expect(json.properties.name.allOf).toBeUndefined(); const nullableAgain = toJsonSchema(ref.nullable()); - expect(nullableAgain.anyOf).toBeDefined(); + expect(nullableAgain.type).toEqual(['string', 'null']); expect(toJsonSchema(ref.readonly()).readOnly).toBe(true); }); it('retains identical Standard JSON Schema views', () => { - const ref = schemaRef(string().schemaName('Name')) - .optional() - .describe('A name'); + const ref = string().schemaName('Name').optional().describe('A name'); const standard = withStandardJsonSchema(ref)['~standard'].jsonSchema; expect(standard.input({ target: 'draft-2020-12' })).toEqual( standard.output({ target: 'draft-2020-12' }) ); }); + + it.each([ + '2020-12', + '07' + ] as const)('retains local modifiers before name resolution in draft %s', draft => { + const target = string() + .nullable() + .describe('Canonical') + .schemaName('Name'); + const local = target + .notNullable() + .describe('Use') + .example('Ada') + .readonly(); + const nameResolver = (s: typeof target) => + s.introspect().schemaName ?? null; + expect( + toJsonSchema(local, { draft, $schema: false, nameResolver }) + ).toEqual({ + allOf: [ + { $ref: '#/components/schemas/Name' }, + { not: { type: 'null' } } + ], + description: 'Use', + examples: ['Ada'], + readOnly: true + }); + expect(toJsonSchema(local, { draft, $schema: false }).allOf).toEqual([ + { type: ['string', 'null'], description: 'Canonical' }, + { not: { type: 'null' } } + ]); + expect( + toJsonSchema(local.nullable(), { + draft, + $schema: false, + nameResolver + }).allOf + ).toEqual([{ $ref: '#/components/schemas/Name' }]); + expect(target.introspect().description).toBe('Canonical'); + }); + + it('exports shape/rule derivatives inline while keeping nested named children', () => { + const name = string().schemaName('Name'); + const user = object({ name }).schemaName('User'); + const partial = user.partial().describe('Patch'); + const json = toJsonSchema(partial, { + $schema: false, + nameResolver: s => s.introspect().schemaName ?? null + }) as any; + expect(json.type).toBe('object'); + expect(json.required).toBeUndefined(); + expect(json.properties.name.allOf).toEqual([ + { $ref: '#/components/schemas/Name' } + ]); + expect( + toJsonSchema(name.maxLength(3), { + $schema: false, + nameResolver: s => s.introspect().schemaName ?? null + }) + ).toEqual({ type: 'string', maxLength: 3 }); + }); }); diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index b5bbccaf..82336ba6 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -24,15 +24,6 @@ function convertNodeInner( const readOnly: Out = info.isReadonly === true ? { readOnly: true } : {}; switch (info.type) { - case 'reference': { - const out: Out = { - ...readOnly, - allOf: [convertNode(info.targetSchema, resolver)] - }; - if (info.nullability === false) - (out.allOf as Out[]).push({ not: { type: 'null' } }); - return out; - } case 'string': { if (info.equalsTo !== undefined) return { ...readOnly, const: info.equalsTo }; @@ -251,6 +242,22 @@ function convertNode( schema: SchemaBuilder, resolver: Resolver ): Out { + const info = schema.introspect(); + if (info.referenceTarget) { + const target = info.referenceTarget; + const canonical = target.introspect(); + // Handle aliases before name lookup so use-site modifiers survive. + let out: Out = { allOf: [convertNode(target, resolver)] }; + if (info.isNullable && !canonical.isNullable) { + out = { anyOf: [out, { type: 'null' }] }; + } else if (!info.isNullable && canonical.isNullable) { + (out.allOf as Out[]).push({ not: { type: 'null' } }); + } + if (info.description !== undefined) out.description = info.description; + if (info.example !== undefined) out.examples = [info.example]; + if (info.isReadonly) out.readOnly = true; + return out; + } if (resolver) { const name = resolver(schema); if (typeof name === 'string' && name.length > 0) { @@ -260,7 +267,6 @@ function convertNode( } } const out = convertNodeInner(schema, resolver); - const info = schema.introspect() as any; if (typeof info.description === 'string' && info.description !== '') out['description'] = info.description; @@ -279,11 +285,7 @@ function convertNode( } // Handle nullable — JSON Schema 2020-12 style: type becomes an array - if ( - info.type === 'reference' - ? info.nullability === true - : info.isNullable === true - ) { + if (info.isNullable === true) { if (out['anyOf'] !== undefined) { // Union type — add { type: 'null' } to anyOf if not already present const anyOf = out['anyOf'] as Out[]; @@ -291,10 +293,7 @@ function convertNode( if (!hasNull) anyOf.push({ type: 'null' }); } else if (out['allOf'] !== undefined && out['type'] === undefined) { // Intersection type without a top-level type — wrap in oneOf with null - out[info.type === 'reference' ? 'anyOf' : 'oneOf'] = [ - { allOf: out['allOf'] as Out[] }, - { type: 'null' } - ]; + out['oneOf'] = [{ allOf: out['allOf'] as Out[] }, { type: 'null' }]; delete out['allOf']; } else if (out['enum'] !== undefined) { // Enum — add null to enum values if not already present diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md index 0fe12617..14695a44 100644 --- a/libs/schema/BOUNDARIES.md +++ b/libs/schema/BOUNDARIES.md @@ -1,6 +1,6 @@ # Schemas across boundaries -These APIs are additive. `InferType` continues to mean validated output; +`InferType` continues to mean validated output; existing builder generic arguments and legacy preprocessors keep their meaning. ## Optional fallbacks @@ -46,31 +46,62 @@ Before, cloning `User.schemaName('User')` with `.optional()` created another named instance and conflicted during OpenAPI generation. ```ts -import { number, object, schemaRef, string } from '@cleverbrush/schema'; +import { number, object, string } from '@cleverbrush/schema'; const User = object({ id: number(), name: string() }).schemaName('User'); const History = object({ - current: schemaRef(User), - previous: schemaRef(User).nullable().optional() + current: User, + previous: User.nullable().optional() .describe('The previous user, when known.') }); ``` -`schemaRef` requires a named target. Its local modifiers do not rename, clone, -or mutate that target. References delegate validation and preserve nested -property selectors/errors. Independent schemas sharing a name still conflict; -there is no name-only deduplication. Use-site optionality controls omission; -nullability controls null acceptance for the wrapper independently. +No wrapper is needed. Ordinary immutable modifiers keep the concrete builder, +fluent methods, extension methods, inference, and nested property selectors. +They validate normally; canonical-reference metadata only affects exporters. +The original definition is never mutated. Reuse the plain constant when there +are no local modifiers. Independent definitions sharing a name still conflict; +there is no name-based or structural deduplication. + +### Which changes preserve the named definition? + +- Use-site presence/nullability: `optional`, `required`, `nullable`, `notNullable`. +- Annotations: `describe`, `example`, `readonly`. +- Type-only changes: `brand`, `hasType`, `clearHasType`, `optimize`. + +Chains of these modifiers always reference the original canonical definition, +not another alias. Calling `schemaName` explicitly creates a new independent +definition, even when the previous name is reused. + +### Shape and rule changes become unnamed + +Property additions/removals, `partial`, `pick`, `omit`, constraints, validators, +preprocessors, defaults, fallbacks, and their clear methods discard the inherited +name and canonical association. Extension changes detach conservatively, too. +These derivatives cannot safely claim to be the original definition. Later +annotations or optionality do not reconnect them. + +```ts +const PartialUser = User.partial(); // unnamed, all properties optional +const UserWithEmail = User.addProp('email', string()); // unnamed, new shape +const PublicUser = User.omit('id').schemaName('PublicUser'); // new definition +const ShortName = string().schemaName('Name').maxLength(20); // unnamed rule change +``` + +Existing nested named schemas still reuse their own definitions. To retain a +stable component name after a shape or rule change, call `schemaName` **last**. +This intentionally changes inherited-name behavior; review code that relied on +constraint/property modifications retaining the old component name. JSON Schema/OpenAPI keeps one definition and uses reference composition for local annotations, examples and nullability, including Draft 07 references. ## Defaults and validation -A default on the reference overrides missing values at that use site and is -validated by the target. Clearing it leaves any target default intact. Local -fallbacks use the existing catch semantics. Async target validators and local -callbacks require `parseAsync`/`validateAsync`. +Defaults and fallbacks retain ordinary builder behavior, not delegated wrapper +behavior. Adding or clearing either detaches the name. Clearing a default removes +it completely; it does not reveal a hidden canonical default. Async validators +and preprocessors still require `parseAsync`/`validateAsync`. Use `InferType` for schema inference and the existing `hasType` method when an explicit static override is needed. Static overrides and casts do not change diff --git a/libs/schema/README.md b/libs/schema/README.md index d602cdab..937bf4f4 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -2,9 +2,10 @@ ## Schemas across boundaries -Use optional-aware `catch(undefined)` and `schemaRef(namedSchema)` for per-use -annotations without cloning named definitions. See the -[composition guide](./BOUNDARIES.md) for examples and null-compatibility limits. +Use optional-aware `catch(undefined)` and ordinary named-schema modifiers for per-use +annotations while preserving canonical named definitions. See the +[composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md) +for examples, naming rules and null-compatibility limits. [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml) [![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/) diff --git a/libs/schema/package.json b/libs/schema/package.json index dce29b43..c89f8fcc 100644 --- a/libs/schema/package.json +++ b/libs/schema/package.json @@ -6,8 +6,7 @@ }, "description": "Schema Definition And Validation library, that allows to define and validate objects of any complexity", "files": [ - "dist", - "BOUNDARIES.md" + "dist" ], "homepage": "https://schema.cleverbrush.com/docs/getting-started", "keywords": [ diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts index d023d40f..25eeffc0 100644 --- a/libs/schema/src/boundaries-docs.test.ts +++ b/libs/schema/src/boundaries-docs.test.ts @@ -3,12 +3,15 @@ import ts from 'typescript'; import { describe, expect, it } from 'vitest'; describe('published boundary documentation', () => { - it('preserves summaries for new APIs, types and public boundary members', () => { - const missing: string[] = []; + it('preserves declaration summaries for boundary types and methods', () => { + const hasSummary = (node: ts.Node) => + (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc?.some( + doc => doc.comment + ); for (const [path, names] of [ [ - '../dist/builders/ReferenceSchemaBuilder.d.ts', - ['ReferenceSchemaBuilder', 'schemaRef'] + '../dist/builders/SchemaBuilder.d.ts', + ['SchemaBuilder', 'SchemaBuilderProps'] ], ['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']] ] as const) { @@ -19,40 +22,31 @@ describe('published boundary documentation', () => { ts.ScriptTarget.Latest, true ); - const hasSummary = (node: ts.Node) => - (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc?.some( - doc => doc.comment + for (const name of names) { + const node = source.statements.find( + node => + 'name' in node && + (node.name as ts.Identifier)?.text === name ); - for (const node of source.statements) { - const name = - 'name' in node && node.name - ? (node.name as ts.Identifier).text - : ''; - if (!(names as readonly string[]).includes(name)) continue; - if (!hasSummary(node)) missing.push(name); - if (ts.isClassDeclaration(node)) - for (const member of node.members) { - if ( - ts.getCombinedModifierFlags(member) & - (ts.ModifierFlags.Private | - ts.ModifierFlags.Protected) - ) - continue; - if (member.name && ts.isPrivateIdentifier(member.name)) - continue; - if ( - ts - .getJSDocTags(member) - .some(tag => tag.tagName.text === 'internal') - ) - continue; - if (!hasSummary(member)) - missing.push( - name + '.' + member.name?.getText(source) - ); + expect(node, name).toBeDefined(); + expect(hasSummary(node!), name).toBe(true); + if (node && ts.isClassDeclaration(node)) { + for (const memberName of [ + 'schemaName', + 'introspect', + 'derive', + 'catch', + 'addPreprocessor' + ]) { + const member = node.members.find( + member => + member.name?.getText(source) === memberName + ); + expect(member, memberName).toBeDefined(); + expect(hasSummary(member!), memberName).toBe(true); } + } } } - expect(missing).toEqual([]); }); }); diff --git a/libs/schema/src/builders/AnySchemaBuilder.ts b/libs/schema/src/builders/AnySchemaBuilder.ts index 20f27099..451a85c1 100644 --- a/libs/schema/src/builders/AnySchemaBuilder.ts +++ b/libs/schema/src/builders/AnySchemaBuilder.ts @@ -62,9 +62,12 @@ export class AnySchemaBuilder< _notUsed?: T ): AnySchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -78,9 +81,12 @@ export class AnySchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #buildResult( diff --git a/libs/schema/src/builders/ArraySchemaBuilder.ts b/libs/schema/src/builders/ArraySchemaBuilder.ts index a044538b..03995a40 100644 --- a/libs/schema/src/builders/ArraySchemaBuilder.ts +++ b/libs/schema/src/builders/ArraySchemaBuilder.ts @@ -188,9 +188,12 @@ export class ArraySchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -205,9 +208,12 @@ export class ArraySchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #createValidationSetup( @@ -839,7 +845,7 @@ export class ArraySchemaBuilder< TExtensions > & TExtensions { - return ArraySchemaBuilder.create({ + return this.derive({ ...this.introspect(), elementSchema: schema } as any) as any; @@ -858,7 +864,7 @@ export class ArraySchemaBuilder< TExtensions > & TExtensions { - return ArraySchemaBuilder.create({ + return this.derive({ ...this.introspect(), elementSchema: undefined } as any) as any; @@ -891,7 +897,7 @@ export class ArraySchemaBuilder< TExtensions { if (typeof length !== 'number' || length < 0) throw new Error('length is expected to be a number which is >= 0'); - return ArraySchemaBuilder.create({ + return this.derive({ ...this.introspect(), minLength: length, minLengthValidationErrorMessageProvider: errorMessage @@ -912,7 +918,7 @@ export class ArraySchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.minLength; - return this.createFromProps({ + return this.derive({ ...schema } as any) as any; } @@ -944,7 +950,7 @@ export class ArraySchemaBuilder< TExtensions { if (typeof length !== 'number' || length < 0) throw new Error('length is expected to be a number which is >= 0'); - return ArraySchemaBuilder.create({ + return this.derive({ ...this.introspect(), maxLength: length, maxLengthValidationErrorMessageProvider: errorMessage @@ -965,7 +971,7 @@ export class ArraySchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.maxLength; - return this.createFromProps({ + return this.derive({ ...schema } as any) as any; } diff --git a/libs/schema/src/builders/BooleanSchemaBuilder.ts b/libs/schema/src/builders/BooleanSchemaBuilder.ts index 050fdf4b..2a3815f4 100644 --- a/libs/schema/src/builders/BooleanSchemaBuilder.ts +++ b/libs/schema/src/builders/BooleanSchemaBuilder.ts @@ -122,9 +122,12 @@ export class BooleanSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -139,9 +142,12 @@ export class BooleanSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #getConstraintViolation( @@ -458,7 +464,7 @@ export class BooleanSchemaBuilder< > ) { if (typeof value !== 'boolean') throw new Error('boolean expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: value, equalsToValidationErrorMessageProvider: errorMessage @@ -485,7 +491,7 @@ export class BooleanSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: undefined } as any) as any; diff --git a/libs/schema/src/builders/DateSchemaBuilder.ts b/libs/schema/src/builders/DateSchemaBuilder.ts index 17392297..161efd5d 100644 --- a/libs/schema/src/builders/DateSchemaBuilder.ts +++ b/libs/schema/src/builders/DateSchemaBuilder.ts @@ -309,9 +309,12 @@ export class DateSchemaBuilder< _notUsed?: T ): DateSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -325,9 +328,12 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #getConstraintViolation( @@ -582,7 +588,7 @@ export class DateSchemaBuilder< ): DateSchemaBuilder & TExtensions { if (!(value instanceof Date)) throw new Error('Date expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: value, equalsToValidationErrorMessageProvider: errorMessage @@ -607,7 +613,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: undefined }) as any; @@ -713,7 +719,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsInFuture: true, ensureIsInFutureValidationErrorMessageProvider: errorMessage @@ -731,7 +737,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsInFuture: false }) as any; @@ -755,7 +761,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsInPast: true, ensureIsInPastValidationErrorMessageProvider: errorMessage @@ -773,7 +779,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsInPast: false }) as any; @@ -800,7 +806,7 @@ export class DateSchemaBuilder< TExtensions { if (!(minValue instanceof Date)) throw new Error('minValue must be a Date'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), min: minValue, minValidationErrorMessageProvider: errorMessage @@ -820,7 +826,7 @@ export class DateSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.min; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -846,7 +852,7 @@ export class DateSchemaBuilder< TExtensions { if (!(maxValue instanceof Date)) throw new Error('maxValue must be a Date'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), max: maxValue, maxValidationErrorMessageProvider: errorMessage @@ -866,7 +872,7 @@ export class DateSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.max; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -920,7 +926,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), parseFromJson: true }) as any; @@ -937,7 +943,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), parseFromJson: false }) as any; @@ -955,7 +961,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), parseFromEpoch: true }) as any; @@ -972,7 +978,7 @@ export class DateSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), parseFromEpoch: false }) as any; diff --git a/libs/schema/src/builders/ExternSchemaBuilder.ts b/libs/schema/src/builders/ExternSchemaBuilder.ts index 6d4fb12d..65cad914 100644 --- a/libs/schema/src/builders/ExternSchemaBuilder.ts +++ b/libs/schema/src/builders/ExternSchemaBuilder.ts @@ -147,9 +147,12 @@ export class ExternSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -164,9 +167,12 @@ export class ExternSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/FunctionSchemaBuilder.ts b/libs/schema/src/builders/FunctionSchemaBuilder.ts index be53b16e..b8aa381b 100644 --- a/libs/schema/src/builders/FunctionSchemaBuilder.ts +++ b/libs/schema/src/builders/FunctionSchemaBuilder.ts @@ -129,9 +129,12 @@ export class FunctionSchemaBuilder< TReturnTypeSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -147,9 +150,12 @@ export class FunctionSchemaBuilder< TReturnTypeSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -436,7 +442,7 @@ export class FunctionSchemaBuilder< TReturnTypeSchema > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), parameters: [...this.#parameters, schema] } as any) as any; @@ -477,7 +483,7 @@ export class FunctionSchemaBuilder< TSchema > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), returnType: schema } as any) as any; diff --git a/libs/schema/src/builders/GenericSchemaBuilder.ts b/libs/schema/src/builders/GenericSchemaBuilder.ts index addf3d1a..54dd9566 100644 --- a/libs/schema/src/builders/GenericSchemaBuilder.ts +++ b/libs/schema/src/builders/GenericSchemaBuilder.ts @@ -361,9 +361,12 @@ export class GenericSchemaBuilder< _notUsed?: T ): GenericSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -378,9 +381,12 @@ export class GenericSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/IntersectionSchemaBuilder.ts b/libs/schema/src/builders/IntersectionSchemaBuilder.ts index 6d746c6a..3ba5465e 100644 --- a/libs/schema/src/builders/IntersectionSchemaBuilder.ts +++ b/libs/schema/src/builders/IntersectionSchemaBuilder.ts @@ -96,9 +96,12 @@ export class IntersectionSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -114,9 +117,12 @@ export class IntersectionSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/LazySchemaBuilder.ts b/libs/schema/src/builders/LazySchemaBuilder.ts index 12f0d7c5..0d154384 100644 --- a/libs/schema/src/builders/LazySchemaBuilder.ts +++ b/libs/schema/src/builders/LazySchemaBuilder.ts @@ -212,9 +212,12 @@ export class LazySchemaBuilder< _notUsed?: T ): LazySchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -228,9 +231,12 @@ export class LazySchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/NullSchemaBuilder.ts b/libs/schema/src/builders/NullSchemaBuilder.ts index 1ec973dc..d4c735cf 100644 --- a/libs/schema/src/builders/NullSchemaBuilder.ts +++ b/libs/schema/src/builders/NullSchemaBuilder.ts @@ -91,9 +91,12 @@ export class NullSchemaBuilder< _notUsed?: T ): NullSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -107,9 +110,12 @@ export class NullSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } // The SchemaBuilder base-class preValidateSync/preValidateAsync treats diff --git a/libs/schema/src/builders/NumberSchemaBuilder.ts b/libs/schema/src/builders/NumberSchemaBuilder.ts index 36e250e7..951dc148 100644 --- a/libs/schema/src/builders/NumberSchemaBuilder.ts +++ b/libs/schema/src/builders/NumberSchemaBuilder.ts @@ -285,9 +285,12 @@ export class NumberSchemaBuilder< _notUsed?: T ): NumberSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -301,9 +304,12 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #getConstraintViolation( @@ -557,7 +563,7 @@ export class NumberSchemaBuilder< > ) { if (typeof value !== 'number') throw new Error('number expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: value, equalsToValidationErrorMessageProvider: errorMessage @@ -582,7 +588,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: undefined }) as any; @@ -600,7 +606,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), isInteger: false, ensureIsIntegerErrorMessageProvider: @@ -619,7 +625,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), isInteger: false, ensureIsIntegerErrorMessageProvider: @@ -645,7 +651,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), isInteger: true, ensureIsIntegerErrorMessageProvider: errorMessage @@ -752,7 +758,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureNotNaN: true, ensureNotNaNErrorMessageProvider: errorMessage @@ -770,7 +776,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureNotNaN: false, ensureNotNaNErrorMessageProvider: @@ -796,7 +802,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsFinite: true, ensureIsFiniteErrorMessageProvider: errorMessage @@ -814,7 +820,7 @@ export class NumberSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), ensureIsFinite: false, ensureIsFiniteErrorMessageProvider: @@ -870,7 +876,7 @@ export class NumberSchemaBuilder< TExtensions { if (typeof minValue !== 'number') throw new Error('minValue must be a number'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), min: minValue, minValidationErrorMessageProvider: errorMessage @@ -890,7 +896,7 @@ export class NumberSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.min; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -916,7 +922,7 @@ export class NumberSchemaBuilder< TExtensions { if (typeof maxValue !== 'number') throw new Error('maxValue must be a number'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), max: maxValue, maxValidationErrorMessageProvider: errorMessage @@ -936,7 +942,7 @@ export class NumberSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.max; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } diff --git a/libs/schema/src/builders/ObjectSchemaBuilder.ts b/libs/schema/src/builders/ObjectSchemaBuilder.ts index 5563d7f0..72e8ffd7 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -1073,8 +1073,7 @@ export class ObjectSchemaBuilder< ObjectSchemaBuilder.#propagateNestedErrors( result as any, descriptor, - addErrorFor, - this.#properties[key] + addErrorFor ); // For extern schemas, also record errors on the extern // descriptor itself so getErrorsFor(t => t.extern) works, @@ -1575,7 +1574,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), acceptUnknownProps: true } as any) as any; @@ -1595,7 +1594,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), acceptUnknownProps: false } as any) as any; @@ -1616,9 +1615,12 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -1634,9 +1636,12 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -1722,7 +1727,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), constructorSchemas: [...this.#constructorSchemas, schema] } as any) as any; @@ -1768,7 +1773,7 @@ export class ObjectSchemaBuilder< [] > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), constructorSchemas: [] } as any) as any; @@ -1810,7 +1815,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: { ...this.introspect().properties, @@ -1845,9 +1850,12 @@ export class ObjectSchemaBuilder< THasDefault, TExtensions > { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -1918,7 +1926,7 @@ export class ObjectSchemaBuilder< newProps[key] = props[key] as any; } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProps } as any) as any; @@ -1997,7 +2005,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: (() => { const result = { ...this.#properties }; @@ -2037,7 +2045,7 @@ export class ObjectSchemaBuilder< delete props.properties[key]; } - return this.createFromProps(props as any); + return this.derive(props as any); } else if ( propNameOrArrayOrPropsOrBuilder instanceof ObjectSchemaBuilder ) { @@ -2055,7 +2063,7 @@ export class ObjectSchemaBuilder< } } - return this.createFromProps(props as any); + return this.derive(props as any); } throw new Error('this parameter type is not supported'); @@ -2109,7 +2117,7 @@ export class ObjectSchemaBuilder< {} as Record ); - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProps } as any) as any; @@ -2169,7 +2177,7 @@ export class ObjectSchemaBuilder< typeof propNameOrArray === 'undefined' || propNameOrArray === null ) { - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: Object.keys(this.#properties).reduce( (acc, key) => { @@ -2207,7 +2215,7 @@ export class ObjectSchemaBuilder< newProps.properties[key].optional(); }); - return this.createFromProps(newProps); + return this.derive(newProps); } if (typeof propNameOrArray === 'string') { @@ -2304,7 +2312,7 @@ export class ObjectSchemaBuilder< newProps[key] = prop.optional(); } } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProps } as any) as any; @@ -2381,7 +2389,7 @@ export class ObjectSchemaBuilder< throw new Error(`property ${property} does not exists`); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: { [property]: this.#properties[property] @@ -2407,7 +2415,7 @@ export class ObjectSchemaBuilder< return acc; }, {}); - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProperties } as any); @@ -2483,7 +2491,7 @@ export class ObjectSchemaBuilder< [propName]: callbackResult }; - return this.createFromProps(props) as any; + return this.derive(props) as any; } /** @@ -2544,7 +2552,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: Object.keys(this.#properties).reduce( (acc, curr) => { @@ -2570,7 +2578,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: Object.keys(this.#properties).reduce( (acc, curr) => { @@ -2760,11 +2768,9 @@ export class ObjectSchemaBuilder< descriptor: any, message: string, parentDescriptor?: any - ) => void, - schemaOverride?: SchemaBuilder + ) => void ): void { const schema = - schemaOverride ?? ObjectSchemaBuilder.#getSchemaForPropertyDescriptor(descriptor); const properties = (schema.introspect() as any).properties; @@ -2816,8 +2822,7 @@ export class ObjectSchemaBuilder< ObjectSchemaBuilder.#propagateNestedErrors( result, nestedPropertyDescriptor, - addErrorFor, - nestedSchema + addErrorFor ); } } diff --git a/libs/schema/src/builders/ParseStringSchemaBuilder.ts b/libs/schema/src/builders/ParseStringSchemaBuilder.ts index 650a5171..cf33b0e7 100644 --- a/libs/schema/src/builders/ParseStringSchemaBuilder.ts +++ b/libs/schema/src/builders/ParseStringSchemaBuilder.ts @@ -550,9 +550,12 @@ export class ParseStringSchemaBuilder< _notUsed?: T ): ParseStringSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -566,9 +569,12 @@ export class ParseStringSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/PromiseSchemaBuilder.ts b/libs/schema/src/builders/PromiseSchemaBuilder.ts index 6de3edef..b1c2f594 100644 --- a/libs/schema/src/builders/PromiseSchemaBuilder.ts +++ b/libs/schema/src/builders/PromiseSchemaBuilder.ts @@ -107,9 +107,12 @@ export class PromiseSchemaBuilder< TResolvedTypeSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -124,9 +127,12 @@ export class PromiseSchemaBuilder< TResolvedTypeSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -410,7 +416,7 @@ export class PromiseSchemaBuilder< TSchema > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), resolvedType: schema } as any) as any; diff --git a/libs/schema/src/builders/RecordSchemaBuilder.ts b/libs/schema/src/builders/RecordSchemaBuilder.ts index f9e2ab63..18c6ac3b 100644 --- a/libs/schema/src/builders/RecordSchemaBuilder.ts +++ b/libs/schema/src/builders/RecordSchemaBuilder.ts @@ -288,9 +288,12 @@ export class RecordSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -306,9 +309,12 @@ export class RecordSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts deleted file mode 100644 index 01ea34f4..00000000 --- a/libs/schema/src/builders/ReferenceSchemaBuilder.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { array, boolean, number, object, schemaRef, string } from '../index.js'; - -describe('optional fallbacks', () => { - it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => { - const text = string().optional().catch(undefined); - expect(text.parse(42)).toBeUndefined(); - expect(await text.parseAsync(false)).toBeUndefined(); - expect(text.parse(null)).toBeNull(); - expect(string().nullable().catch(null).parse(42)).toBeNull(); - const normalize = string() - .optional() - .addPreprocessor(v => (v == null ? undefined : v)) - .catch(undefined); - expect(normalize.parse(null)).toBeUndefined(); - expect(object({ text }).safeParse(null).valid).toBe(false); - expect( - object({ - text, - flag: boolean() - .optional() - .catch(() => undefined) - }).parse({ text: {}, flag: 1 }) - ).toEqual({ text: undefined, flag: undefined }); - }); - - it('supports async optional preprocessing without dropping array entries', async () => { - const fallback = vi.fn(() => undefined); - const text = string().optional().catch(fallback); - expect(array(text).parse(['ok', 42])).toEqual(['ok', undefined]); - expect(fallback).toHaveBeenCalledTimes(1); - const normalized = string() - .optional() - .addPreprocessor(async v => (v == null ? undefined : v)); - expect(await normalized.parseAsync(null)).toBeUndefined(); - expect(() => normalized.parse(null)).toThrow(/validateAsync/); - }); -}); - -describe('named schema references', () => { - it('preserves one target definition and independent local metadata', () => { - const user = object({ name: string().minLength(1) }).schemaName('User'); - const ref = schemaRef(user); - const previous = ref - .nullable() - .optional() - .describe('Previous user') - .example(null); - expect(ref.introspect().targetSchema).toBe(user); - expect(previous.introspect().targetSchema).toBe(user); - expect(user.introspect().description).toBeUndefined(); - expect(user.introspect().isRequired).toBe(true); - expect(ref.safeParse(null).valid).toBe(false); - expect(previous.parse(null)).toBeNull(); - expect(previous.parse(undefined)).toBeUndefined(); - expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' }); - expect(() => schemaRef(string())).toThrow(/named schema/); - expect(() => schemaRef(string().schemaName(' '))).toThrow( - /named schema/ - ); - }); - - it('preserves deeply nested reference errors and selectors', async () => { - const address = object({ city: string().minLength(1) }).schemaName( - 'Address' - ); - const user = object({ address: schemaRef(address) }).schemaName('User'); - const root = object({ user: schemaRef(user) }); - for (const result of [ - root.validate({ user: { address: { city: '' } } }), - await root.validateAsync({ user: { address: { city: '' } } }) - ]) { - expect(result.valid).toBe(false); - expect( - result.getErrorsFor(t => t.user.address.city).errors.length - ).toBeGreaterThan(0); - expect( - result - .getInvalidProperties() - .map(p => p.descriptor.toJsonPointer()) - ).toContain('/user/address/city'); - } - }); - - it('keeps target defaults, local defaults and fallbacks independent', async () => { - const target = number().default(2).schemaName('Size'); - const ref = schemaRef(target); - expect(ref.parse(undefined)).toBe(2); - expect(ref.optional().parse(undefined)).toBeUndefined(); - expect(ref.optional().default(4).parse(undefined)).toBe(4); - expect(ref.default(4).clearDefault().parse(undefined)).toBe(2); - expect(target.parse(undefined)).toBe(2); - const fallback = vi.fn(() => 8); - const caught = ref.catch(fallback); - expect(caught.parse('invalid')).toBe(8); - expect(await caught.parseAsync('invalid')).toBe(8); - expect(fallback).toHaveBeenCalledTimes(2); - expect(ref.optional().catch(undefined).parse({})).toBeUndefined(); - }); - - it('keeps local optionality, nullability and required error messages distinct', async () => { - const ref = schemaRef( - string().nullable().optional().schemaName('Text') - ); - expect(ref.parse(null)).toBeNull(); - expect(ref.notNullable().safeParse(null).valid).toBe(false); - expect(ref.notNullable().parse(undefined)).toBeUndefined(); - const required = ref.required('Provide text'); - expect(required.safeParse(undefined).errors?.[0].message).toBe( - 'Provide text' - ); - expect(required.parse(null)).toBeNull(); - const asyncRequired = ref.required(async () => 'Async required'); - expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/); - expect( - (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message - ).toBe('Async required'); - }); - - it('delegates async target validation and runs local callbacks', async () => { - const target = string() - .addPreprocessor(async v => v.trim()) - .schemaName('Trimmed'); - const ref = schemaRef(target); - expect(() => ref.parse(' text ')).toThrow(/validateAsync/); - expect(await ref.parseAsync(' text ')).toBe('text'); - expect(await ref['~standard'].validate(' text ')).toEqual({ - value: 'text' - }); - const local = schemaRef(number().schemaName('Count')) - .addPreprocessor(v => v + 1) - .addValidator(v => ({ valid: v < 5 })); - expect(local.parse(2)).toBe(3); - expect(local.safeParse(5).valid).toBe(false); - expect( - schemaRef(string().schemaName('OptionalText')) - .optional() - .addPreprocessor(() => undefined) - .parse('text') - ).toBeUndefined(); - }); - - it('retains existing hasType behavior without changing runtime validation', () => { - const ref = schemaRef(number().schemaName('Count')).hasType(); - expect(ref.parse(3)).toBe(3); - expect(ref.safeParse('three').valid).toBe(false); - expect(ref.clearHasType().parse(4)).toBe(4); - }); -}); diff --git a/libs/schema/src/builders/ReferenceSchemaBuilder.ts b/libs/schema/src/builders/ReferenceSchemaBuilder.ts deleted file mode 100644 index fa11afc2..00000000 --- a/libs/schema/src/builders/ReferenceSchemaBuilder.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { - type BRAND, - type InferType, - SchemaBuilder, - type SchemaBuilderProps, - SYMBOL_HAS_PROPERTIES, - type ValidationContext, - type ValidationErrorMessageProvider, - type ValidationResult -} from './SchemaBuilder.js'; - -type AnySchema = SchemaBuilder; -type ReferenceValue< - TSchema, - TRequired extends boolean, - TNullable extends boolean, - TExplicitType -> = - | (TRequired extends true - ? Exclude< - TExplicitType extends undefined - ? InferType - : TExplicitType, - undefined - > - : - | (TExplicitType extends undefined - ? InferType - : TExplicitType) - | undefined) - | (TNullable extends true ? null : never); -type ReferenceProps = Partial> & { - targetSchema: AnySchema; - presence?: boolean; - nullability?: boolean; -}; - -/** - * Immutable use-site wrapper around one named schema. Create it with schemaRef. - * Local modifiers never clone or mutate the target definition. - * @typeParam TSchema - Referenced schema. - * @typeParam TRequired - Whether a value is required at this use site. - * @typeParam TNullable - Whether null is allowed at this use site. - * @typeParam THasDefault - Whether the wrapper supplies a default. - * @typeParam TExplicitType - Optional static type override. - */ -export class ReferenceSchemaBuilder< - TSchema extends AnySchema, - TRequired extends boolean = true, - TNullable extends boolean = false, - THasDefault extends boolean = false, - TExplicitType = undefined -> extends SchemaBuilder< - ReferenceValue, - TRequired, - TNullable, - THasDefault -> { - readonly #reference: ReferenceProps; - /** @internal Enables nested property descriptors for referenced objects. */ - readonly [SYMBOL_HAS_PROPERTIES] = true; - - /** @internal Use schemaRef rather than constructing a reference directly. */ - constructor(props: ReferenceProps) { - super({ - preprocessors: [], - validators: [], - ...props, - type: 'reference' - }); - this.#reference = props; - } - - /** Describes the target and local modifiers without changing the target. */ - public introspect() { - return { - ...super.introspect(), - targetSchema: this.#reference.targetSchema as TSchema, - presence: this.#reference.presence, - nullability: this.#reference.nullability, - properties: (this.#reference.targetSchema.introspect() as any) - .properties - }; - } - - /** @internal Reconstructs an immutable wrapper around the same target. */ - protected createFromProps(props: ReferenceProps): this { - return new ReferenceSchemaBuilder(props) as this; - } - - /** Validates through the target and retains the inferred result type. */ - public validate( - value: unknown, - context?: ValidationContext - ): ValidationResult< - ReferenceValue - > { - return super.validate(value, context); - } - - /** Async validation with the same inference and fallback behavior. */ - public validateAsync( - value: unknown, - context?: ValidationContext - ): Promise< - ValidationResult< - ReferenceValue - > - > { - return super.validateAsync(value, context); - } - - #props(): ReferenceProps { - return { - ...this.#reference, - ...this.introspect(), - preprocessors: [...this.preprocessors], - validators: [...this.validators] - }; - } - - #early(value: unknown): ValidationResult | undefined { - if (value === undefined && this.hasDefault) return undefined; - if (value === undefined && this.#reference.presence !== undefined) { - return this.#reference.presence - ? { valid: false, errors: [{ message: 'is required' }] } - : { valid: true, object: undefined }; - } - if (value === null && this.#reference.nullability !== undefined) { - return this.#reference.nullability - ? { valid: true, object: null } - : { valid: false, errors: [{ message: 'must not be null' }] }; - } - return undefined; - } - - #checkNull(result: ValidationResult): ValidationResult { - if ( - result.valid && - result.object === null && - this.#reference.nullability === false - ) - return { valid: false, errors: [{ message: 'must not be null' }] }; - return result; - } - - /** @internal Delegates validation while applying local presence and validators. */ - protected _validate( - value: unknown, - context?: ValidationContext - ): ValidationResult { - const early = this.#early(value); - if (early) { - if (!early.valid && value === undefined) - early.errors = [ - { - message: this.getValidationErrorMessageSync( - this.requiredErrorMessage, - value as any - ) - } - ]; - return early; - } - const result = this.#checkNull( - this.#reference.targetSchema.validate( - value === undefined && this.hasDefault - ? this.resolveDefaultValue() - : value, - context - ) - ); - if (!result.valid) return result; - const prepared = this.preValidateSync(result.object, context); - if (!prepared.valid) return { valid: false, errors: prepared.errors }; - if (this.preprocessors.length === 0) return result; - const preparedValue = prepared.transaction!.object.validatedObject; - return this.#checkNull( - this.#early(preparedValue) ?? - this.#reference.targetSchema.validate(preparedValue, context) - ); - } - - /** @internal Async counterpart, including target validators and local callbacks. */ - protected async _validateAsync( - value: unknown, - context?: ValidationContext - ): Promise> { - const early = this.#early(value); - if (early) { - if (!early.valid && value === undefined) - early.errors = [ - { - message: await this.getValidationErrorMessage( - this.requiredErrorMessage, - value as any - ) - } - ]; - return early; - } - const result = this.#checkNull( - await this.#reference.targetSchema.validateAsync( - value === undefined && this.hasDefault - ? this.resolveDefaultValue() - : value, - context - ) - ); - if (!result.valid) return result; - const prepared = await this.preValidateAsync(result.object, context); - if (!prepared.valid) return { valid: false, errors: prepared.errors }; - if (this.preprocessors.length === 0) return result; - const preparedValue = prepared.transaction!.object.validatedObject; - return this.#checkNull( - this.#early(preparedValue) ?? - (await this.#reference.targetSchema.validateAsync( - preparedValue, - context - )) - ); - } - - /** Overrides the static type only; target validation remains unchanged. */ - public hasType( - _notUsed?: T - ): ReferenceSchemaBuilder { - return this.createFromProps(this.#props()) as any; - } - - /** Restores inference from the referenced schema. */ - public clearHasType(): ReferenceSchemaBuilder< - TSchema, - TRequired, - TNullable, - THasDefault - > { - return this.createFromProps(this.#props()) as any; - } - - /** Rejects omitted values at this use site, unless a local default exists. */ - public required( - errorMessage?: ValidationErrorMessageProvider - ): ReferenceSchemaBuilder< - TSchema, - true, - TNullable, - THasDefault, - TExplicitType - > { - return this.createFromProps({ - ...this.#props(), - isRequired: true, - presence: true, - requiredValidationErrorMessageProvider: errorMessage - }) as any; - } - - /** Allows omission without invoking the target, unless a local default exists. */ - public optional(): ReferenceSchemaBuilder< - TSchema, - false, - TNullable, - THasDefault, - TExplicitType - > { - return this.createFromProps({ - ...this.#props(), - isRequired: false, - presence: false - }) as any; - } - - /** Allows null independently of omission at this use site. */ - public nullable(): ReferenceSchemaBuilder< - TSchema, - TRequired, - true, - THasDefault, - TExplicitType - > { - return this.createFromProps({ - ...this.#props(), - isNullable: true, - nullability: true - }) as any; - } - - /** Rejects null without modifying the target definition. */ - public notNullable(): ReferenceSchemaBuilder< - TSchema, - TRequired, - false, - THasDefault, - Exclude< - TExplicitType extends undefined - ? InferType - : TExplicitType, - null - > - > { - return this.createFromProps({ - ...this.#props(), - isNullable: false, - nullability: false - }) as any; - } - - /** Supplies a local default for omission, validated by the target. */ - public default( - value: InferType | (() => InferType) - ): ReferenceSchemaBuilder { - return this.createFromProps({ - ...this.#props(), - defaultValue: value, - isRequired: true - }) as any; - } - - /** Removes the local default; target defaults are unchanged. */ - public clearDefault(): ReferenceSchemaBuilder< - TSchema, - TRequired, - TNullable, - false, - TExplicitType - > { - return this.createFromProps({ - ...this.#props(), - defaultValue: undefined - }) as any; - } - - /** Brands the inferred type without changing runtime validation. */ - public brand( - _name?: B - ): ReferenceSchemaBuilder< - TSchema, - TRequired, - TNullable, - THasDefault, - (TExplicitType extends undefined - ? InferType - : TExplicitType) & { readonly [K in BRAND]: B } - > { - return super.brand(_name); - } - - /** Makes the inferred type readonly without freezing runtime values. */ - public readonly(): ReferenceSchemaBuilder< - TSchema, - TRequired, - TNullable, - THasDefault, - Readonly< - TExplicitType extends undefined ? InferType : TExplicitType - > - > { - return super.readonly(); - } -} - -/** - * References one named definition with independent use-site annotations. - * @param schema - A reused schema constant with a nonempty schemaName. - * @returns An immutable reference wrapper; the target is neither cloned nor mutated. - * @throws Error if the target has no name. - * @example - * const User = object({ name: string() }).schemaName('User'); - * const previous = schemaRef(User).nullable().optional().describe('Previous user'); - */ -export function schemaRef( - schema: TSchema -): ReferenceSchemaBuilder< - TSchema, - undefined extends InferType ? false : true, - null extends InferType ? true : false -> { - const info = schema.introspect(); - if (!info.schemaName?.trim()) - throw new Error('schemaRef requires a named schema.'); - return new ReferenceSchemaBuilder({ - targetSchema: schema, - isRequired: info.isRequired, - isNullable: info.isNullable - }); -} diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 5fdc8671..c5344a38 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -5,6 +5,7 @@ import { transaction } from '../utils/transaction.js'; import type { ArraySchemaBuilder } from './ArraySchemaBuilder.js'; +import type { ExternSchemaBuilder } from './ExternSchemaBuilder.js'; import type { ObjectSchemaBuilder } from './ObjectSchemaBuilder.js'; /** @internal Symbol used as the key for the type brand on schema builders. */ @@ -244,6 +245,8 @@ export type SchemaBuilderProps = { hasCatch?: boolean; description?: string; schemaName?: string; + /** @internal Canonical named definition for a use-site-only derivative. */ + referenceTarget?: SchemaBuilder; example?: unknown; }; @@ -537,85 +540,77 @@ export type PropertyDescriptorTree< TParentPropertyDescriptor = undefined > = PropertyDescriptor & (TSchema extends ObjectSchemaBuilder - ? 0 extends 1 & TProperties - ? { [key: string]: any } - : { - [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder< - any, - any, - any - > - ? PropertyDescriptorTree< - TProperties[K], + ? { + [K in keyof TProperties]: TProperties[K] extends ObjectSchemaBuilder< + any, + any, + any + > + ? PropertyDescriptorTree< + TProperties[K], + TRootSchema, + any, + PropertyDescriptor< TRootSchema, - any, - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - > + TSchema, + TParentPropertyDescriptor > - : TProperties[K] extends { - readonly [SYMBOL_HAS_PROPERTIES]: true; - } - ? PropertyDescriptor< + > + : TProperties[K] extends ExternSchemaBuilder< + any, + any, + any, + any, + any, + any, + infer TExternResult + > + ? PropertyDescriptor< + TRootSchema, + TProperties[K], + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + >, + K & string + > & + ExternOutputPropertyDescriptors< + TExternResult, TRootSchema, - TProperties[K], PropertyDescriptor< TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > & - ExternOutputPropertyDescriptors< - NonNullable>, - TRootSchema, + TProperties[K], PropertyDescriptor< TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > + TSchema, + TParentPropertyDescriptor + >, + K & string > - : TProperties[K] extends ArraySchemaBuilder< - infer TArrayElement, - any, - any - > - ? TArrayElement extends ObjectSchemaBuilder< - any, - any, - any, - any, - any - > - ? PropertyDescriptor< + > + : TProperties[K] extends ArraySchemaBuilder< + infer TArrayElement, + any, + any + > + ? TArrayElement extends ObjectSchemaBuilder< + any, + any, + any, + any, + any + > + ? PropertyDescriptor< + TRootSchema, + TProperties[K], + PropertyDescriptor< TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > - : InferType extends TAssignableTo - ? PropertyDescriptor< - TRootSchema, - TProperties[K], - PropertyDescriptor< - TRootSchema, - TSchema, - TParentPropertyDescriptor - >, - K & string - > - : never + TSchema, + TParentPropertyDescriptor + >, + K & string + > : InferType extends TAssignableTo ? PropertyDescriptor< TRootSchema, @@ -627,8 +622,20 @@ export type PropertyDescriptorTree< >, K & string > - : never; - } + : never + : InferType extends TAssignableTo + ? PropertyDescriptor< + TRootSchema, + TProperties[K], + PropertyDescriptor< + TRootSchema, + TSchema, + TParentPropertyDescriptor + >, + K & string + > + : never; + } : never); /** @@ -751,6 +758,7 @@ export abstract class SchemaBuilder< #isReadonly = false; #description: string | undefined; #schemaName: string | undefined; + #referenceTarget: SchemaBuilder | undefined; #preprocessors: PreprocessorEntry[] = []; #validators: ValidatorEntry[] = []; #hasMutating = false; @@ -905,6 +913,27 @@ export abstract class SchemaBuilder< */ protected abstract createFromProps(props: any): this; + /** + * Constructs an immutable derivative through the existing subclass hook. + * Shape, rule and extension changes detach from inherited component names + * by default. Only presence, nullability, annotations and type-only changes + * may preserve the canonical definition. Preserve chains collapse to their + * original target; an unnamed derivative never reconnects automatically. + * @param props - Complete introspection properties for the new builder. + * @param preserveReference - Whether only use-site metadata changed. + * @internal + */ + protected derive(props: any, preserveReference = false): this { + return this.createFromProps({ + ...props, + schemaName: preserveReference ? this.#schemaName : undefined, + referenceTarget: + preserveReference && this.#schemaName + ? (this.#referenceTarget ?? this) + : undefined + }); + } + /** * The string identifier of the schema type (e.g. `'string'`, `'number'`, `'object'`). */ @@ -1455,6 +1484,14 @@ export abstract class SchemaBuilder< * or `undefined` if none was set. */ schemaName: this.#schemaName, + /** + * Canonical named definition retained by use-site modifiers. + * Exporters must compose local annotations/nullability around this + * target before resolving the derivative's inherited name. + * Structural and validation-rule derivatives have no target. + * @internal + */ + referenceTarget: this.#referenceTarget, /** * Whether a catch/fallback value has been set on this schema via `.catch()`. */ @@ -1476,10 +1513,13 @@ export abstract class SchemaBuilder< * Makes schema optional (consider `null` and `undefined` as valid objects for this schema) */ public optional() { - return this.createFromProps({ - ...this.introspect(), - isRequired: false - }) as any; + return this.derive( + { + ...this.introspect(), + isRequired: false + }, + true + ) as any; } /** @@ -1490,10 +1530,13 @@ export abstract class SchemaBuilder< * `.optional()` to accept both `null` and `undefined`. */ public nullable() { - return this.createFromProps({ - ...this.introspect(), - isNullable: true - }) as any; + return this.derive( + { + ...this.introspect(), + isNullable: true + }, + true + ) as any; } /** @@ -1501,10 +1544,13 @@ export abstract class SchemaBuilder< * value. This is the counterpart of `.nullable()`. */ public notNullable() { - return this.createFromProps({ - ...this.introspect(), - isNullable: false - }) as any; + return this.derive( + { + ...this.introspect(), + isNullable: false + }, + true + ) as any; } /** @@ -1529,7 +1575,7 @@ export abstract class SchemaBuilder< * ``` */ public default(value: TResult | (() => TResult)) { - return this.createFromProps({ + return this.derive({ ...this.introspect(), defaultValue: value }) as any; @@ -1587,7 +1633,7 @@ export abstract class SchemaBuilder< | ResolvedSchemaType | (() => ResolvedSchemaType) ): this { - return this.createFromProps({ + return this.derive({ ...this.introspect(), catchValue: value, hasCatch: true @@ -1598,7 +1644,7 @@ export abstract class SchemaBuilder< * Removes the default value set by a previous call to `.default()`. */ public clearDefault() { - return this.createFromProps({ + return this.derive({ ...this.introspect(), defaultValue: undefined }) as any; @@ -1624,10 +1670,13 @@ export abstract class SchemaBuilder< * ``` */ public describe(text: string): this { - return this.createFromProps({ - ...this.introspect(), - description: text - }) as unknown as this; + return this.derive( + { + ...this.introspect(), + description: text + }, + true + ) as unknown as this; } /** @@ -1647,10 +1696,13 @@ export abstract class SchemaBuilder< * ``` */ public example(value: TResult): this { - return this.createFromProps({ - ...this.introspect(), - example: value - }) as unknown as this; + return this.derive( + { + ...this.introspect(), + example: value + }, + true + ) as unknown as this; } /** @@ -1666,6 +1718,15 @@ export abstract class SchemaBuilder< * safe; how conflicts between different instances with the same name are * handled depends on the tool. * + * Ordinary presence, nullability, annotation and type-only modifiers retain + * this canonical definition for document exporters. Shape, validation-rule, + * default, fallback and extension changes discard the inherited name. Apply + * `schemaName` after those edits to give the derivative its own component. + * Calling this method always establishes a fresh definition, even on an alias. + * + * @param name - Component name for this independent schema definition. + * @returns A new named builder without an inherited canonical target. + * * @example * ```ts * import { object, string, number } from '@cleverbrush/schema'; @@ -1681,7 +1742,8 @@ export abstract class SchemaBuilder< public schemaName(name: string): this { return this.createFromProps({ ...this.introspect(), - schemaName: name + schemaName: name, + referenceTarget: undefined }) as unknown as this; } @@ -1702,9 +1764,12 @@ export abstract class SchemaBuilder< * ``` */ public brand(_name?: TBrand) { - return this.createFromProps({ - ...this.introspect() - }) as any; + return this.derive( + { + ...this.introspect() + }, + true + ) as any; } /** @@ -1725,10 +1790,13 @@ export abstract class SchemaBuilder< * ``` */ public readonly() { - return this.createFromProps({ - ...this.introspect(), - isReadonly: true - }) as any; + return this.derive( + { + ...this.introspect(), + isReadonly: true + }, + true + ) as any; } /** @@ -1736,19 +1804,22 @@ export abstract class SchemaBuilder< * @param errorMessage - optional custom error message or provider for the 'is required' validation error */ public required(errorMessage?: ValidationErrorMessageProvider) { - return this.createFromProps({ - ...this.introspect(), - isRequired: true, - ...(errorMessage !== undefined - ? { - requiredValidationErrorMessageProvider: - this.assureValidationErrorMessageProvider( - errorMessage, - this.#defaultRequiredErrorMessageProvider - ) - } - : {}) - }) as any; + return this.derive( + { + ...this.introspect(), + isRequired: true, + ...(errorMessage !== undefined + ? { + requiredValidationErrorMessageProvider: + this.assureValidationErrorMessageProvider( + errorMessage, + this.#defaultRequiredErrorMessageProvider + ) + } + : {}) + }, + true + ) as any; } /** @@ -1771,7 +1842,7 @@ export abstract class SchemaBuilder< if (typeof preprocessor !== 'function') { throw new Error('preprocessor must be a function'); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), preprocessors: [ ...this.preprocessors, @@ -1784,7 +1855,7 @@ export abstract class SchemaBuilder< * Remove all preprocessors for this schema. */ public clearPreprocessors(): this { - return this.createFromProps({ + return this.derive({ ...this.introspect(), preprocessors: [] }); @@ -1800,7 +1871,7 @@ export abstract class SchemaBuilder< if (typeof validator !== 'function') { throw new Error('validator must be a function'); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), validators: [ ...this.validators, @@ -1813,7 +1884,7 @@ export abstract class SchemaBuilder< * Remove all validators for this schema. */ public clearValidators(): this { - return this.createFromProps({ + return this.derive({ ...this.introspect(), validators: [] }); @@ -2009,7 +2080,7 @@ export abstract class SchemaBuilder< * @internal Used by extension authors inside `defineExtension()` callbacks. */ public withExtension(key: string, value: unknown): this { - return this.createFromProps({ + return this.derive({ ...this.introspect(), extensions: { ...this.#extensions, @@ -2133,6 +2204,7 @@ export abstract class SchemaBuilder< if (typeof props.schemaName === 'string') { this.#schemaName = props.schemaName; } + this.#referenceTarget = props.referenceTarget; if (props.example !== undefined) { this.#example = props.example; diff --git a/libs/schema/src/builders/StringSchemaBuilder.ts b/libs/schema/src/builders/StringSchemaBuilder.ts index 0f2c3814..5323b96c 100644 --- a/libs/schema/src/builders/StringSchemaBuilder.ts +++ b/libs/schema/src/builders/StringSchemaBuilder.ts @@ -314,9 +314,12 @@ export class StringSchemaBuilder< _notUsed?: T ): StringSchemaBuilder & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -330,9 +333,12 @@ export class StringSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #getConstraintViolation( @@ -584,7 +590,7 @@ export class StringSchemaBuilder< > ) { if (typeof value !== 'string') throw new Error('string expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: value, equalsToValidationErrorMessageProvider: errorMessage @@ -609,7 +615,7 @@ export class StringSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), equalsTo: undefined }) as any; @@ -747,7 +753,7 @@ export class StringSchemaBuilder< TExtensions { if (typeof length !== 'number') throw new Error('length must be a number'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), minLength: length, minLengthValidationErrorMessageProvider: errorMessage @@ -767,7 +773,7 @@ export class StringSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.minLength; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -794,7 +800,7 @@ export class StringSchemaBuilder< TExtensions { if (typeof length !== 'number') throw new Error('length must be a number'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), maxLength: length, maxLengthValidationErrorMessageProvider: errorMessage @@ -814,7 +820,7 @@ export class StringSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.maxLength; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -840,7 +846,7 @@ export class StringSchemaBuilder< TExtensions { if (typeof val !== 'string' || !val) throw new Error('non empty string expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), startsWith: val, startsWithValidationErrorMessageProvider: errorMessage @@ -860,7 +866,7 @@ export class StringSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.startsWith; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -886,7 +892,7 @@ export class StringSchemaBuilder< TExtensions { if (typeof val !== 'string' || !val) throw new Error('non empty string expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), endsWith: val, endsWithValidationErrorMessageProvider: errorMessage @@ -906,7 +912,7 @@ export class StringSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.endsWith; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } @@ -932,7 +938,7 @@ export class StringSchemaBuilder< > & TExtensions { if (!(regexp instanceof RegExp)) throw new Error('regexp expected'); - return this.createFromProps({ + return this.derive({ ...this.introspect(), matches: regexp, matchesValidationErrorMessageProvider: errorMessage @@ -952,7 +958,7 @@ export class StringSchemaBuilder< TExtensions { const schema = this.introspect(); delete schema.matches; - return this.createFromProps({ + return this.derive({ ...schema }) as any; } diff --git a/libs/schema/src/builders/TupleSchemaBuilder.ts b/libs/schema/src/builders/TupleSchemaBuilder.ts index 8a5a6d76..9f95b748 100644 --- a/libs/schema/src/builders/TupleSchemaBuilder.ts +++ b/libs/schema/src/builders/TupleSchemaBuilder.ts @@ -215,9 +215,12 @@ export class TupleSchemaBuilder< TRestSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -233,9 +236,12 @@ export class TupleSchemaBuilder< TRestSchema > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #getLengthError(arr: any[]): string | null { @@ -914,7 +920,7 @@ export class TupleSchemaBuilder< TSchema > & TExtensions { - return TupleSchemaBuilder.create({ + return this.derive({ ...this.introspect(), restSchema: schema } as any) as any; @@ -934,7 +940,7 @@ export class TupleSchemaBuilder< undefined > & TExtensions { - return TupleSchemaBuilder.create({ + return this.derive({ ...this.introspect(), restSchema: undefined } as any) as any; diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts index 0560e4c4..2aaf8e4b 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.ts @@ -324,9 +324,12 @@ export class UnionSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } /** @@ -341,9 +344,12 @@ export class UnionSchemaBuilder< TExtensions > & TExtensions { - return this.createFromProps({ - ...this.introspect() - } as any) as any; + return this.derive( + { + ...this.introspect() + } as any, + true + ) as any; } #createValidationSetup( @@ -995,7 +1001,7 @@ export class UnionSchemaBuilder< 'schema must be an instance of the SchemaBuilder class' ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), options: [...this.#options, schema] } as any) as any; @@ -1024,7 +1030,7 @@ export class UnionSchemaBuilder< ) { throw new Error('index must be >= 0 and <= count of the options'); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), options: this.#options.filter((_v, i) => i !== index) } as any) as any; @@ -1071,7 +1077,7 @@ export class UnionSchemaBuilder< 'schema must be an instance of the SchemaBuilder class' ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), options: [schema] } as any) as any; diff --git a/libs/schema/src/builders/namedSchemas.test.ts b/libs/schema/src/builders/namedSchemas.test.ts new file mode 100644 index 00000000..1f2afa8e --- /dev/null +++ b/libs/schema/src/builders/namedSchemas.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as core from '../core.js'; +import { array, boolean, number, object, string, tuple } from '../index.js'; + +describe('optional fallbacks', () => { + it('accepts optional/nullable fallbacks and preserves legacy null acceptance', async () => { + const text = string().optional().catch(undefined); + expect(text.parse(42)).toBeUndefined(); + expect(await text.parseAsync(false)).toBeUndefined(); + expect(text.parse(null)).toBeNull(); + expect(string().nullable().catch(null).parse(42)).toBeNull(); + const normalize = string() + .optional() + .addPreprocessor(v => (v == null ? undefined : v)) + .catch(undefined); + expect(normalize.parse(null)).toBeUndefined(); + expect(object({ text }).safeParse(null).valid).toBe(false); + expect( + object({ + text, + flag: boolean() + .optional() + .catch(() => undefined) + }).parse({ text: {}, flag: 1 }) + ).toEqual({ text: undefined, flag: undefined }); + }); + + it('supports async optional preprocessing without dropping array entries', async () => { + const fallback = vi.fn(() => undefined); + const text = string().optional().catch(fallback); + expect(array(text).parse(['ok', 42])).toEqual(['ok', undefined]); + expect(fallback).toHaveBeenCalledTimes(1); + const normalized = string() + .optional() + .addPreprocessor(async v => (v == null ? undefined : v)); + expect(await normalized.parseAsync(null)).toBeUndefined(); + expect(() => normalized.parse(null)).toThrow(/validateAsync/); + }); +}); + +describe('named schema references', () => { + it('preserves one target definition and independent local metadata', () => { + const user = object({ name: string().minLength(1) }).schemaName('User'); + const ref = user; + const previous = ref + .nullable() + .optional() + .describe('Previous user') + .example(null); + expect(ref.introspect().referenceTarget).toBeUndefined(); + expect(previous.introspect().referenceTarget).toBe(user); + expect(user.introspect().description).toBeUndefined(); + expect(user.introspect().isRequired).toBe(true); + expect(ref.safeParse(null).valid).toBe(false); + expect(previous.parse(null)).toBeNull(); + expect(previous.parse(undefined)).toBeUndefined(); + expect(previous.parse({ name: 'Ada' })).toEqual({ name: 'Ada' }); + expect( + string().optional().introspect().referenceTarget + ).toBeUndefined(); + }); + + it('preserves deeply nested reference errors and selectors', async () => { + const address = object({ city: string().minLength(1) }).schemaName( + 'Address' + ); + const user = object({ address: address.optional() }).schemaName('User'); + const root = object({ user: user.optional().describe('User') }); + for (const result of [ + root.validate({ user: { address: { city: '' } } }), + await root.validateAsync({ user: { address: { city: '' } } }) + ]) { + expect(result.valid).toBe(false); + expect( + result.getErrorsFor(t => t.user.address.city).errors.length + ).toBeGreaterThan(0); + expect( + result + .getInvalidProperties() + .map(p => p.descriptor.toJsonPointer()) + ).toContain('/user/address/city'); + } + }); + + it('keeps target defaults, local defaults and fallbacks independent', async () => { + const target = number().default(2).schemaName('Size'); + const ref = target; + expect(ref.parse(undefined)).toBe(2); + expect(ref.optional().parse(undefined)).toBe(2); + expect(ref.optional().default(4).parse(undefined)).toBe(4); + expect(ref.default(4).clearDefault().safeParse(undefined).valid).toBe( + false + ); + expect(target.parse(undefined)).toBe(2); + const fallback = vi.fn(() => 8); + const caught = ref.catch(fallback); + expect(caught.parse('invalid')).toBe(8); + expect(await caught.parseAsync('invalid')).toBe(8); + expect(fallback).toHaveBeenCalledTimes(2); + expect(ref.optional().catch(undefined).parse({})).toBe( + number().default(2).optional().catch(undefined).parse({}) + ); + }); + + it('keeps local optionality, nullability and required error messages distinct', async () => { + const ref = string().nullable().optional().schemaName('Text'); + expect(ref.parse(null)).toBeNull(); + // Preserve the existing optional-null behavior, not wrapper semantics. + expect(ref.notNullable().parse(null)).toBeNull(); + expect(ref.notNullable().parse(undefined)).toBeUndefined(); + const required = ref.required('Provide text'); + expect(required.safeParse(undefined).errors?.[0].message).toBe( + 'Provide text' + ); + expect(required.parse(null)).toBeNull(); + const asyncRequired = ref.required(async () => 'Async required'); + expect(() => asyncRequired.parse(undefined)).toThrow(/Async|async/); + expect( + (await asyncRequired.safeParseAsync(undefined)).errors?.[0].message + ).toBe('Async required'); + }); + + it('delegates async target validation and runs local callbacks', async () => { + const target = string() + .addPreprocessor(async v => v.trim()) + .schemaName('Trimmed'); + const ref = target.optional(); + expect(() => ref.parse(' text ')).toThrow(/validateAsync/); + expect(await ref.parseAsync(' text ')).toBe('text'); + expect(await ref['~standard'].validate(' text ')).toEqual({ + value: 'text' + }); + const local = number() + .schemaName('Count') + .addPreprocessor(v => v + 1) + .addValidator(v => ({ valid: v < 5 })); + expect(local.parse(2)).toBe(3); + expect(local.safeParse(5).valid).toBe(false); + expect( + string() + .schemaName('OptionalText') + .optional() + .addPreprocessor(() => undefined) + .parse('text') + ).toBeUndefined(); + }); + + it('retains existing hasType behavior without changing runtime validation', () => { + const ref = number().schemaName('Count').hasType(); + expect(ref.parse(3)).toBe(3); + expect(ref.safeParse('three').valid).toBe(false); + expect(ref.clearHasType().parse(4)).toBe(4); + }); +}); + +describe('named schema derivation policy', () => { + it('uses the same naming policy for every concrete builder', () => { + const builders = [ + core.any(), + core.array(core.string()), + core.boolean(), + core.date(), + core.extern(core.string()), + core.func(), + core.generic(s => core.object({ value: s })), + core.intersection( + core.object({ a: core.string() }), + core.object({ b: core.number() }) + ), + core.lazy(() => core.string()), + core.nul(), + core.number(), + core.object({ name: core.string() }), + core.parseString( + core.object({ id: core.number() }), + t => t`/users/${p => p.id}` + ), + core.promise(core.string()), + core.record(core.string()), + core.string(), + core.tuple([core.string()]), + core.union([core.string(), core.number()]) + ]; + for (const builder of builders) { + const named = builder.schemaName('Definition'); + const alias = named.optional().nullable().describe('Use'); + expect(Object.getPrototypeOf(alias)).toBe( + Object.getPrototypeOf(named) + ); + expect(alias.introspect().referenceTarget).toBe(named); + expect(alias.introspect().schemaName).toBe('Definition'); + expect( + alias.hasType().clearHasType().introspect().referenceTarget + ).toBe(named); + const detached = alias.withExtension('custom', true); + expect(detached.introspect().schemaName).toBeUndefined(); + expect(detached.introspect().referenceTarget).toBeUndefined(); + } + }); + + it('preserves canonical identity through all use-site and type-only modifiers', () => { + const user = object({ name: string() }).schemaName('User'); + const variants = [ + user.optional(), + user.required(), + user.nullable(), + user.notNullable(), + user.describe('Use'), + user.example({ name: 'Ada' }), + user.readonly(), + user.brand<'User'>(), + user.hasType<{ name: string }>(), + user.clearHasType(), + user.optimize(), + user + .optional() + .nullable() + .required() + .notNullable() + .describe('Chained') + ]; + for (const variant of variants) { + expect(variant.introspect().schemaName).toBe('User'); + expect(variant.introspect().referenceTarget).toBe(user); + } + expect(user.introspect().referenceTarget).toBeUndefined(); + expect(user.introspect().description).toBeUndefined(); + }); + + it('detaches structural mutations and never reconnects them', () => { + const child = string().schemaName('Name'); + const user = object({ name: child, age: number() }).schemaName('User'); + const variants = [ + user.addProp('active', boolean()), + user.addProps({ active: boolean() }), + user.omit('age'), + user.pick('name'), + user.partial(), + user.deepPartial(), + user.modifyPropSchema('name', s => s.optional()), + user.makePropOptional('age'), + user.makePropRequired('age'), + user.makeAllPropsOptional(), + user.makeAllPropsRequired(), + user.acceptUnknownProps(), + user.notAcceptUnknownProps(), + user.optional().addProp('active', boolean()) + ]; + for (const variant of variants) { + expect(variant.introspect().schemaName).toBeUndefined(); + expect(variant.introspect().referenceTarget).toBeUndefined(); + expect( + variant.optional().describe('Use').introspect().referenceTarget + ).toBeUndefined(); + } + expect(user.omit('age').introspect().properties.name).toBe(child); + expect(Object.keys(user.introspect().properties)).toEqual([ + 'name', + 'age' + ]); + }); + + it('detaches rules, callbacks, defaults, fallbacks and extension changes including clear methods', () => { + const name = string().schemaName('Name').optional(); + const variants = [ + name.minLength(1), + name.maxLength(2), + name.clearMinLength(), + name.clearMaxLength(), + name.addValidator(() => ({ valid: true })), + name.clearValidators(), + name.addPreprocessor(v => v), + name.clearPreprocessors(), + name.default('Ada'), + name.clearDefault(), + name.catch(undefined), + name.withExtension('custom', true), + name.email(), + number().schemaName('Count').min(0), + boolean().schemaName('Flag').equals(true), + array(string()).schemaName('Names').minLength(1), + array(string()).schemaName('Names').maxLength(2), + array(string()).schemaName('Names').of(number()), + array(string()).schemaName('Names').clearOf(), + tuple([string()]).schemaName('Tuple').rest(number()), + tuple([string()]).schemaName('Tuple').clearRest() + ]; + for (const variant of variants) { + expect(variant.introspect().schemaName).toBeUndefined(); + expect(variant.introspect().referenceTarget).toBeUndefined(); + } + expect(name.minLength(1).email).toBeTypeOf('function'); + expect( + array(string()).schemaName('Names').minLength(1).nonempty + ).toBeTypeOf('function'); + }); + + it('explicit naming establishes a new independent definition', () => { + const original = object({ name: string() }).schemaName('User'); + const renamed = original + .optional() + .describe('Alias') + .schemaName('MaybeUser'); + expect(renamed.introspect().referenceTarget).toBeUndefined(); + expect(renamed.required().introspect().referenceTarget).toBe(renamed); + const extended = original + .addProp('id', number()) + .schemaName('UserWithId'); + expect(extended.optional().introspect().referenceTarget).toBe(extended); + expect(original.introspect().schemaName).toBe('User'); + }); + + it('keeps named and unnamed runtime behavior identical including async callbacks', async () => { + const plain = string().addPreprocessor(async value => value?.trim()); + const named = plain.schemaName('Text'); + for (const value of [undefined, null, '', ' a ']) { + expect(await named.optional().validateAsync(value)).toEqual( + await plain.optional().validateAsync(value) + ); + expect( + await named.required().nullable().validateAsync(value) + ).toEqual(await plain.required().nullable().validateAsync(value)); + } + }); +}); diff --git a/libs/schema/src/builders/references.test-d.ts b/libs/schema/src/builders/references.test-d.ts index 1f8ca4a9..b22b897d 100644 --- a/libs/schema/src/builders/references.test-d.ts +++ b/libs/schema/src/builders/references.test-d.ts @@ -1,5 +1,5 @@ import { expectTypeOf, test } from 'vitest'; -import { type InferType, number, object, schemaRef, string } from '../index.js'; +import { type InferType, number, object, string } from '../index.js'; test('optional-aware fallback and preprocessing types remain checked', () => { // @ts-expect-error required schemas cannot fall back to undefined @@ -20,7 +20,7 @@ test('optional-aware fallback and preprocessing types remain checked', () => { test('references preserve inferred types, local modifiers and selectors', () => { const user = object({ name: string() }).schemaName('User'); - const ref = schemaRef(user); + const ref = user; expectTypeOf>().toEqualTypeOf< InferType >(); @@ -40,8 +40,18 @@ test('references preserve inferred types, local modifiers and selectors', () => root.validate({ user: null }).getErrorsFor(t => t.user.name); // @ts-expect-error concrete descriptor trees still reject unknown fields root.validate({ user: null }).getErrorsFor(t => t.user.missing); - const override = schemaRef(number().schemaName('Number')).hasType(); + const override = number().schemaName('Number').hasType(); expectTypeOf>().toEqualTypeOf(); const cleared = override.clearHasType(); expectTypeOf>().toEqualTypeOf(); + const extended = user.optional().addProp('age', number()).required(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + age: number; + }>(); + const email = string().schemaName('Name').optional().email(); + const plainEmail = string().optional().email(); + expectTypeOf>().toEqualTypeOf< + InferType + >(); }); diff --git a/libs/schema/src/core.ts b/libs/schema/src/core.ts index c27c368c..023f3b3b 100644 --- a/libs/schema/src/core.ts +++ b/libs/schema/src/core.ts @@ -50,10 +50,6 @@ export { } from './builders/PromiseSchemaBuilder.js'; export type { RecordSchemaValidationResult } from './builders/RecordSchemaBuilder.js'; export { RecordSchemaBuilder, record } from './builders/RecordSchemaBuilder.js'; -export { - ReferenceSchemaBuilder, - schemaRef -} from './builders/ReferenceSchemaBuilder.js'; export type { NestedValidationResult, PropertyDescriptor, diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index f44f40cd..9cb75e6a 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -9,10 +9,12 @@ OpenAPI 3.1 specification generation for [`@cleverbrush/server`](../server). Con ### Schema boundaries -Use `schemaRef(NamedSchema).optional().nullable().describe(...)` to annotate a -single use without cloning the named definition. Conflicting named definitions +Use `NamedSchema.optional().nullable().describe(...)` to annotate a +single use while preserving the canonical named definition. Conflicting named definitions still fail. OpenAPI and AsyncAPI retain one canonical component per named target. -See the [composition guide](../schema/BOUNDARIES.md). +Shape and rule changes detach the inherited name; name the result explicitly +with `schemaName` when it needs its own component. +See the [composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md). - **`generateOpenApiSpec()`** — converts `@cleverbrush/server` endpoint registrations into an OpenAPI 3.1 document. - **`generateAsyncApiSpec()`** — converts `@cleverbrush/server` WebSocket subscription registrations into an AsyncAPI 3.0 document. diff --git a/libs/server-openapi/src/boundaries.test.ts b/libs/server-openapi/src/boundaries.test.ts index 28c0702d..a9fd4bc0 100644 --- a/libs/server-openapi/src/boundaries.test.ts +++ b/libs/server-openapi/src/boundaries.test.ts @@ -3,7 +3,6 @@ import { lazy, object, type SchemaBuilder, - schemaRef, string } from '@cleverbrush/schema'; import { endpoint } from '@cleverbrush/server'; @@ -16,8 +15,8 @@ describe('named schema references in API documents', () => { it('uses one canonical component for requests, responses and annotated references', () => { const user = object({ name: string() }).schemaName('User'); const history = object({ - current: schemaRef(user), - previous: schemaRef(user).nullable().optional().describe('Previous') + current: user, + previous: user.nullable().optional().describe('Previous') }); const contract = endpoint .post('/history') @@ -39,7 +38,7 @@ describe('named schema references in API documents', () => { ].schema; expect(request).toEqual(response); expect(request.required).toEqual(['current']); - expect(request.properties.current.allOf[0].$ref).toBe( + expect(request.properties.current.$ref).toBe( '#/components/schemas/User' ); expect(request.properties.previous.description).toBe('Previous'); @@ -55,14 +54,18 @@ describe('named schema references in API documents', () => { walkSchemas( object({ current: user, - previous: schemaRef(user).nullable().optional() + previous: user.nullable().optional() }), registry ); expect([...registry.entries()].map(([name]) => name)).toEqual(['User']); - expect(() => walkSchemas(user.optional(), registry)).toThrow( - /already registered/ - ); + expect(() => walkSchemas(user.optional(), registry)).not.toThrow(); + expect(() => + walkSchemas(user.optional().schemaName('User'), registry) + ).toThrow(/already registered/); + expect(() => + walkSchemas(object({ name: string() }).schemaName('User'), registry) + ).toThrow(/already registered/); expect(() => walkSchemas(string().schemaName('User'), registry) ).toThrow(/already registered/); @@ -72,7 +75,7 @@ describe('named schema references in API documents', () => { type Node = { name: string; children: Node[] }; const node: SchemaBuilder = object({ name: string(), - children: array(lazy(() => schemaRef(node))) + children: array(lazy(() => node.optional())) }).schemaName('Node'); const contract = endpoint.get('/nodes').responses({ 200: node }); const openapi = generateOpenApiSpec({ @@ -88,8 +91,8 @@ describe('named schema references in API documents', () => { protocol: 'subscription', basePath: '/ws', pathTemplate: '/nodes', - incomingSchema: schemaRef(node), - outgoingSchema: schemaRef(node), + incomingSchema: node, + outgoingSchema: node, querySchema: null, headerSchema: null, serviceSchemas: null, @@ -121,4 +124,47 @@ describe('named schema references in API documents', () => { messages.ServerEvent.payload ); }); + + it('separates inline derivatives, explicit new definitions and nested shared components', () => { + const name = string().schemaName('Name'); + const user = object({ name, role: string() }).schemaName('User'); + const patch = user.partial(); + const renamed = user.omit('role').schemaName('PublicUser'); + const schema = object({ + user, + patch, + public: renamed.optional(), + short: name.maxLength(2) + }); + const contract = endpoint + .post('/mixed') + .body(schema) + .responses({ 200: schema }); + const spec = generateOpenApiSpec({ + registrations: [ + { endpoint: contract.introspect(), handler: () => {} } + ], + info: { title: 'Mixed', version: '1' } + }) as any; + expect(Object.keys(spec.components.schemas).sort()).toEqual([ + 'Name', + 'PublicUser', + 'User' + ]); + expect( + spec.components.schemas.PublicUser.properties + ).not.toHaveProperty('role'); + const props = + spec.paths['/mixed'].post.requestBody.content['application/json'] + .schema.properties; + expect(props.patch.type).toBe('object'); + expect(props.patch.required).toBeUndefined(); + expect(props.patch.properties.name.allOf[0].$ref).toBe( + '#/components/schemas/Name' + ); + expect(props.short).toEqual({ type: 'string', maxLength: 2 }); + expect(props.public.allOf[0].$ref).toBe( + '#/components/schemas/PublicUser' + ); + }); }); diff --git a/libs/server-openapi/src/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts index f2610a17..10f464dd 100644 --- a/libs/server-openapi/src/schemaRegistry.ts +++ b/libs/server-openapi/src/schemaRegistry.ts @@ -11,8 +11,8 @@ import type { SchemaBuilder } from '@cleverbrush/schema'; * `$ref: '#/components/schemas/'` pointers. * * **Conflict rule**: registering two *different* schema instances (different - * object references) under the same name throws immediately. Re-registering - * the same instance is a no-op. + * object references) under the same name throws immediately. Use-site aliases + * register their canonical target; re-registering that target is a no-op. * * @example * ```ts @@ -37,6 +37,7 @@ export class SchemaRegistry { * - If the schema has no `schemaName` in its introspect output, it is * silently skipped. * - If the same instance is already registered, this is a no-op. + * - Use-site modifiers register their canonical definition, not the alias. * - If a **different** instance is already registered under the same name, * an error is thrown. * @@ -44,6 +45,7 @@ export class SchemaRegistry { * @throws {Error} When two distinct schema instances share the same name. */ register(schema: SchemaBuilder): void { + schema = schema.introspect().referenceTarget ?? schema; const name = (schema.introspect() as any).schemaName as | string | undefined; @@ -74,6 +76,7 @@ export class SchemaRegistry { * @returns The registered name, or `null`. */ getName(schema: SchemaBuilder): string | null { + schema = schema.introspect().referenceTarget ?? schema; return this.byInstance.get(schema) ?? null; } @@ -104,9 +107,8 @@ export class SchemaRegistry { * schemas may safely be shared across multiple branches without causing * infinite recursion. * - * **Excluded schema types** - * - `lazy` — deferred resolution would require calling the getter, which may - * itself reference the parent schema; lazy schemas are handled separately. + * Use-site aliases walk their canonical definition. Lazy schemas resolve their + * getter; the shared visited set prevents cycles through named recursive roots. * * @param schema - Root schema to start the walk from. * @param registry - Registry to register named schemas into. @@ -125,10 +127,12 @@ export function walkSchemas( const info = schema.introspect() as any; + if (info.referenceTarget) { + walkSchemas(info.referenceTarget, registry, visited); + return; + } + switch (info.type) { - case 'reference': - walkSchemas(info.targetSchema, registry, visited); - break; case 'intersection': walkSchemas(info.left, registry, visited); walkSchemas(info.right, registry, visited); diff --git a/websites/docs/app/server-openapi/page.tsx b/websites/docs/app/server-openapi/page.tsx index 784a18fb..99aa5fd1 100644 --- a/websites/docs/app/server-openapi/page.tsx +++ b/websites/docs/app/server-openapi/page.tsx @@ -23,11 +23,12 @@ export default function ServerOpenApiPage() {

Named references and local annotations

- Use schemaRef to annotate one use of a named definition - without cloning it. Optionality, nullability and - descriptions remain local, while the target has one - canonical component. Conflicting named definitions still - fail registration. + Use ordinary fluent modifiers on a named definition. + Optionality, nullability and descriptions remain local, + while the target has one canonical component. + Conflicting named definitions still fail registration. + Shape and rule changes become unnamed; apply schemaName + last to name a new definition.

Schema boundary composition and compatibility diff --git a/websites/schema/app/docs/sections/schema-boundaries.tsx b/websites/schema/app/docs/sections/schema-boundaries.tsx index 8d94821d..385819d1 100644 --- a/websites/schema/app/docs/sections/schema-boundaries.tsx +++ b/websites/schema/app/docs/sections/schema-boundaries.tsx @@ -26,15 +26,18 @@ const nullableText = string().nullable().catch(null);`}
                     {`const User = object({ name: string() }).schemaName('User');
 const History = object({
-    current: schemaRef(User),
-    previous: schemaRef(User).nullable().optional()
+    current: User,
+    previous: User.nullable().optional()
         .describe('Previous user')
 });`}
                 

- Reference modifiers apply at the use site. The original - named definition remains unchanged, and genuinely - conflicting definitions still fail registration. + Ordinary modifiers apply at the use site. The original named + definition remains unchanged, and genuinely conflicting + definitions still fail registration. Shape, rule, default, + fallback and extension changes discard inherited names. + Apply schemaName after those edits when the result needs its + own component.

diff --git a/websites/schema/app/schema-json/page.tsx b/websites/schema/app/schema-json/page.tsx index 1b936ffc..ea95b003 100644 --- a/websites/schema/app/schema-json/page.tsx +++ b/websites/schema/app/schema-json/page.tsx @@ -35,10 +35,12 @@ export default function SchemaJsonPage() {

Named references

- Use schemaRef to reuse a named definition with local - annotations and nullability. Reference composition - preserves the original definition in both Draft 7 and - Draft 2020-12. + Use ordinary fluent modifiers to reuse a named + definition with local annotations and nullability. + Reference composition preserves the original definition + in both Draft 7 and Draft 2020-12. Shape and rule + changes detach the inherited name; apply schemaName last + when naming a new definition.

Named references and optional fallbacks From 89abbf53cd76c37947243d8548e5bc983f5d811b Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 29 Sep 2026 11:02:06 +0000 Subject: [PATCH 5/5] docs(schema): consolidate reference guidance after review --- libs/schema-json/README.md | 43 +++++-- libs/schema/BOUNDARIES.md | 116 ------------------ libs/schema/README.md | 102 +++++++++++++-- libs/schema/src/boundaries-docs.test.ts | 52 -------- libs/server-openapi/README.md | 41 +++++-- websites/docs/app/server-openapi/page.tsx | 57 ++++++--- websites/schema/app/docs/[[...slug]]/page.tsx | 2 - websites/schema/app/docs/sections/index.ts | 6 - .../app/docs/sections/schema-boundaries.tsx | 58 --------- .../app/docs/sections/schema-modifiers.tsx | 107 ++++++++++++++++ websites/schema/app/schema-json/page.tsx | 59 ++++++--- 11 files changed, 347 insertions(+), 296 deletions(-) delete mode 100644 libs/schema/BOUNDARIES.md delete mode 100644 libs/schema/src/boundaries-docs.test.ts delete mode 100644 websites/schema/app/docs/sections/schema-boundaries.tsx diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index d6c137ff..3084461a 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -12,13 +12,6 @@ for use in OpenAPI specs, form generators, or any other JSON Schema consumer. ## When to use this library -### Named references - -Ordinary modifiers such as `User.optional().nullable().describe('Previous')` -preserve a named definition automatically. Shape and validation-rule changes -become unnamed derivatives; apply `schemaName` last to name a new definition. -See the [composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md). - - **Consuming external APIs** — you have a JSON Schema from an OpenAPI spec or a third-party service and want to validate incoming data with full TypeScript type inference. @@ -163,6 +156,40 @@ Descriptions set via `.describe(text)` are emitted as the `description` field on Examples set via `.example(value)` are emitted as the `examples` array on the corresponding JSON Schema node. +#### Named references and local annotations + +Ordinary modifiers such as `User.optional().nullable().describe('Previous')` +preserve the canonical named definition. With a `nameResolver`, direct reuse +emits a `$ref`; modified uses compose around that reference so annotations and +nullability remain local in both Draft 07 and Draft 2020-12. + +```ts +const User = object({ name: string() }).schemaName('User'); +const History = object({ + current: User, + previous: User.optional().nullable().describe('Previous user') +}); +const json = toJsonSchema(History, { + $schema: false, + nameResolver: schema => schema.introspect().schemaName ?? null +}); +// json.required: ['current'] +// json.properties.current: { $ref: '#/components/schemas/User' } +// json.properties.previous: local description and a nullable reference to User +``` + +Use-site modifiers are handled before name resolution, which receives the +canonical target. The resolver does not create component definitions itself; +supply them in the containing document or use `@cleverbrush/server-openapi`. +Without a resolver, the target is converted inline. + +Shape, validation-rule, default, fallback and extension changes discard the +inherited name and export inline, while nested named children still reuse their +definitions. Apply `schemaName` after such edits to name a new definition. See +the [schema naming rules](https://schema.cleverbrush.com/docs/schema-modifiers#schema-name). +Standard JSON Schema `input()` and `output()` retain their existing identical +representation; no separate directional schemas are introduced. + #### Discriminated unions When a `union()` is a **discriminated union** — all branches are objects sharing a required property with unique literal values — `toJsonSchema()` automatically emits the `discriminator` keyword alongside `anyOf`: @@ -195,7 +222,7 @@ This enables code-generation tools (openapi-generator, orval, etc.) to produce p | --- | --- | --- | --- | | `draft` | `'2020-12' \| '07'` | `'2020-12'` | JSON Schema draft version for the `$schema` URI | | `$schema` | `boolean` | `true` | Whether to include the `$schema` header in the output | -| `nameResolver` | `(schema: SchemaBuilder) => string \| null` | `undefined` | Called for every node before conversion. Return a non-null string to emit `{ $ref: '#/components/schemas/' }` instead of an inline schema. Used by `@cleverbrush/server-openapi` to wire named schemas from `.schemaName()` into `$ref` pointers. | +| `nameResolver` | `(schema: SchemaBuilder) => string \| null` | `undefined` | Return a component name to emit `{ $ref: '#/components/schemas/' }` instead of an inline definition. Use-site modifiers resolve their canonical target and compose local annotations/nullability around it. Used by `@cleverbrush/server-openapi` for named components. | ```ts // Embed in OpenAPI (suppress the $schema header) diff --git a/libs/schema/BOUNDARIES.md b/libs/schema/BOUNDARIES.md deleted file mode 100644 index 14695a44..00000000 --- a/libs/schema/BOUNDARIES.md +++ /dev/null @@ -1,116 +0,0 @@ -# Schemas across boundaries - -`InferType` continues to mean validated output; -existing builder generic arguments and legacy preprocessors keep their meaning. - -## Optional fallbacks - -Before, consumers needed a separate safeParse helper for each optional scalar. -Now a fallback can use the optional or nullable schema's full output type: - -```ts -import { boolean, object, string } from '@cleverbrush/schema'; - -const optionalText = string().optional().catch(undefined); -const ExternalRecord = object({ - name: optionalText, - enabled: boolean().optional().catch(() => undefined) -}); -ExternalRecord.parse({ name: 42, enabled: 'unknown' }); -// { name: undefined, enabled: undefined } -``` - -Fallbacks are opt-in. A malformed required root object still fails. Arrays do -not silently discard invalid elements. A fallback factory runs only on failure. - -**Compatibility limitation:** legacy optional schemas also accept `null` at -runtime, even though their inferred type does not include it. This release does -not change that behavior. Consequently, `.optional().catch(undefined)` leaves -`null` unchanged. Normalize it explicitly when the application requires this: - -```ts -const normalizedText = string().optional() - .addPreprocessor(value => value == null ? undefined : value) - .catch(undefined); -``` - -Preprocessors can return optional/nullable values, including asynchronously. -Their existing callback parameter typing is preserved; it is not a guarantee -that unknown runtime input already has that type. Use explicit guards or -preprocessing/conversion followed by validation at untrusted boundaries. -Global strict null rejection is a separate, compatibility-sensitive follow-up. - -## One named definition, many annotated references - -Before, cloning `User.schemaName('User')` with `.optional()` created another -named instance and conflicted during OpenAPI generation. - -```ts -import { number, object, string } from '@cleverbrush/schema'; - -const User = object({ id: number(), name: string() }).schemaName('User'); -const History = object({ - current: User, - previous: User.nullable().optional() - .describe('The previous user, when known.') -}); -``` - -No wrapper is needed. Ordinary immutable modifiers keep the concrete builder, -fluent methods, extension methods, inference, and nested property selectors. -They validate normally; canonical-reference metadata only affects exporters. -The original definition is never mutated. Reuse the plain constant when there -are no local modifiers. Independent definitions sharing a name still conflict; -there is no name-based or structural deduplication. - -### Which changes preserve the named definition? - -- Use-site presence/nullability: `optional`, `required`, `nullable`, `notNullable`. -- Annotations: `describe`, `example`, `readonly`. -- Type-only changes: `brand`, `hasType`, `clearHasType`, `optimize`. - -Chains of these modifiers always reference the original canonical definition, -not another alias. Calling `schemaName` explicitly creates a new independent -definition, even when the previous name is reused. - -### Shape and rule changes become unnamed - -Property additions/removals, `partial`, `pick`, `omit`, constraints, validators, -preprocessors, defaults, fallbacks, and their clear methods discard the inherited -name and canonical association. Extension changes detach conservatively, too. -These derivatives cannot safely claim to be the original definition. Later -annotations or optionality do not reconnect them. - -```ts -const PartialUser = User.partial(); // unnamed, all properties optional -const UserWithEmail = User.addProp('email', string()); // unnamed, new shape -const PublicUser = User.omit('id').schemaName('PublicUser'); // new definition -const ShortName = string().schemaName('Name').maxLength(20); // unnamed rule change -``` - -Existing nested named schemas still reuse their own definitions. To retain a -stable component name after a shape or rule change, call `schemaName` **last**. -This intentionally changes inherited-name behavior; review code that relied on -constraint/property modifications retaining the old component name. - -JSON Schema/OpenAPI keeps one definition and uses reference composition for -local annotations, examples and nullability, including Draft 07 references. - -## Defaults and validation - -Defaults and fallbacks retain ordinary builder behavior, not delegated wrapper -behavior. Adding or clearing either detaches the name. Clearing a default removes -it completely; it does not reveal a hidden canonical default. Async validators -and preprocessors still require `parseAsync`/`validateAsync`. - -Use `InferType` for schema inference and the existing `hasType` method when an -explicit static override is needed. Static overrides and casts do not change -runtime validation or convert values. - -## API documents - -JSON Schema, OpenAPI and AsyncAPI retain one canonical component per named -target. Reference annotations compose around that definition rather than -changing it. Named recursive references are supported; independently rebuilt -schemas with the same name still conflict. Standard JSON Schema `input()` and -`output()` retain their existing identical representation. diff --git a/libs/schema/README.md b/libs/schema/README.md index 937bf4f4..78bce142 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -1,12 +1,5 @@ # @cleverbrush/schema -## Schemas across boundaries - -Use optional-aware `catch(undefined)` and ordinary named-schema modifiers for per-use -annotations while preserving canonical named definitions. See the -[composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md) -for examples, naming rules and null-compatibility limits. - [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml) [![Standard Schema v1](https://img.shields.io/badge/Standard%20Schema-v1-blue)](https://standardschema.dev/) @@ -1132,6 +1125,43 @@ console.log(info.hasCatch); // true console.log(info.catchValue); // 'unknown' ``` +### Optional and nullable fallbacks + +Fallback values and factories respect the schema's resolved output type: +optional schemas allow `undefined`, and nullable schemas allow `null`. + +```typescript +const optionalText = string().optional().catch(undefined); +const nullableText = string().nullable().catch(() => null); + +optionalText.parse(42); // undefined +nullableText.parse(42); // null +object({ text: optionalText }).parse({ text: false }); // { text: undefined } +array(optionalText).parse(['ok', 42]); // ['ok', undefined] — no entries dropped +``` + +Fallbacks are opt-in: a fallback on a property does not make a malformed required +root object valid. A fallback factory runs only when validation fails. + +**Null compatibility:** legacy optional schemas accept `null` at runtime even +though their inferred type does not include it. `.optional().catch(undefined)` +therefore leaves `null` unchanged. Normalize it explicitly when needed: + +```typescript +const normalizedText = string().optional() + .addPreprocessor(value => value == null ? undefined : value) + .catch(undefined); + +normalizedText.parse(null); // undefined +``` + +Preprocessors can return optional/nullable values, including asynchronously. +Their existing callback parameter typing does not guarantee that unknown input +already has that type: guard untrusted values before using type-specific methods. +Use `parseAsync` / `validateAsync` for async preprocessors or validators. +`InferType` and `hasType` keep their existing meaning; static overrides and casts +do not perform runtime conversion or validation. + ## Readonly Modifier Every schema builder supports `.readonly()`. This is a **type-level-only** modifier — it marks the inferred TypeScript type as immutable, but does not alter validation behaviour or freeze the validated value at runtime. @@ -1196,7 +1226,7 @@ export const UserSchema = object({ UserSchema.introspect().schemaName; // 'User' ``` -Chains naturally with all other modifiers: +Annotations can be applied after naming a definition: ```typescript const ProductSchema = object({ @@ -1217,12 +1247,64 @@ import { generateOpenApiSpec } from '@cleverbrush/server-openapi'; generateOpenApiSpec({ registrations, info: { title: 'My API', version: '1.0.0' } }); ``` -> **Name uniqueness:** Registering two *different* schema instances under the same name throws an error. Always export named schemas as constants and reuse the same reference everywhere. +### Reusing a named definition + +Use the plain constant directly, or apply ordinary use-site modifiers. No wrapper +is needed; the concrete builder, fluent and extension methods, inferred types, +and nested property selectors are preserved. The original remains unchanged. + +```typescript +const History = object({ + current: UserSchema, + previous: UserSchema.optional().nullable().describe('Previous user') +}); +// One canonical User component; previous has local annotations/nullability. +``` + +These modifiers retain the canonical named definition for document exporters: + +- Presence/nullability: `optional`, `required`, `nullable`, `notNullable`. +- Annotations: `describe`, `example`, `readonly`. +- Type-only changes: `brand`, `hasType`, `clearHasType`, `optimize`. + +Modifier chains reference the original definition, not another alias. JSON Schema, +OpenAPI and AsyncAPI compose local annotations and nullability around that +definition, including Draft 07 references. Runtime validation still follows the +ordinary builder's behavior; canonical-reference metadata is for exporters. + +### Shape and rule changes discard inherited names + +Property additions/removals, `partial`, `pick`, `omit`, constraints, validators, +preprocessors, defaults, fallbacks and their available clear methods produce +unnamed derivatives. Extension changes detach conservatively too. Later +annotations or optionality do not reconnect the derivative to the original. + +```typescript +const PatchUser = UserSchema.partial(); // unnamed, changed shape +const UserWithEmail = UserSchema.addProp('email', string()); // unnamed +const PublicUser = UserSchema.omit('id').schemaName('PublicUser'); // new definition +const ShortName = string().schemaName('Name').maxLength(20); // unnamed rule change +``` + +Existing nested named schemas still reuse their own definitions. Apply +`schemaName` **after** shape/rule edits when the result needs a stable component +name. This changes inherited-name behavior: code that relied on property or +constraint edits retaining a name should name the final result explicitly. + +Defaults and fallbacks keep ordinary builder semantics. Adding or clearing them +detaches the inherited name; `clearDefault()` removes the default completely, +without revealing a hidden default from the canonical definition. + +> **Name uniqueness:** Independent definitions with the same name still conflict, +> even with identical shapes; there is no name-only or structural deduplication. +> Use-site modifiers reuse the original definition and do not conflict. Calling +> `schemaName` explicitly always establishes a fresh independent definition, +> even on an alias or when the previous name is reused. | Method / Property | Signature | Notes | |---|---|---| | `.schemaName(name)` | `schemaName(name: string): this` | Returns a new builder; original is unchanged | -| `.introspect().schemaName` | `string \| undefined` | The name passed to `.schemaName()`, or `undefined` | +| `.introspect().schemaName` | `string \| undefined` | The explicit or preserved name; `undefined` after a shape/rule change | ## Describe diff --git a/libs/schema/src/boundaries-docs.test.ts b/libs/schema/src/boundaries-docs.test.ts deleted file mode 100644 index 25eeffc0..00000000 --- a/libs/schema/src/boundaries-docs.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { readFileSync } from 'node:fs'; -import ts from 'typescript'; -import { describe, expect, it } from 'vitest'; - -describe('published boundary documentation', () => { - it('preserves declaration summaries for boundary types and methods', () => { - const hasSummary = (node: ts.Node) => - (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc?.some( - doc => doc.comment - ); - for (const [path, names] of [ - [ - '../dist/builders/SchemaBuilder.d.ts', - ['SchemaBuilder', 'SchemaBuilderProps'] - ], - ['../../schema-json/dist/types.d.ts', ['ToJsonSchemaOptions']] - ] as const) { - const text = readFileSync(new URL(path, import.meta.url), 'utf8'); - const source = ts.createSourceFile( - path, - text, - ts.ScriptTarget.Latest, - true - ); - for (const name of names) { - const node = source.statements.find( - node => - 'name' in node && - (node.name as ts.Identifier)?.text === name - ); - expect(node, name).toBeDefined(); - expect(hasSummary(node!), name).toBe(true); - if (node && ts.isClassDeclaration(node)) { - for (const memberName of [ - 'schemaName', - 'introspect', - 'derive', - 'catch', - 'addPreprocessor' - ]) { - const member = node.members.find( - member => - member.name?.getText(source) === memberName - ); - expect(member, memberName).toBeDefined(); - expect(hasSummary(member!), memberName).toBe(true); - } - } - } - } - }); -}); diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 9cb75e6a..8290de49 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -7,15 +7,6 @@ OpenAPI 3.1 specification generation for [`@cleverbrush/server`](../server). Con ## Features -### Schema boundaries - -Use `NamedSchema.optional().nullable().describe(...)` to annotate a -single use while preserving the canonical named definition. Conflicting named definitions -still fail. OpenAPI and AsyncAPI retain one canonical component per named target. -Shape and rule changes detach the inherited name; name the result explicitly -with `schemaName` when it needs its own component. -See the [composition guide](https://github.com/cleverbrush/framework/blob/development/libs/schema/BOUNDARIES.md). - - **`generateOpenApiSpec()`** — converts `@cleverbrush/server` endpoint registrations into an OpenAPI 3.1 document. - **`generateAsyncApiSpec()`** — converts `@cleverbrush/server` WebSocket subscription registrations into an AsyncAPI 3.0 document. - **`serveAsyncApi()`** — middleware that lazily generates and caches the AsyncAPI spec; serves it at a configurable path (default: `/asyncapi.json`). @@ -150,9 +141,31 @@ const AddressSchema = object({ street: string(), city: string() }).schemaName('A const CreateUserBody = object({ address: AddressSchema, name: string() }); ``` +### Local modifiers and changed shapes + +Ordinary use-site modifiers retain the canonical named component: + +```ts +const History = object({ + current: UserSchema, + previous: UserSchema.optional().nullable().describe('Previous user') +}); +// One User component; previous composes local nullability and annotations. + +const PatchUser = UserSchema.partial(); // unnamed, exported inline +const PublicUser = UserSchema.omit('id').schemaName('PublicUser'); // new component +``` + +Presence, nullability, annotations and type-only modifiers preserve the original +definition. Shape/rule edits, callbacks, defaults, fallbacks and extension changes +discard inherited names. Their nested named children still reuse components. +Apply `schemaName` after these edits when the result needs its own component; +later optionality or annotations do not reconnect an unnamed derivative. +See the [complete naming rules](https://schema.cleverbrush.com/docs/schema-modifiers#schema-name). + ### Conflict rule -Registering **two different schema instances** under the same name throws immediately during spec generation: +Registering **two independent definitions** under the same name throws immediately during spec generation, even if their shapes match: ```ts const A = object({ x: string() }).schemaName('Thing'); @@ -162,7 +175,9 @@ generateOpenApiSpec({ registrations: [...], info: { … } }); // Error: Schema name "Thing" is already registered by a different schema instance. ``` -Re-registering the **same** instance (because it appears in multiple endpoints) is a no-op. +Re-registering the **same** instance or its use-site modifiers is a no-op. Calling +`schemaName` explicitly establishes a new independent definition, even on a +modified use; reusing an already registered name then conflicts. ### `SchemaRegistry` (advanced) @@ -455,6 +470,10 @@ Each subscription endpoint becomes: Named schemas (registered via `.schemaName()`) are collected into `components.schemas` and referenced via `$ref` pointers in the channel messages. +AsyncAPI uses the same canonical-reference, local-modifier and name-conflict +rules as OpenAPI. Named recursive schemas are expanded once per component, +with recursive uses referencing that component. + ### `AsyncApiOptions` | Field | Type | Default | Description | diff --git a/websites/docs/app/server-openapi/page.tsx b/websites/docs/app/server-openapi/page.tsx index 99aa5fd1..0e815725 100644 --- a/websites/docs/app/server-openapi/page.tsx +++ b/websites/docs/app/server-openapi/page.tsx @@ -20,20 +20,6 @@ export default function ServerOpenApiPage() {

- {/* ── Installation ─────────────────────────────────── */}

- Conflict rule: registering two{' '} - different schema instances under the same name - throws during spec generation. Always export named - schemas as constants and share the same object - reference. + Ordinary use-site modifiers retain the canonical + component, with local annotations and nullability: +

+
+                        {`const History = object({
+    current: UserSchema,
+    previous: UserSchema.optional().nullable().describe('Previous user')
+});
+// One User component shared by both properties.
+
+const PatchUser = UserSchema.partial(); // unnamed, exported inline
+const PublicUser = UserSchema.omit('id').schemaName('PublicUser');`}
+                    
+

+ Shape/rule edits, callbacks, defaults, fallbacks and + extension changes discard inherited names. Nested named + children still reuse components. Apply{' '} + schemaName after these edits when the + result needs its own component. Later optionality or + annotations do not reconnect an unnamed derivative. See + the{' '} + + complete naming rules + + . +

+

+ Conflict rule: independent definitions + sharing a name throw during spec generation, even if + their shapes match. Reusing a constant or its use-site + modifiers does not conflict. Explicitly calling{' '} + schemaName always creates a new independent + definition, including on a modified use. +

+

+ AsyncAPI follows the same rules: one canonical component + per named definition, local annotations in message + payloads, and references for recursive uses.

diff --git a/websites/schema/app/docs/[[...slug]]/page.tsx b/websites/schema/app/docs/[[...slug]]/page.tsx index 2e4f66c8..854d0b28 100644 --- a/websites/schema/app/docs/[[...slug]]/page.tsx +++ b/websites/schema/app/docs/[[...slug]]/page.tsx @@ -17,7 +17,6 @@ import ObjectConstructorsSection from '../sections/object-constructors'; import ParseStringSection from '../sections/parse-string'; import PropertyDescriptorsSection from '../sections/property-descriptors'; import RecursiveSchemasSection from '../sections/recursive-schemas'; -import SchemaBoundariesSection from '../sections/schema-boundaries'; import SchemaModifiersSection from '../sections/schema-modifiers'; import SchemaTypesSection from '../sections/schema-types'; import StandardSchemaSection from '../sections/standard-schema'; @@ -50,7 +49,6 @@ const SECTION_COMPONENTS: Record = { 'parse-string': ParseStringSection, 'object-constructors': ObjectConstructorsSection, 'schema-modifiers': SchemaModifiersSection, - 'schema-boundaries': SchemaBoundariesSection, extensions: ExtensionsSection, 'built-in-extensions': BuiltInExtensionsSection, 'generic-schemas': GenericSchemasSection, diff --git a/websites/schema/app/docs/sections/index.ts b/websites/schema/app/docs/sections/index.ts index a86e09a5..79e3d249 100644 --- a/websites/schema/app/docs/sections/index.ts +++ b/websites/schema/app/docs/sections/index.ts @@ -19,7 +19,6 @@ export const SECTION_GROUPS = [ label: 'Composition', slugs: [ 'immutability', - 'schema-boundaries', 'discriminated-unions', 'recursive-schemas', 'parse-string', @@ -51,11 +50,6 @@ export const SECTION_GROUPS = [ ]; export const SCHEMA_SECTIONS: SchemaSection[] = [ - { - slug: 'schema-boundaries', - title: 'Schema Boundaries', - group: 'Composition' - }, { slug: 'why', title: 'Why @cleverbrush/schema?', group: 'Fundamentals' }, { slug: 'getting-started', diff --git a/websites/schema/app/docs/sections/schema-boundaries.tsx b/websites/schema/app/docs/sections/schema-boundaries.tsx deleted file mode 100644 index 385819d1..00000000 --- a/websites/schema/app/docs/sections/schema-boundaries.tsx +++ /dev/null @@ -1,58 +0,0 @@ -export default function SchemaBoundariesSection() { - return ( - <> -
-

Schemas Across Boundaries

-

- Compose optional fallbacks and named references without - duplicating validation rules. -

-
-
-

Optional fallbacks

-
-                    {`const text = string().optional().catch(undefined);
-const nullableText = string().nullable().catch(null);`}
-                
-

- Legacy optional schemas accept null at runtime. A fallback - does not replace a value that passed validation. Normalize - null explicitly if your application requires undefined; this - release does not change that compatibility behavior. -

-
-
-

One named definition

-
-                    {`const User = object({ name: string() }).schemaName('User');
-const History = object({
-    current: User,
-    previous: User.nullable().optional()
-        .describe('Previous user')
-});`}
-                
-

- Ordinary modifiers apply at the use site. The original named - definition remains unchanged, and genuinely conflicting - definitions still fail registration. Shape, rule, default, - fallback and extension changes discard inherited names. - Apply schemaName after those edits when the result needs its - own component. -

-
-
-

API documents

-

- JSON Schema, OpenAPI and AsyncAPI preserve one canonical - named definition with local reference annotations. - Independent definitions with the same name still fail - registration. Existing inference and static type overrides - are unchanged. -

- - Read the full composition guide - -
- - ); -} diff --git a/websites/schema/app/docs/sections/schema-modifiers.tsx b/websites/schema/app/docs/sections/schema-modifiers.tsx index 2a24b4df..71c123dd 100644 --- a/websites/schema/app/docs/sections/schema-modifiers.tsx +++ b/websites/schema/app/docs/sections/schema-modifiers.tsx @@ -120,6 +120,48 @@ console.log(schema.introspect().catchValue); // 'unknown'`) }} /> +

Optional and nullable fallbacks

+

+ Fallback values and factories respect the resolved output + type: optional schemas allow undefined, and + nullable schemas allow null. +

+
+                    {`const text = string().optional().catch(undefined);
+const nullableText = string().nullable().catch(() => null);
+
+text.parse(42); // undefined
+nullableText.parse(42); // null
+array(text).parse(['ok', 42]); // ['ok', undefined] — no entries dropped`}
+                
+

+ Fallbacks are opt-in. A property fallback does not make a + malformed required root object valid, and a fallback factory + runs only when validation fails. +

+

+ Legacy optional schemas accept null at runtime + even when their inferred type omits it. A fallback does not + replace a value that passed validation, so normalize null + explicitly when your application requires undefined: +

+
+                    {`const normalizedText = string().optional()
+    .addPreprocessor(value => value == null ? undefined : value)
+    .catch(undefined);
+
+normalizedText.parse(null); // undefined`}
+                
+

+ Preprocessors may return optional/nullable values, including + asynchronously. Their callback parameter types do not + guarantee that unknown input already has that type; guard + untrusted values before using type-specific methods. Use{' '} + parseAsync / validateAsync for + async callbacks. Existing InferType and{' '} + hasType behavior is unchanged; static overrides + do not convert or validate runtime values. +

{/* ── Readonly ─────────────────────────────────────── */} @@ -283,6 +325,71 @@ console.log(UserSchema.introspect().schemaName); // 'User' }} /> +

Reuse the definition with ordinary modifiers

+
+                    {`const History = object({
+    current: UserSchema,
+    previous: UserSchema.optional().nullable().describe('Previous user')
+});
+// One User component, with local annotations and nullability for previous.`}
+                
+

+ No wrapper is needed. The original remains unchanged, and + concrete builder methods, extensions, inference and nested + property selectors are preserved. Modifier chains reference + the original definition, not another alias. +

+
    +
  • + Presence/nullability: optional,{' '} + required, nullable,{' '} + notNullable. +
  • +
  • + Annotations: describe, example + , readonly. +
  • +
  • + Type-only changes: brand,{' '} + hasType, clearHasType,{' '} + optimize. +
  • +
+

Shape and rule changes become unnamed

+

+ Property edits, partial/pick/omit, constraints, validators, + preprocessors, defaults, fallbacks and their available clear + methods discard inherited names. Extension changes detach + conservatively too. Later annotations or optionality do not + reconnect the derivative; existing nested named children + still reuse their own definitions. +

+
+                    {`const PatchUser = UserSchema.partial(); // unnamed
+const UserWithEmail = UserSchema.addProp('email', string()); // unnamed
+const PublicUser = UserSchema.omit('id').schemaName('PublicUser');
+const ShortName = string().schemaName('Name').maxLength(20); // unnamed`}
+                
+

+ Apply schemaName after shape/rule edits when + the result needs a stable component name. Code that + previously relied on edits retaining an inherited name + should name the final result explicitly. +

+

+ Explicit naming always creates an independent definition, + even on an alias. Independent definitions sharing a name + still conflict, including identical shapes; use-site + modifiers of the same definition do not. +

+

+ Canonical-reference metadata affects exporters, not runtime + validation. Defaults and fallbacks follow ordinary builder + semantics; clearDefault() removes the default + completely without revealing a hidden canonical default. + JSON Schema, OpenAPI and AsyncAPI keep one canonical + definition with local annotations and nullability. +

{/* ── Promise Schemas ──────────────────────────────── */} diff --git a/websites/schema/app/schema-json/page.tsx b/websites/schema/app/schema-json/page.tsx index ea95b003..ba7b62a1 100644 --- a/websites/schema/app/schema-json/page.tsx +++ b/websites/schema/app/schema-json/page.tsx @@ -32,20 +32,6 @@ export default function SchemaJsonPage() { ]} /> -
-

Named references

-

- Use ordinary fluent modifiers to reuse a named - definition with local annotations and nullability. - Reference composition preserves the original definition - in both Draft 7 and Draft 2020-12. Shape and rule - changes detach the inherited name; apply schemaName last - when naming a new definition. -

- - Named references and optional fallbacks - -
{/* ── Installation ─────────────────────────────────── */} +

Named references and local annotations

+

+ Ordinary use-site modifiers preserve a named definition. + Direct reuse emits a $ref; modified uses + compose local annotations and nullability around it in + both Draft 7 and Draft 2020-12: +

+
+                        {`const User = object({ name: string() }).schemaName('User');
+const History = object({
+    current: User,
+    previous: User.optional().nullable().describe('Previous user')
+});
+const json = toJsonSchema(History, {
+    $schema: false,
+    nameResolver: schema => schema.introspect().schemaName ?? null
+});
+// json.required: ['current']
+// current references User; previous adds local description and nullability.`}
+                    
+

+ Use-site modifiers are processed before name resolution, + which receives their canonical target. The resolver does + not create component definitions: supply those in the + containing document or use{' '} + @cleverbrush/server-openapi. Without a + resolver, the target is converted inline. +

+

+ Shape, rule, default, fallback and extension changes + discard inherited names and export inline. Nested named + children still reuse their definitions. Apply{' '} + schemaName after those edits to name a new + definition. See the{' '} + + schema naming rules + + . +

+

+ Standard JSON Schema input() and{' '} + output() keep their existing identical + representation; no separate directional schemas are + introduced. +

{/* ── TypeScript inference ─────────────────────────── */}