diff --git a/.changeset/schema-boundaries.md b/.changeset/schema-boundaries.md new file mode 100644 index 00000000..c4c0d255 --- /dev/null +++ b/.changeset/schema-boundaries.md @@ -0,0 +1,13 @@ +--- +"@cleverbrush/schema": minor +"@cleverbrush/schema-json": minor +"@cleverbrush/server-openapi": minor +--- + +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 97bb3743..3084461a 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -156,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`: @@ -188,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-json/src/boundaries.test.ts b/libs/schema-json/src/boundaries.test.ts new file mode 100644 index 00000000..c79286aa --- /dev/null +++ b/libs/schema-json/src/boundaries.test.ts @@ -0,0 +1,123 @@ +import { object, string } from '@cleverbrush/schema'; +import { describe, expect, it } from 'vitest'; +import { withStandardJsonSchema } from './standardJsonSchema.js'; +import { toJsonSchema } from './toJsonSchema.js'; + +describe('named reference JSON Schema', () => { + it.each([ + '2020-12', + '07' + ] as const)('keeps annotations outside the definition in draft %s', draft => { + const user = object({ name: string() }).schemaName('User'); + const schema = object({ + user: user, + previous: 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({ + $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('keeps final local default and nullability modifiers', () => { + const target = string().nullable().schemaName('Name'); + 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).toBeUndefined(); + expect(json.properties.name.default).toBe('new'); + expect(json.properties.name.type).toBe('string'); + expect(json.properties.name.allOf).toBeUndefined(); + const nullableAgain = toJsonSchema(ref.nullable()); + expect(nullableAgain.type).toEqual(['string', 'null']); + expect(toJsonSchema(ref.readonly()).readOnly).toBe(true); + }); + + it('retains identical Standard JSON Schema views', () => { + 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 a322e59b..82336ba6 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -242,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) { @@ -251,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; diff --git a/libs/schema/README.md b/libs/schema/README.md index 0fc779c0..78bce142 100644 --- a/libs/schema/README.md +++ b/libs/schema/README.md @@ -1125,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. @@ -1189,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({ @@ -1210,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/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 d78d89d8..72e8ffd7 100644 --- a/libs/schema/src/builders/ObjectSchemaBuilder.ts +++ b/libs/schema/src/builders/ObjectSchemaBuilder.ts @@ -1574,7 +1574,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), acceptUnknownProps: true } as any) as any; @@ -1594,7 +1594,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), acceptUnknownProps: false } as any) as any; @@ -1615,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; } /** @@ -1633,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; } /** @@ -1721,7 +1727,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), constructorSchemas: [...this.#constructorSchemas, schema] } as any) as any; @@ -1767,7 +1773,7 @@ export class ObjectSchemaBuilder< [] > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), constructorSchemas: [] } as any) as any; @@ -1809,7 +1815,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: { ...this.introspect().properties, @@ -1844,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; } /** @@ -1917,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; @@ -1996,7 +2005,7 @@ export class ObjectSchemaBuilder< ); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: (() => { const result = { ...this.#properties }; @@ -2036,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 ) { @@ -2054,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'); @@ -2108,7 +2117,7 @@ export class ObjectSchemaBuilder< {} as Record ); - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProps } as any) as any; @@ -2168,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) => { @@ -2206,7 +2215,7 @@ export class ObjectSchemaBuilder< newProps.properties[key].optional(); }); - return this.createFromProps(newProps); + return this.derive(newProps); } if (typeof propNameOrArray === 'string') { @@ -2303,7 +2312,7 @@ export class ObjectSchemaBuilder< newProps[key] = prop.optional(); } } - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProps } as any) as any; @@ -2380,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] @@ -2406,7 +2415,7 @@ export class ObjectSchemaBuilder< return acc; }, {}); - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: newProperties } as any); @@ -2482,7 +2491,7 @@ export class ObjectSchemaBuilder< [propName]: callbackResult }; - return this.createFromProps(props) as any; + return this.derive(props) as any; } /** @@ -2543,7 +2552,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: Object.keys(this.#properties).reduce( (acc, curr) => { @@ -2569,7 +2578,7 @@ export class ObjectSchemaBuilder< TConstructorSchemas > & TExtensions { - return this.createFromProps({ + return this.derive({ ...this.introspect(), properties: Object.keys(this.#properties).reduce( (acc, curr) => { 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/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 6b119bca..c5344a38 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -245,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; }; @@ -756,6 +758,7 @@ export abstract class SchemaBuilder< #isReadonly = false; #description: string | undefined; #schemaName: string | undefined; + #referenceTarget: SchemaBuilder | undefined; #preprocessors: PreprocessorEntry[] = []; #validators: ValidatorEntry[] = []; #hasMutating = false; @@ -807,13 +810,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 }, …] }` @@ -910,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'`). */ @@ -1460,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()`. */ @@ -1481,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; } /** @@ -1495,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; } /** @@ -1506,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; } /** @@ -1534,7 +1575,7 @@ export abstract class SchemaBuilder< * ``` */ public default(value: TResult | (() => TResult)) { - return this.createFromProps({ + return this.derive({ ...this.introspect(), defaultValue: value }) as any; @@ -1555,7 +1596,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,8 +1628,12 @@ export abstract class SchemaBuilder< * c.validate(42); // { valid: true, object: 'anon' } ← also fires * ``` */ - public catch(value: TResult | (() => TResult)): this { - return this.createFromProps({ + public catch( + value: + | ResolvedSchemaType + | (() => ResolvedSchemaType) + ): this { + return this.derive({ ...this.introspect(), catchValue: value, hasCatch: true @@ -1596,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; @@ -1622,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; } /** @@ -1645,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; } /** @@ -1664,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'; @@ -1679,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; } @@ -1700,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; } /** @@ -1723,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; } /** @@ -1734,32 +1804,45 @@ 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; } /** - * 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. + * Unknown external data still needs runtime validation. */ public addPreprocessor( - preprocessor: Preprocessor, + preprocessor: ( + object: TResult + ) => + | ResolvedSchemaType + | Promise>, options?: { mutates?: boolean } ): this { if (typeof preprocessor !== 'function') { throw new Error('preprocessor must be a function'); } - return this.createFromProps({ + return this.derive({ ...this.introspect(), preprocessors: [ ...this.preprocessors, @@ -1772,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: [] }); @@ -1788,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, @@ -1801,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: [] }); @@ -1997,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, @@ -2121,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 new file mode 100644 index 00000000..b22b897d --- /dev/null +++ b/libs/schema/src/builders/references.test-d.ts @@ -0,0 +1,57 @@ +import { expectTypeOf, test } from 'vitest'; +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 + 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 = 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 = 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/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..8290de49 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -141,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'); @@ -153,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) @@ -446,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/libs/server-openapi/src/boundaries.test.ts b/libs/server-openapi/src/boundaries.test.ts new file mode 100644 index 00000000..a9fd4bc0 --- /dev/null +++ b/libs/server-openapi/src/boundaries.test.ts @@ -0,0 +1,170 @@ +import { + array, + lazy, + object, + type SchemaBuilder, + string +} 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('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: user, + previous: user.nullable().optional().describe('Previous') + }); + const contract = endpoint + .post('/history') + .body(history) + .responses({ 200: history }); + const spec = generateOpenApiSpec({ + registrations: [ + { endpoint: contract.introspect(), handler: () => {} } + ], + info: { title: 'References', version: '1' } + }) as any; + 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; + expect(request).toEqual(response); + expect(request.required).toEqual(['current']); + expect(request.properties.current.$ref).toBe( + '#/components/schemas/User' + ); + expect(request.properties.previous.description).toBe('Previous'); + expect(spec.components.schemas.User).toMatchObject({ + type: 'object', + required: ['name'] + }); + }); + + it('preserves strict instance-based naming conflicts', () => { + const user = object({ name: string() }).schemaName('User'); + const registry = new SchemaRegistry(); + walkSchemas( + object({ + current: user, + previous: user.nullable().optional() + }), + registry + ); + expect([...registry.entries()].map(([name]) => name)).toEqual(['User']); + 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/); + }); + + 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(() => node.optional())) + }).schemaName('Node'); + const contract = endpoint.get('/nodes').responses({ 200: node }); + const openapi = generateOpenApiSpec({ + registrations: [ + { endpoint: contract.introspect(), handler: () => {} } + ], + info: { title: 'Nodes', version: '1' } + }) as any; + const asyncapi = generateAsyncApiSpec({ + subscriptions: [ + { + endpoint: { + protocol: 'subscription', + basePath: '/ws', + pathTemplate: '/nodes', + incomingSchema: node, + outgoingSchema: 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 + ); + }); + + 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/generateAsyncApiSpec.ts b/libs/server-openapi/src/generateAsyncApiSpec.ts index 4f75278f..9bf6c091 100644 --- a/libs/server-openapi/src/generateAsyncApiSpec.ts +++ b/libs/server-openapi/src/generateAsyncApiSpec.ts @@ -326,7 +326,14 @@ export function generateAsyncApiSpec( if (!registry.isEmpty) { const schemas: Record = {}; for (const [name, schema] of registry.entries()) { - schemas[name] = convertSchema(schema, registry); + let rootInlined = false; + 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/schemaRegistry.ts b/libs/server-openapi/src/schemaRegistry.ts index 009bfa45..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,7 +127,16 @@ export function walkSchemas( const info = schema.introspect() as any; + if (info.referenceTarget) { + walkSchemas(info.referenceTarget, registry, visited); + return; + } + switch (info.type) { + 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..0e815725 100644 --- a/websites/docs/app/server-openapi/page.tsx +++ b/websites/docs/app/server-openapi/page.tsx @@ -269,11 +269,44 @@ const CreateUserBody = object({ address: AddressSchema, name: string() });` />

- 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/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 4f3120a4..ba7b62a1 100644 --- a/websites/schema/app/schema-json/page.tsx +++ b/websites/schema/app/schema-json/page.tsx @@ -455,6 +455,51 @@ const withRefs = toJsonSchema(ProductSchema, { }} /> +

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 ─────────────────────────── */}