c48a106a051de9430e2b6f441afef3b610ae3ed5
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c48a106a05 |
🌳 fix: make cereale/min tree-shake too, and correct what I overclaimed
An audit of the last commit found two real problems and several claims of mine that went further than the evidence. **cereale/min was not tree-shakable.** scripts/build-bundle.mjs used esbuild's `minify: true`, whose minifyWhitespace pass strips comments — /*#__PURE__*/ annotations included. The published entry point therefore reproduced exactly the bug the previous commit fixed: one decorator came out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from the per-module entry. Every source-level check stayed green, because they all bundled src/ and the annotations are stripped on the way into dist/. It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire for a file that behaves correctly however it is used. One decorator via cereale/min is now 1,996 bytes. The build asserts the annotation count survives, and treeshake.test.ts now bundles the published artifact as well as the source — the gap that let this through. **sideEffects was partly inert.** `./dist/cjs/metadata.js` could never match: the build writes dist/cjs/package.json, which becomes the nearest descriptor for everything beneath it, so bundlers read sideEffects from there. That file now carries its own declaration. `./src/metadata.ts` was missing while src/ is published, which declared the Symbol.metadata install droppable in the source tree. Five module-scope caches in utils.ts are annotated for the same reason as the rules. I checked the audit's third blocker — that the Symbol.metadata install is dropped by bundlers — and it is not. It survives every case where it is load-bearing (a decorator import, toPlainSync, modelOf, and a decorated model bundled with an app). It is dropped only when importing nothing but flattenErrors, which needs no metadata, so that is correct. Corrections to my own wording: - "all three bundlers land within 20 bytes" held only for the one-decorator row; larger imports differ by up to a few hundred bytes - "measured through three bundlers, and pinned by a test" read as though the test covered all three; it covers esbuild, on source and on dist - "every rule is a top-level call" — 30 of the 68 are - the docs page "loads nothing from the network" — it fetches its own vendored compiler, same-origin, on first Run. It has no third-party dependencies, which is the claim I should have made - cereale/min's size, everywhere it appears Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
2a2f7345ad |
🌳 perf: make the library actually tree-shakable
Importing one decorator pulled in the message and validator of all 68 — 4,909 bytes instead of 1,837 through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing warned. The library was simply about three times heavier than it needed to be in every consumer's bundle. Every rule is a top-level call: `export const IsString = rule(...)`. rollup proves such a call side-effect-free by reading the factory, which is why it was already emitting 1,823 bytes — and why a single-bundler measurement would have shown no problem at all. esbuild and webpack do not do that analysis and keep the call. Thirty declarations now carry /*#__PURE__*/, which tsc preserves into the ESM emit, and all three bundlers now land within 20 bytes of each other. Measured, minified, esbuild / rollup / webpack: flattenErrors 287 / 292 / 291 one decorator 1837 / 1823 / 1818 validateSync 3722 / 3554 / 3823 toPlainSync 7744 / 7769 / 7832 toInstanceSync 7900 / 7942 / 7956 a typical DTO 10395 / 10402 / 10372 everything 26266 / 25671 / 26879 The serializer and deserializer drop independently — read JSON and you do not pay for writing it. Both mapping entry points keep the validator, because `validate` defaults to true and that is a real reference rather than a missed optimisation. The annotations are a promise to the bundler, so I checked the three factories they cover: rule, pattern and affix each return a closure and touch nothing outside themselves. A false promise here would mean silent deletion in someone else's production build. src/treeshake.test.ts pins the property. It asserts content rather than only bytes — it names the rules that must not appear — and one case asserts everything IS present when everything is used, so a "shaken" result cannot come from a bundle that failed to build. That mattered: two earlier passes at this measurement reported a clean sweep of shaken symbols because rollup had failed to resolve its entry and grep was reading missing files as absence. Strip the annotations and the test fails with `"must be a latitude" should have been shaken out`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
b4f0657d09 |
📦 feat: add cereale/min, and a verified framework guide
**cereale/min** — the library flattened into one minified ES module, 25.5 KB / 8.6 KB gzipped, for import maps, <script type="module">, Deno and Workers. Built from dist/esm/index.js, so the decorator lowering and the ES2025 target are tsc's; esbuild only flattens and minifies. It is an addition rather than a replacement, and the measurement is the reason. Bundled through esbuild the flat and per-module builds land within 2 bytes of each other; through rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake out of both. With the size argument a wash, per-module stays the default import for the one thing it does better — readable stack traces without source maps. (My first pass at that measurement reported "shaken" for every symbol because both rollup builds had failed and grep was reading missing files as absence. The check now asserts the bundle is non-empty and that a *used* symbol is present, so it can tell a real result from a broken harness.) **FRAMEWORKS.md** — a recipe per framework, each one run before it was written, with the versions and date verified against. Angular works, which was not obvious: the CLI scaffolds experimentalDecorators: true, but ngtsc erases @Component and @Injectable into static properties rather than leaning on TypeScript's decorator emit. Flip the flag and both systems coexist. Verified with ngc on Angular 21.2 with strictTemplates — templates still type-check and a wrong cereale rule is still TS1240 inside the Angular build. Next.js cannot work inline, structurally: it derives both the SWC parser's decorator support and the transform mode from the one flag, so on gives legacy emit and off makes @ a syntax error. NestJS cannot either — its DI needs design:type from emitDecoratorMetadata. Both have the same answer: keep the cereale classes in a package compiled by tsc and import the built output. Verified inside a program with BOTH legacy flags on, alongside @Injectable() — mapping and validation work, and the compile-time guarantee still holds where the rules are written. Also verified: Bun 1.3 needs no configuration, and a real Vite 8 build with the plugin works where the same build without it silently leaves decorator syntax in the bundle. Version 0.4.0: cereale/min is a new public entry point, and cutting a minor keeps the existing v0.3.0 tag meaningful instead of force-moving it onto a commit it was never cut from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
fd42675d3d |
🔗 docs: link the live site, now that Pages is confirmed
Pages serves docs/ from main, so the page rebuilt in #6 is live at avalon-vanguard.github.io/cereale. The README pointed at the local file because the URL could not be verified from here; it now links the site and keeps the local instructions as the fallback. package.json homepage moves there too — npm renders it as the package's headline link, and a live playground is a better landing spot than an anchor inside the README. Adds canonical and Open Graph tags. No og:image: a preview card with a broken image is worse than one without, and there is no artwork yet. check-docs.mjs flagged the canonical link as a remote subresource, which it is not — the browser never fetches it. Rather than exempt the URL, the check now looks at rel and only flags the relations that actually fetch or connect. Verified it still catches a CDN stylesheet, a preconnect and a script src; a check that cannot tell a declaration from a request is one that gets switched off the first time it is wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
e626a1003a |
🔧 fix: generate .nojekyll instead of hand-editing generated files
The previous commit appended the .nojekyll rationale to docs/vendor/README.md — a file whose own first line reads "Generated by npm run build:docs. Do not edit by hand." CI's "Landing page bundle is in sync with src/" step regenerated it, the text vanished, git diff was non-empty and all three Node jobs failed. The guard did its job; I was the one who put a hand-written paragraph in a generated file. The note now lives in the generator, and build:docs writes docs/.nojekyll itself so it is part of the generated set rather than a loose file that a docs/ rewrite could drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
364f44f4bf |
📄 chore: serve the docs folder verbatim on GitHub Pages
Pages runs Jekyll by default. Jekyll ignores paths beginning with an underscore and carries default `vendor/` exclusions, neither of which suits a hand-built page — and the failure mode is an asset that silently does not publish, which for this page means the playground's compiler 404s and the Run button dies exactly the way the old CDN-based one did. `.nojekyll` opts out, so what is in docs/ is what gets served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
8bd770e343 |
🔧 ci: fetch tags in the release workflow
The version gate resolves `v$VERSION` with git, but actions/checkout does not fetch tags at its default depth — so the guard would have failed every release with "No tag v0.3.0" whether or not the tag existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
9796d599dc |
📦 chore: make the published package actually complete
`files` was ["dist"], but dist carries 256KB of .js.map and .d.ts.map whose `sources` point at ../../src/*.ts — which was not published. Every shipped sourcemap resolved to nothing: 44% of the tarball, dead weight. The source is 116KB and its comments are the most detailed explanation of why the engine does what it does, so it now ships (tests and the demo excluded) and the maps resolve. Verified from a real `npm pack` install: both utils.js.map and utils.d.ts.map now resolve to a file that exists, so stepping into cereale in a debugger and "go to definition" from a decorator both land in the real TypeScript. Also: CHANGELOG.md ships; the repository/homepage/bugs URLs said Avalon-Vanguard and only worked via GitHub's redirect, now lowercase to match the org; publishConfig.access is explicit so a later move to a scoped name cannot quietly attempt a private publish. Adds a Publish to npm workflow, deliberately manual — pushing a tag does not publish, because a tag is a decision to cut a release and publishing is a decision to make it public and immutable. It asserts the tag exists and points at the commit being published, refuses a version already on the registry, runs the full verify gate, prints the file list, and defaults to a dry run. Checked end to end against the tarball: a strict consumer (no skipLibCheck, no DOM lib) compiles and runs against both `cereale` and `cereale/vite`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
478f852b42 |
🔒 fix!: refuse names that no longer reach their property
A rename did not actually take effect. @JsonProperty stopped the old name
from being *mapped*, but not from being *accepted*: unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key fell through to the
unknown-key policy, and the default `allow` copied it onto the instance
untouched.
The value therefore landed on a declared property having skipped everything
declared for it:
@JsonProperty('home_address') @JsonType(() => Addr) @ValidateNested()
address!: Addr;
toInstanceSync(Order, { address: { city: 'Paris' } })
-> address is a plain object, instanceof Addr === false
-> validateSync() returns [] <- nothing complains
A payload aimed at the previous version of a class was accepted in part, in
silence. Three routes led to the same hole, and all three are now closed:
- the property key of a field renamed with @JsonProperty
- the raw key of a field a naming strategy renders differently
(`firstName` under snake_case)
- the property key of a field that is both renamed and @JsonReadOnly, which
was still settable under its own key
Refused, not swallowed. A stale name is a mismatch with whatever produced
the payload, not a deliberate refusal like @JsonReadOnly, so it is kept in
its own map rather than lumped into `blocked`: under unknownKeys: 'error'
it is still reported, and the report now names the property it was reaching
for and what that property is called now.
"ref" is not a JSON name for Order: property "ref" is mapped to
"order_ref". Send that name, or add @JsonAlias("ref") to keep
accepting this one.
@JsonAlias still keeps an old name working, and a key that some *other*
property legitimately answers to is still mapped to that property — both
asserted. The resolution happens once when the name map is built, which is
memoized per class and naming strategy, so deserialization is unchanged at
~45µs for 50 nested orders.
The behaviour this replaces was pinned by tests two commits ago, pending
this decision; those tests now assert the fix, and six more cover the
naming-strategy, read-only, alias and key-collision cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
057f008ac0 |
🔍 fix: correct the last three findings from the page review
Of 26 findings raised across five auditors, 23 were refuted on a second
pass. These three survived.
**A renamed property's old key is still writable.** The README, the landing
page and two doc comments all said that once a property carries
@JsonProperty its original name "is no longer accepted on input". It is no
longer *mapped* — but it is not rejected either. Unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key falls through to the
unknown-key policy, and the default `allow` copies it onto the instance
untouched. Reproduced against dist:
@JsonProperty('home_address') @JsonType(() => Addr) @ValidateNested()
address!: Addr;
toInstanceSync(Order, { address: { city: 'Paris' } })
-> address is a plain object, instanceof Addr === false
-> validateSync() returns [] <- nothing complains
-> round-trips out as home_address <- silently accepted
Blocking the old key would fix it, but would also swallow the
`unknownKeys: 'error'` report a strict caller gets today, which is arguably
the more useful signal. That is a judgement call the library has not made,
so this commit states the behaviour accurately everywhere it was stated
wrongly and pins it with five tests covering the default, `strip`, `error`
and the @JsonAlias fix — so it cannot drift either way while the question
is open.
**@IsNotEmpty and @IsEmpty are not complements.** `[]` and `{}` pass BOTH:
isNotEmpty checks only null/undefined/'' while isEmpty also treats empty
arrays and objects as empty. Listed one line apart as "must not be empty" /
"must be empty", they invited exactly the wrong inference.
**unknownKeys is deserialization-only**, in a group whose blurb says these
apply per call or via configure().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
8c9aea3062 |
📐 fix: correct what the adversarial review of the page found
Five auditors read the finished page against the source; a second pass tried to refute each finding. What survived: **The esbuild row was wrong, and dangerously so.** It said esbuild takes "the same settings via tsconfigRaw" as tsc. It does not: esbuild lowers standard decorators only when its own *top-level* `target` is below `esnext`. A `target` inside `tsconfigRaw` sets the `useDefineForClassFields` default and nothing else. I ran it — the decorator survives verbatim and the module throws SyntaxError on import, which is the exact silent passthrough the section blames on oxc. The repo's own vite plugin and toolchain test always passed `target` top-level, so the executed matrix never backed the advice the docs gave. Both halves are now asserted in src/toolchain.test.ts. **"This table is executed by a test" did not cover the oxc row** — the only ✗, and the row the whole section is built around. It cannot be: oxc ships as a native binary with no standalone transform API, which the test file already said in a comment. Fixed on the page and in the README. Reference corrections, each verified against the source: - fromRequest has no …Sync twin; the group blurb claimed every entry did - @IsNotIn does not narrow its field, unlike its five neighbours - @MinDate/@MaxDate take a Date as well as a thunk - @Validate has three parameters, not two; defineRule has four - getConfig() and resetConfig() were missing from a group rendered under the heading "Everything cereale exports" And on the page itself: the vite.config.ts snippet never imported defineConfig, so pasting it failed; and the plugin note omitted that .tsx is excluded by default, which would drop a reader straight back into the 0-test hole the section exists to describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
c828f5cfe9 |
🌾 feat: rebuild the landing page, and stop it from rotting again
The old page had been quietly broken for some time. It loaded
@babel/standalone from an **unpinned** CDN URL, which rolled over to Babel
8 and dropped the `proposal-class-properties` plugin the page asked for, so
Babel.transform threw before it ever reached the decorators — and the
decorator config it passed was `{ legacy: true }`, which 0.2.0 had already
made wrong. Nothing on the page said so. The copy was still selling the
0.1.0 pitch ("Spring-like"), listed about half the decorators, showed
`npm install cereale` for a package the registry returns 404 for, and
claimed "Zero overhead" against a README that publishes the real
microsecond costs.
The rebuild is one self-contained page: hand-written CSS, no Tailwind CDN,
no CodeMirror, and a vendored compiler pinned by package.json. It loads
nothing from the network. The playground runs the real bundled library
across six examples, all verified in a headless browser. The reference
covers all 68 decorators and the full API, counted from the bundle at
runtime so it cannot drift.
The hero's compiler error is not typed into the HTML. scripts/build-docs.mjs
compiles the snippets with the real tsc and writes the verbatim diagnostics
into docs/diagnostics.js, failing the build if a snippet the page calls a
compile error ever compiles — and two snippets that must compile guard
against the harness passing vacuously.
Three guards keep it honest, all wired into CI:
- check:docs fails on any remote subresource
- build:docs + git diff fails if docs/ is stale against src/
- check:types compiles a consumer against dist/ with no DOM lib, no
@types/node and no skipLibCheck
That last one found a real packaging defect: `fromRequest` was declared as
taking the global `Request`, so cereale's own published .d.ts raised
"Cannot find name 'Request'" in any project whose lib and types did not
happen to supply it — inside a dependency, in code they may never call, and
unfixable from the outside. It now takes a structural JsonBody, which a
Request still satisfies. The library's own type tests had been hiding it by
enabling both DOM and skipLibCheck.
An adversarial review of the finished page caught four more: the lede
claimed *every* rule is type-checked (@IsDefined and @IsNotIn deliberately
are not), the guarantee section was wrong about the mechanism (a legacy
decorator does get design:type under emitDecoratorMetadata — the real claim
is about its type signature), one sample called a Movie method on a Media[]
and did not compile, and "nested objects come back as real classes" omitted
that you have to declare them. WCAG contrast was measured rather than
eyeballed: seven real failures fixed in the two themes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
0938300477 |
🔊 feat!: make the three silent failures loud
Every change here answers one question: where does cereale currently fail
without saying so?
**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.
**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.
**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.
Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.
The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.
193 -> 249 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
d56c47d55c |
🔖 chore: number the standard-decorator line 0.2.0, not 2.0.0
Nothing has been published to npm, so 2.0.0 claimed a 1.0.0 predecessor that no one could install. Staying on 0.x states what is true — the API may still move — and under semver a breaking change is then a minor bump, which is precisely the relationship between the two lines: 0.1.x keeps legacy experimentalDecorators, 0.2.x moves to TC39 standard decorators. The break itself is unchanged and still documented; only the number and the references to the other line moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
305be7a16f |
🔒 fix: resolve the metadata symbol into a binding, not a per-use lookup
Review on #4 flagged that the Symbol.metadata polyfill is a module-level side effect while package.json declares "sideEffects": false, so a bundler is permitted to drop the module. Checking it narrowed the concern and corrected half of it. The decorator transforms are not exposed: esbuild's helper is __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name) which already falls back. The exposure was in this library's own read path, which used `Symbol.metadata` directly. Had the symbol been absent, `clazz[undefined]` would read a property literally named "undefined", modelOf() would return an empty model, and every object would validate clean — silent success, the worst failure mode a validation library can have. The key is now resolved once into METADATA_KEY, with the same Symbol.for fallback the transforms use, and all reads and writes go through it. The global assignment stays for consumer emit that reads Symbol.metadata directly, and package.json now lists metadata.js under sideEffects so bundlers keep it. 193 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
297d3bfe77 |
✨ feat!: v2 — strongly typed decorators on the TC39 standard
BREAKING CHANGE: cereale moves from legacy `experimentalDecorators` to TC39
standard decorators, which is what makes validation rules type-checked against
the fields they are attached to.
class User {
@IsString() name!: string; // fine
@IsString() age!: number; // Type 'number' is not assignable to 'string'
}
Legacy decorators receive (target: any, key: string) and lose the field type
entirely, so this was impossible in v1. Standard decorators receive
ClassFieldDecoratorContext<This, Value>, which carries it. Rules now checked:
scalar rules against scalar fields; { each: true } against arrays, in both
directions; @JsonType against the field's class; @JsonSerialize/@JsonDeserialize
against the field's type; @IsIn and @IsEnum against the field's value type.
17 tests invoke the real compiler to assert the wrong code stays rejected — a
guarantee nobody checks is one that quietly stops holding.
Positioning follows the capability: validated domain objects, not validated
data. The README now leads with the Zod comparison. Cereale does not infer your
type from a schema — you still write the field type and the rule — but it
guarantees the two cannot disagree, which is what class-validator never offered.
Removed
- metadata-storage.ts and its WeakMap singleton. Metadata lives on
context.metadata now, which also removes the dual ESM/CJS double-singleton
hazard. Inheritance merging becomes structural rather than reconstructed on
every read, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
reoccur by construction.
- registerDecorator, replaced by defineRule(Class, 'field', constraint).
Unchanged: the engine, options, naming strategies, access control, error
helpers, the sync API, and the performance work. 193 tests pass.
Toolchain note: standard decorators are transformed by tsc and esbuild, but not
yet by oxc. The library builds with tsc and consumers on esbuild/Vite are fine;
Vitest 4 uses oxc, so the test runner needs an esbuild transform plugin. This is
recorded in vitest.config.ts and the README, and is the reason 1.x should stay
available for oxc-based toolchains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
6d30182ca8 |
✨ feat: add a synchronous API and redact write-only values from errors
Synchronous API --------------- Nothing on the default path is genuinely asynchronous - only a serializer, deserializer or validator the caller supplies can be - so requiring `await` everywhere taxed the common case. Rather than duplicating the traversal into a second sync copy (the traversal is exactly where the eight defects fixed in 0.1.0 lived, and two copies would drift), the engines are now written synchronously and anything a hook makes asynchronous is recorded and reconciled once at the end. A `*Sync` call that encounters a Promise raises a JsonMappingError naming the async alternative instead of returning a half-built object. Adds validateSync, validateOrRejectSync, toPlainSync, toJsonSync, toInstanceSync, toInstanceArraySync, fromJsonSync, fromJsonArraySync. fromRequest has no synchronous form, since reading a request body is inherently async. Removing the per-property await also sped up the async path substantially. With the plan caching from the previous release, against JSON.parse + JSON.stringify (5.8 us) as a fixed reference: validate (50 orders) 221.6 us -> 17.8 us 12.4x validate (10 orders) 47.8 us -> 4.5 us 10.6x toPlain (50 orders) 294.4 us -> 36.0 us 8.2x toInstance (50 orders) 255.1 us -> 31.7 us 8.0x toInstance (single) 19.8 us -> 5.2 us 3.8x Write-only redaction -------------------- A @JsonWriteOnly password that failed @MinLength put the rejected password into ValidationError.value, and from there into any log that recorded the error. Values of properties that never leave the process - @JsonWriteOnly and @JsonIgnore - are now replaced with the exported REDACTED placeholder. The property name and failure message are unchanged, so the error stays actionable. Tests ----- 176 tests, up from 150. The new suite covers the sync family, its refusal of async hooks (including that refusing does not leave an unhandled rejection), and async hooks through the async API - serializers, deserializers, validators, and async validators under each: true, which the suite had never exercised. That last group caught a regression this change introduced: with an async serializer the deferred write appended its key after the synchronous ones, changing property order in the output. The slot is now claimed before deferring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
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
|
||
|
|
2acdf3b360 |
🔧 ci: run the pipeline on develop as well as main
The PR now targets develop, which was branched from main. The workflow only triggered on push and pull_request against main, so nothing would have run on develop or on any future PR into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |
||
|
|
83ad2a289d |
📝 docs: bring README, demo and playground in line with the code; release 0.1.0
The README described a library that did not exist in places. It showed
@ValidateNested({ each: true }), which did not compile; told users to enable
emitDecoratorMetadata, which the library never reads; and documented none of the
mapping API. Its Quick Start now runs verbatim — verified by compiling and
executing it against the local source.
- README: document field-name mapping, access control, options, error helpers
and the 30 new validators; drop the emitDecoratorMetadata instruction; add a
Notes and Limitations section covering circular references, validate() on
plain objects, and the fact that @JsonProperty stops the original name from
being accepted unless you add @JsonAlias
- CHANGELOG.md: new, covering 0.1.0
- example.ts: rewritten as a tour of the current API — read-only ids, write-only
secrets, renamed fields, conditional validation, flattened errors, and a
base-class rule reaching a subclass
- docs: the playground hand-listed its symbol table in three parallel places and
exposed IsEmail, which is not an export. It now derives scope from the bundle,
so new decorators work there as soon as they ship. Bundle regenerated
- version 0.1.0
136 tests, 97% statement and 100% function coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
44c8f28f4b |
✨ feat: add 30 validation decorators, @ValidateIf and @Allow
Rounds out the validator set with the rules an application actually reaches for,
all following the existing decorator style and honouring each/message options.
- equality and presence: @Equals, @NotEquals, @IsEmpty, @IsEnum, @IsInstance
- strings: @Length, @IsAlpha, @IsAlphanumeric, @IsNumberString, @IsLowercase,
@IsUppercase, @Contains, @NotContains, @StartsWith, @EndsWith
- formats: @IsUUID, @IsJSON, @IsDateString, @IsSemVer, @IsHexColor, @IsIP
- numbers: @IsDivisibleBy, @IsPort, @IsLatitude, @IsLongitude, @IsBigInt
- dates: @MinDate, @MaxDate
- arrays: @ArrayUnique, @ArrayContains, @ArrayNotContains
@ValidateIf(o => ...) makes a property's rules conditional on the rest of the
object, and @Allow() declares a property that needs no rules of its own so it
survives unknownKeys: 'strip'.
Two details worth noting. @IsEnum filters the reverse mapping a numeric enum
compiles to, so 'Low' is not accepted as a value of enum { Low, High }.
@MinDate/@MaxDate accept a thunk, so "not in the past" is evaluated per
validation instead of being frozen when the class was declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
666762a146 |
✨ feat: add field-name mapping, access control and transform options
A library whose headline feature is "JSON mapping" could not map a name: there
was no way to read {"first_name": ...} into firstName, no way to keep a password
out of the response, and no way to parse a payload without validating it.
Name mapping
- @JsonProperty(name) renames a property in both directions
- @JsonAlias(...names) accepts extra names on input only, so a field can be
renamed without breaking older clients
- naming strategies (snake_case, kebab-case, SCREAMING_SNAKE_CASE, PascalCase,
camelCase, or your own function) for properties with no explicit name.
Acronyms split where a reader expects: parseHTTPResponse -> parse_http_response
Access control
- @JsonIgnore() excluded both ways
- @JsonWriteOnly() accepted from input, never echoed back (passwords)
- @JsonReadOnly() serialized, never settable by a client (server-owned ids)
Blocked names are dropped explicitly rather than falling through to the unknown
key path, which would otherwise have copied a rejected id straight back on under
the default policy.
Transform options, per call or globally via configure()
- validate: false to map without validating, for lenient parsing
- unknownKeys: 'allow' | 'strip' | 'error'
- namingStrategy
Error ergonomics — the nested ValidationError tree was hard to turn into an HTTP
400 body. flattenErrors() yields {"items[0].qty": ["qty must be at least 1"]},
plus formatErrors() and collectErrorMessages(). Adds validateOrReject().
All defaults preserve existing behaviour; the 68 prior tests pass unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
6d04b43964 |
🐛 fix: repair eight correctness defects in the mapping and validation engines
Each fix is pinned by a regression test in src/regressions.test.ts describing the
old behaviour.
- Inheritance dropped base-class rules. A subclass re-decorating an inherited
property registered its constraints against its own prototype, and validate()
read only the nearest set, so everything the base declared was silently lost.
Constraints are now merged down the whole prototype chain, base first, with
genuinely identical rules collapsed so restating @IsString() on an override
does not double-report. The library's own example.ts was affected: Media's
@IsString() title had never been enforced for Book.
- Circular references exhausted the heap. serialize() recursed forever, taking
8 GB and the process with it; it now tracks ancestors and raises a
JsonMappingError naming the cause. Diamonds still serialize. validate() skips
back-edges instead of recursing.
- @Matches with a g or y flag was stateful: RegExp.test advances lastIndex, so
validating the same value twice gave different answers. Those flags are
stripped.
- An unmatched @JsonPolymorphic discriminator silently dropped the value — the
single-object branch fell through without assigning. The raw value is now
preserved, with { onUnknown: 'error' } and { fallback } to choose otherwise.
- Custom @JsonSerialize serializers ran on null/undefined, crashing on any unset
optional property. They now only see real values.
- serialize() read obj.constructor.prototype, which throws for null-prototype
objects; both engines now agree on Object.getPrototypeOf.
- __proto__, constructor and prototype arriving in untrusted JSON were copied
onto the instance, detaching it from its own class. They are dropped.
- The "each element in ..." prefix was glued onto caller-supplied messages, and
two rules sharing a name overwrote each other so only one failure surfaced.
Also adds toInstanceArray/fromJsonArray, since toInstance and fromJson accept
arrays at runtime but type the result as T, and reports a non-JSON request body
in fromRequest as a JsonMappingError rather than a raw SyntaxError.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
|
||
|
|
66e19f690f |
🔧 fix: repair test, build and CI infrastructure
The vitest suite silently ran zero tests: vitest 4 transpiles with oxc, which does not pick up `experimentalDecorators` from a tsconfig that excludes the files being transformed, so every decorator-using suite failed to parse and was reported as "0 test". A vitest.config.ts enabling legacy decorators brings all 40 existing tests back to life. - Add vitest.config.ts (oxc legacy decorators + v8 coverage config) - Type-check test files: move the test/demo exclusions from the base tsconfig onto the two build configs, and fix the strict-mode errors this surfaced - Stop shipping src/example.ts in dist (it invokes runExample() at import time, a side effect in a package declaring "sideEffects": false) - CI: run lint, tests with coverage, build and entry-point smoke checks; drop EOL Node 18, add Node 24; commit package-lock.json so `npm ci` works - Add `verify`, `test:watch` and `build:docs` scripts, and an engines field; `build:docs` regenerates the previously hand-maintained docs/cereale.js Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK |