Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions packages/core-internal/src/validators/ajvProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function createDefaultAjvInstance(): AjvLike {
* ```
*/
export class AjvJsonSchemaValidator implements jsonSchemaValidator {
private readonly _ajv: AjvLike;
private _ajv: AjvLike | undefined;
/** True iff the constructor received a caller-supplied engine; the `$schema` check is skipped. */
private readonly _userAjv: boolean;

Expand All @@ -88,12 +88,19 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
* used for **every** schema regardless of its declared `$schema` (the caller owns dialect
* choice). When omitted, the provider constructs a single `Ajv2020` instance with
* `strict: false`, `validateFormats: true`, `validateSchema: false`, `allErrors: true`, and
* `ajv-formats` registered. The parameter is typed structurally so consumers who don't pass an
* instance need not have `ajv` installed.
* `ajv-formats` registered — **lazily, on the first {@linkcode getValidator} call**, so
* constructing the provider (e.g. as the default validator of a `Client`/`Server` that never
* validates a JSON Schema) does not pay the ajv + ajv-formats instantiation cost. The parameter
* is typed structurally so consumers who don't pass an instance need not have `ajv` installed.
*/
constructor(ajv?: AjvLike) {
this._userAjv = ajv !== undefined;
this._ajv = ajv ?? createDefaultAjvInstance();
this._ajv = ajv;
}

/** The underlying engine — the default instance is created on first use. */
private get ajv(): AjvLike {
return (this._ajv ??= createDefaultAjvInstance());
}

getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
Expand All @@ -113,10 +120,11 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
);
}

const engine = this.ajv;
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema))
: this._ajv.compile(schema);
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
: engine.compile(schema);

return (input: unknown): JsonSchemaValidatorResult<T> => {
const valid = ajvValidator(input);
Expand All @@ -130,7 +138,7 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
: {
valid: false,
data: undefined,
errorMessage: this._ajv.errorsText(ajvValidator.errors)
errorMessage: engine.errorsText(ajvValidator.errors)
};
};
}
Expand Down
57 changes: 57 additions & 0 deletions packages/core-internal/test/validators/ajvLazyInit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';

import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';

/** Peeks at the provider's private engine slot without triggering the lazy getter. */
function engineSlot(provider: AjvJsonSchemaValidator): unknown {
return (provider as unknown as { _ajv: unknown })._ajv;
}

describe('AjvJsonSchemaValidator lazy engine construction', () => {
it('does not construct the default Ajv engine until the first getValidator call', () => {
const provider = new AjvJsonSchemaValidator();
expect(engineSlot(provider)).toBeUndefined();

const validate = provider.getValidator<{ a?: string }>({ type: 'object', properties: { a: { type: 'string' } } });
expect(engineSlot(provider)).toBeDefined();
expect(validate({ a: 'x' }).valid).toBe(true);
expect(validate({ a: 1 }).valid).toBe(false);
});

it('reuses one engine across getValidator calls', () => {
const provider = new AjvJsonSchemaValidator();
provider.getValidator({ type: 'string' });
const first = engineSlot(provider);
provider.getValidator({ type: 'number' });
expect(engineSlot(provider)).toBe(first);
});

it('uses a caller-supplied engine immediately and never replaces it', () => {
let compiles = 0;
const fake = {
compile: () => {
compiles += 1;
return Object.assign(() => true, { errors: undefined });
},
getSchema: () => undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorsText: (_errors?: any) => ''
};
const provider = new AjvJsonSchemaValidator(fake);
expect(engineSlot(provider)).toBe(fake);

const validate = provider.getValidator({ type: 'object' });
expect(compiles).toBe(1);
expect(validate({}).valid).toBe(true);
expect(engineSlot(provider)).toBe(fake);
});

it('still rejects non-2020-12 $schema dialects before constructing the default engine', () => {
const provider = new AjvJsonSchemaValidator();
expect(() => provider.getValidator({ $schema: 'http://json-schema.org/draft-07/schema#', type: 'object' })).toThrow(
/unsupported dialect/
);
// The dialect check fires before engine construction — no engine was built for the rejected schema.
expect(engineSlot(provider)).toBeUndefined();
});
});
Loading