Files
cereale/src/config.ts
T
Claude 6e458fdd43 ⚡ perf: memoize per-class plans; add depth guard and per-index each reporting
Profiling the validator showed roughly half of all validation time re-deriving
answers that cannot change — collectConstraints 22%, getOwnMetadata 12%,
getMetadataChain 9%, getProperties 4%, getMetadata 3%, plus 8% GC from the
allocation churn. The constraint predicates themselves were under 1%.

Decorator metadata is fixed once classes are declared, so the derived structures
are now memoized per prototype: the validation plan, the serialization plan, the
deserialization plan, and serializer/deserializer instances, which were being
constructed fresh for every property of every object. MetadataStorage carries a
version counter that invalidates the caches when metadata is written, so
registerDecorator after first use still works — covered by a test.

Measured against JSON.parse + JSON.stringify as a fixed reference:

  validate    (50 orders)   221.6 us -> 49.6 us   4.5x
  validate    (10 orders)    47.8 us -> 12.9 us   3.7x
  toInstance  (50 orders)   255.1 us -> 74.0 us   3.4x
  toInstance  (10 orders)    64.6 us -> 19.0 us   3.4x
  toPlain     (50 orders)   294.4 us -> 95.3 us   3.1x

Reliability, in the same pass:

- maxDepth option (default 64) on every mapping function, on validate(), and on
  configure(). All three engines recurse, so a payload nested thousands of levels
  deep could exhaust the call stack. Cycles were already handled; legitimate deep
  nesting was not bounded.
- each: true failures now name the element that failed ("failed at index 3"). A
  bad entry in a 200-item array previously produced a message that could not
  locate it. A message function now receives the failing element as args.value
  rather than the whole array; caller-supplied strings stay verbatim.

150 tests (up from 136), all green on the existing suite unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-04 11:36:53 +00:00

86 lines
2.9 KiB
TypeScript

import { NamingStrategy } from './naming.js';
/**
* How an incoming key that maps to no known property should be treated.
*
* - `allow` (default): copy it onto the instance untouched, preserving the previous behaviour.
* - `strip`: drop it, so instances only ever carry declared properties.
* - `error`: reject the payload with a {@link JsonMappingError}.
*/
export type UnknownKeyPolicy = 'allow' | 'strip' | 'error';
export interface TransformOptions {
/**
* Validate the result and throw {@link JsonValidationError} on failure.
*
* Defaults to `true`, matching the behaviour of every previous release. Set it to `false`
* to map without validating — useful when you want to inspect a partially-valid payload,
* or when validation happens elsewhere in your stack.
*/
validate?: boolean;
/**
* Naming convention used on the JSON side for properties without an explicit
* `@JsonProperty`. Defaults to `identity` (property names are used as-is).
*/
namingStrategy?: NamingStrategy;
/** What to do with incoming keys that match no declared property. Deserialization only. */
unknownKeys?: UnknownKeyPolicy;
/**
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
*
* All three engines recurse, so a hostile payload nested thousands of levels deep would
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
*/
maxDepth?: number;
}
/** Options that can be set once for the whole application via {@link configure}. */
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
const DEFAULTS: Required<GlobalOptions> = {
namingStrategy: 'identity',
unknownKeys: 'allow',
validate: true,
maxDepth: 64,
};
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
/**
* Sets library-wide defaults, so an application that consistently speaks `snake_case` does
* not have to repeat itself at every call site.
*
* ```ts
* configure({ namingStrategy: 'snake_case', unknownKeys: 'strip' });
* ```
*
* Per-call options always take precedence over these.
*/
export function configure(options: GlobalOptions): void {
globalOptions = { ...globalOptions, ...options };
}
/** Returns the current library-wide defaults. */
export function getConfig(): Required<GlobalOptions> {
return { ...globalOptions };
}
/** Restores the library-wide defaults to their original values. */
export function resetConfig(): void {
globalOptions = { ...DEFAULTS };
}
/** Merges per-call options over the library-wide defaults. */
export function resolveOptions(options?: TransformOptions): Required<GlobalOptions> {
if (!options) return globalOptions;
return {
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
validate: options.validate ?? globalOptions.validate,
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
};
}