From 093830047734c00cdfc9eba1b1a849879b2aaece Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:11:32 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8A=20feat!:=20make=20the=20three=20si?= =?UTF-8?q?lent=20failures=20loud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 84 ++++++++++++ README.md | 93 ++++++++++--- docs/cereale.js | 4 +- package-lock.json | 267 +++++++++++++++++++++++++++++++++++++- package.json | 16 ++- src/decorators.ts | 29 +++-- src/hardening.test.ts | 41 ++++++ src/metadata.ts | 61 ++++++++- src/representable.test.ts | 185 ++++++++++++++++++++++++++ src/toolchain.test.ts | 184 ++++++++++++++++++++++++++ src/utils.ts | 143 ++++++++++++++++++-- src/vite.ts | 175 +++++++++++++++++++++++++ vitest.config.ts | 30 +---- 14 files changed, 1237 insertions(+), 77 deletions(-) create mode 100644 src/representable.test.ts create mode 100644 src/toolchain.test.ts create mode 100644 src/vite.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a581e16..202390f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,7 @@ jobs: run: | node --input-type=module -e "import * as m from './dist/esm/index.js'; if (typeof m.toInstance !== 'function') throw new Error('ESM entry point broken');" node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');" + node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');" + node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');" - name: Run Demo run: npm run demo diff --git a/CHANGELOG.md b/CHANGELOG.md index 98065b4..34a0472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,90 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-08-05 + +Every change here comes from the same question: where does cereale currently fail *quietly*? +Three answers, each of which cost a real user nothing to hit and everything to diagnose. + +### Vite 8 and Vitest 4 silently drop decorators — `cereale/vite` + +Both transform TypeScript with oxc, which does not implement the standard decorator transform +and does not say so. It leaves the syntax in the output, so: + +- `vitest` reports `0 test` next to a bare `SyntaxError` +- `vite build` reports **success**, having emitted a bundle that throws the moment it is imported + +Cereale now ships the plugin that fixes it: + +```ts +// vite.config.ts / vitest.config.ts +import { standardDecorators } from 'cereale/vite'; + +export default defineConfig({ plugins: [standardDecorators()] }); +``` + +It transforms with esbuild, falling back to the TypeScript compiler; cereale depends on +neither, and says which to install if somehow neither is present. Options: `include`, +`target`, and `transformer` to pin one deliberately. The library's own test suite runs +through it, so it is exercised by every test rather than by one test about itself. + +### Legacy decorators now say so + +With `experimentalDecorators: true` — still the default in most existing TypeScript projects, +because class-validator required it — decorators are invoked as `(prototype, "name")` and +cereale died with `TypeError: Cannot convert undefined or null to object`, which names neither +the cause nor the fix. Every decorator now resolves its metadata through one checkpoint that +raises an error naming the tsconfig setting instead. The same checkpoint rejects application +to a method, getter or `accessor` field, all of which previously recorded metadata that +nothing would ever read. + +### Values JSON cannot carry are refused, not emptied + +A populated `Map` serialized to `{}`. A `Set` serialized to `{}`. A `Uint8Array` to +`{"0":1,"1":2}`. A `bigint` passed straight through, so the caller's own `JSON.stringify` +threw somewhere unrelated. `RegExp`, `Error`, `Promise`, `WeakMap`, `DataView`, symbols and +functions all had their own version of the same failure. All of them now raise a +`JsonMappingError` that names the property path and the two ways out: + +``` +JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON. +Give the property a @JsonSerialize() serializer that converts it, or drop it from the +output with @JsonIgnore(). +``` + +The check also covers what a `@JsonSerialize` serializer hands back, sync or async. This is +**breaking** for anyone relying on the old behaviour, though "relying on" is a strong word for +losing data without being told. + +Circular-reference and depth-limit errors now name the path too (`at child.parent`), which +came free with the bookkeeping. + +### 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. Every sibling subclass then inherited a rule meant for one + of them. +- The plugin's TypeScript path emitted a `//# sourceMappingURL=` comment pointing at a file + nobody wrote, which Vite followed and failed to read on every transformed module. + +### Positioning + +`zod-alternative` is out of the keywords, and the README leads with the comparison that +actually applies: cereale replaces **class-validator + class-transformer**. It does not infer +types from schemas, and framing it against Zod invited exactly the objection that it is +missing `z.infer` — which is a different design, not a gap. + +The README's toolchain support table (`tsc`, esbuild, swc ✅, oxc ❌) is now +[executed by a test](src/toolchain.test.ts): each row compiles a decorated class with that +tool and asserts the metadata arrived, so the table cannot quietly go stale. + +### Performance + +Serialization is a few percent slower for the representability check. Primitives are handled +inline, and arrays and dates skip it, so it costs one `Symbol.toStringTag` read per object. +Validation is unchanged. + ## [0.2.0] - 2026-08-04 > The project stays on 0.x while nothing has been published: under semver that signals the diff --git a/README.md b/README.md index 6666c63..fe3fdb5 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,30 @@ const user = fromJsonSync(User, body); // a real User user.greet(); // your methods are still there ``` -## Why not Zod? +## Where it fits -Zod is excellent, and if a plain validated object is what you want, use it. The difference is -what you get back: +The stack Cereale replaces is **class-validator + class-transformer**: + +| | class-validator + class-transformer | Cereale | +| --- | --- | --- | +| Packages to install | 2, plus `reflect-metadata` | 1, no runtime dependencies | +| Decorators | legacy (`experimentalDecorators`) | TC39 standard | +| Rules checked against the field | no — `@IsInt() name: string` compiles | **yes, at compile time** | +| Mapping and validation | two libraries that must agree | one model | + +The comparison people ask about is **Zod**, and it is worth being precise about, because +Cereale is not a drop-in for it: | | Zod | Cereale | | --- | --- | --- | | Result of parsing | an anonymous object matching a schema | an instance of **your class** | | Methods, getters, inheritance | none — data only | preserved | | Where the type comes from | inferred from the schema | your class declaration | -| Rules checked against the type | not applicable — schema *is* the type | **yes, at compile time** | | Bidirectional mapping (renaming both ways) | not the focus | first-class | -Cereale does not infer your type from a schema, so you still write the field type and the rule. -What it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not -compile. That is the guarantee class-validator has never offered. +Cereale does **not** infer your type from a schema. You write the field type and the rule, and +what it guarantees is that **the two cannot disagree** — `@IsInt() name!: string` does not +compile. If you want `z.infer`, you want Zod; that is a different design, not a missing feature. Reach for Cereale when your domain model is already a class — a NestJS provider, a TypeORM entity, anything with behaviour attached. Reach for Zod when you just want the data. @@ -51,6 +59,8 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d - **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes. - **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions. - **Access control:** keep passwords out of responses and server-owned ids out of requests. +- **Nothing fails quietly:** a misconfigured compiler, a cycle, or a value JSON cannot carry + raises an error that names the cause — never an empty object. - **Sync and async:** every entry point has a synchronous twin. - **Zero dependencies**, ESM + CJS, Node 20+. @@ -60,7 +70,7 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d npm install cereale ``` -Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` flag: +Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag: ```json { @@ -74,10 +84,44 @@ Cereale v2 uses **TC39 standard decorators**, so no `experimentalDecorators` fla Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and `emitDecoratorMetadata` is not read. -> **Toolchain note.** Standard decorators are transformed by `tsc` and by esbuild (so Vite -> works). They are **not** yet transformed by oxc; if your toolchain uses it, decorator syntax -> will fail to parse. The 0.1.x line, which uses legacy decorators, remains available for -> those setups. +`experimentalDecorators` must be **off**. The two decorator systems cannot coexist in one +program, so a project that still needs legacy decorators for another library cannot use +Cereale yet. If yours is configured for them, you get an error saying exactly that rather +than a `TypeError` from somewhere inside the engine. + +### Toolchain support + +Whether Cereale works at all depends on your compiler emitting standard decorators, so this +table is [checked by a test](src/toolchain.test.ts) rather than asserted here: + +| Transformer | Status | Notes | +| --- | --- | --- | +| `tsc` | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ | +| esbuild | ✅ | Same settings via `tsconfigRaw` | +| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` | +| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below | + +**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without +reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build` +reports success while emitting a bundle that throws the moment it is imported. Cereale ships +the plugin that fixes it: + +```ts +// vite.config.ts / vitest.config.ts +import { defineConfig } from 'vite'; +import { standardDecorators } from 'cereale/vite'; + +export default defineConfig({ + plugins: [standardDecorators()], +}); +``` + +It transforms `.ts`, `.mts` and `.cts` outside `node_modules` with esbuild, falling back to +the TypeScript compiler if esbuild is not installed — Cereale depends on neither. Pass +`include` to widen or narrow the set (decorated classes in `.tsx` files need this), +`transformer: 'esbuild' | 'typescript'` to pin one, or `target` to change the output level +from the default `es2022`. Nothing in the plugin is specific to Cereale; delete it once oxc +implements the transform. ## Quick Start @@ -424,6 +468,11 @@ against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine: If you validate at the edge and map internally afterwards, `{ validate: false }` skips the dominant cost. +Serialization also checks every value it walks against the set JSON cannot represent. That +costs a few percent on `toPlain`, which is the price of never emitting `{}` where a `Map` +used to be; primitives are handled inline and the check is skipped for arrays and dates, so +it is one `Symbol.toStringTag` read per object. + ## Notes and Limitations - **Rules are checked, types are not inferred.** You write both the field type and the rule; @@ -431,10 +480,24 @@ dominant cost. model, not this one. - **`abstract` fields cannot be decorated.** Standard decorators do not apply to abstract members. Declare the field concretely in the base class instead. -- **oxc does not transform standard decorators yet.** `tsc` and esbuild do. +- **`accessor` fields cannot be decorated.** Their value lives in a private slot that mapping + and validation cannot reach. Applying a decorator to one is an error, not a silent no-op. +- **oxc does not transform standard decorators yet.** `tsc`, esbuild and swc do — see + [Toolchain support](#toolchain-support) for the Vite/Vitest plugin. +- **Values JSON cannot carry are rejected**, not quietly dropped. `Map`, `Set`, `RegExp`, + `Error`, typed arrays, `bigint`, `symbol` and functions all raise a `JsonMappingError` naming + the property path: -- **Circular references** are rejected during serialization with a `JsonMappingError`. Break - the cycle with `@JsonIgnore()` on the back-reference. + ``` + JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON. + Give the property a @JsonSerialize() serializer that converts it, or drop it from the + output with @JsonIgnore(). + ``` + + Serializing a populated `Map` to `{}` and returning success is the failure mode this + library exists to prevent, so it does not do it either. +- **Circular references** are rejected during serialization with a `JsonMappingError` that + names where the cycle closed. Break it with `@JsonIgnore()` on the back-reference. - **`validate()` on a plain object** returns no errors: rules live on the class, so validate the instance you get back from `toInstance`, not the raw payload. - **Renaming is not backwards-compatible by itself.** Once a property carries diff --git a/docs/cereale.js b/docs/cereale.js index fcc7f59..a68c825 100644 --- a/docs/cereale.js +++ b/docs/cereale.js @@ -1,2 +1,2 @@ -"use strict";var Cereale=(()=>{var L=Object.defineProperty;var In=Object.getOwnPropertyDescriptor;var An=Object.getOwnPropertyNames;var Nn=Object.prototype.hasOwnProperty;var Mn=(n,e)=>{for(var t in e)L(n,t,{get:e[t],enumerable:!0})},Pn=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of An(e))!Nn.call(n,r)&&r!==t&&L(n,r,{get:()=>e[r],enumerable:!(o=In(e,r))||o.enumerable});return n};var Rn=n=>Pn(L({},"__esModule",{value:!0}),n);var gt={};Mn(gt,{Allow:()=>Yn,ArrayContains:()=>Ye,ArrayMaxSize:()=>He,ArrayMinSize:()=>Ge,ArrayNotContains:()=>Qe,ArrayNotEmpty:()=>Ze,ArrayUnique:()=>Xe,Contains:()=>Fe,Email:()=>Oe,EndsWith:()=>Ue,Equals:()=>Be,IsAlpha:()=>Ce,IsAlphanumeric:()=>Te,IsArray:()=>We,IsBigInt:()=>re,IsBoolean:()=>oe,IsDate:()=>ae,IsDateString:()=>Ee,IsDefined:()=>se,IsDivisibleBy:()=>me,IsEmpty:()=>ue,IsEnum:()=>Le,IsHexColor:()=>Ve,IsIP:()=>ve,IsIn:()=>_e,IsInstance:()=>qe,IsInt:()=>te,IsJSON:()=>Ie,IsLatitude:()=>ge,IsLongitude:()=>he,IsLowercase:()=>$e,IsNotEmpty:()=>le,IsNotIn:()=>Ke,IsNumber:()=>ee,IsNumberString:()=>Se,IsObject:()=>ie,IsOptional:()=>Hn,IsPort:()=>ye,IsSemVer:()=>De,IsString:()=>ne,IsUUID:()=>Re,IsUppercase:()=>ke,IsUrl:()=>Ae,JsonAlias:()=>jn,JsonDeserialize:()=>Wn,JsonIgnore:()=>_n,JsonMapper:()=>G,JsonMappingError:()=>b,JsonPolymorphic:()=>Gn,JsonProperty:()=>Bn,JsonReadOnly:()=>Kn,JsonSerialize:()=>qn,JsonType:()=>Zn,JsonValidationError:()=>C,JsonWriteOnly:()=>Ln,Length:()=>we,Matches:()=>Ne,Max:()=>de,MaxDate:()=>et,MaxLength:()=>xe,Min:()=>ce,MinDate:()=>nt,MinLength:()=>be,Negative:()=>fe,NotContains:()=>Je,NotEquals:()=>je,Positive:()=>pe,REDACTED:()=>yn,StartsWith:()=>ze,Validate:()=>tt,ValidateIf:()=>Xn,ValidateNested:()=>Qn,addConstraint:()=>E,collectErrorMessages:()=>rt,configure:()=>Jn,defineRule:()=>Fn,flattenErrors:()=>Z,formatErrors:()=>ot,fromJson:()=>kn,fromJsonArray:()=>Sn,fromJsonArraySync:()=>yt,fromJsonSync:()=>mt,fromRequest:()=>En,getConfig:()=>zn,modelOf:()=>R,modelOfInstance:()=>v,modelVersion:()=>T,propertyModel:()=>h,resetConfig:()=>Un,resolveNamingStrategy:()=>F,resolveOptions:()=>x,toInstance:()=>P,toInstanceArray:()=>nn,toInstanceArraySync:()=>$n,toInstanceSync:()=>Q,toJson:()=>Dn,toJsonSync:()=>ft,toPlain:()=>Y,toPlainSync:()=>Tn,validate:()=>M,validateOrReject:()=>Cn,validateOrRejectSync:()=>pt,validateSync:()=>j});Symbol.metadata??=Symbol.for("Symbol.metadata");var S=Symbol.for("cereale.model"),on=0;function T(){return on}function vn(n){if(!Object.hasOwn(n,S)){let e=n[S],t={};for(let[o,r]of Object.entries(e??{}))t[o]={...r,constraints:[...r.constraints]};n[S]=t}return n[S]}function h(n,e){on++;let t=vn(n);return t[e]??={constraints:[]}}function E(n,e,t,o){o?.each&&(t.each=!0),o?.message&&(t.message=o.message,t.hasCustomMessage=!0),h(n,e).constraints.push(t)}function R(n){return typeof n!="function"?{}:n[Symbol.metadata]?.[S]??{}}function v(n){let e=Object.getPrototypeOf(n);if(!e)return{};let t=Object.getOwnPropertyDescriptor(e,"constructor");return R(t?.value)}function Fn(n,e,t,o){let r=n;r[Symbol.metadata]??=Object.create(null),E(r[Symbol.metadata],e,t,o)}function I(n){return n.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1 $2").replace(/[_\-\s]+/g," ").trim().split(" ").filter(Boolean).map(e=>e.toLowerCase())}var rn=n=>n&&n.charAt(0).toUpperCase()+n.slice(1),q={identity:n=>n,camelCase:n=>{let e=I(n);return e.length===0?n:e[0]+e.slice(1).map(rn).join("")},PascalCase:n=>I(n).map(rn).join("")||n,snake_case:n=>I(n).join("_")||n,SCREAMING_SNAKE_CASE:n=>I(n).join("_").toUpperCase()||n,"kebab-case":n=>I(n).join("-")||n};function F(n){if(!n)return q.identity;if(typeof n=="function")return n;let e=q[n];if(!e)throw new Error(`Unknown naming strategy ${JSON.stringify(n)}. Use one of: ${Object.keys(q).join(", ")}, or pass your own function.`);return e}var an={namingStrategy:"identity",unknownKeys:"allow",validate:!0,maxDepth:64},O={...an};function Jn(n){O={...O,...n}}function zn(){return{...O}}function Un(){O={...an}}function x(n){return n?{namingStrategy:n.namingStrategy??O.namingStrategy,unknownKeys:n.unknownKeys??O.unknownKeys,validate:n.validate??O.validate,maxDepth:n.maxDepth??O.maxDepth}:O}function d(n,e){return((t,o)=>{let r=String(o.name);E(o.metadata,r,n(r),e)})}function p(n,e,t){return(o=>d(r=>({name:n,validate:e,message:t(r)}),o))}function A(n,e,t){return p(n,o=>typeof o=="string"&&e.test(o),t)}function Bn(n){return((e,t)=>{h(t.metadata,String(t.name)).name=n})}function jn(...n){return((e,t)=>{let o=h(t.metadata,String(t.name));o.aliases=[...o.aliases??[],...n]})}function W(n){return((e,t)=>{h(t.metadata,String(t.name)).access=n})}var _n=()=>W("none"),Kn=()=>W("readonly"),Ln=()=>W("writeonly");function qn(n){return((e,t)=>{h(t.metadata,String(t.name)).serializer=n})}function Wn(n){return((e,t)=>{h(t.metadata,String(t.name)).deserializer=n})}function Zn(n){return((e,t)=>{h(t.metadata,String(t.name)).type=n})}function Gn(n,e,t){return((o,r)=>{let a={discriminator:n,subTypes:e,onUnknown:t?.onUnknown??"keep",...t?.fallback?{fallback:t.fallback}:{}};h(r.metadata,String(r.name)).polymorphic=a})}function Hn(){return((n,e)=>{h(e.metadata,String(e.name)).optional=!0})}function Xn(n){return((e,t)=>{h(t.metadata,String(t.name)).condition=n})}function Yn(){return((n,e)=>{h(e.metadata,String(e.name))})}function Qn(n){return((e,t)=>{let o=String(t.name);h(t.metadata,o).nested=!0,n?.each&&E(t.metadata,o,{name:"nestedEach",validate:r=>Array.isArray(r),message:`${o} must be an array`})})}var ne=p("isString",n=>typeof n=="string",n=>`${n} must be a string`),ee=p("isNumber",n=>typeof n=="number"&&!isNaN(n),n=>`${n} must be a number`),te=p("isInt",n=>Number.isInteger(n),n=>`${n} must be an integer`),oe=p("isBoolean",n=>typeof n=="boolean",n=>`${n} must be a boolean`),re=p("isBigInt",n=>typeof n=="bigint",n=>`${n} must be a bigint`),ae=p("isDate",n=>n instanceof Date&&!isNaN(n.getTime()),n=>`${n} must be a valid Date object`),ie=p("isObject",n=>typeof n=="object"&&n!==null&&!Array.isArray(n),n=>`${n} must be an object`),se=p("isDefined",n=>n!=null,n=>`${n} should not be null or undefined`),le=p("isNotEmpty",n=>n!=null&&n!=="",n=>`${n} should not be empty`),ue=p("isEmpty",n=>n==null||n===""?!0:Array.isArray(n)?n.length===0:typeof n=="object"?Object.keys(n).length===0:!1,n=>`${n} must be empty`);function ce(n,e){return d(t=>({name:"min",validate:o=>typeof o=="number"&&o>=n,message:`${t} must be at least ${n}`,constraints:[n]}),e)}function de(n,e){return d(t=>({name:"max",validate:o=>typeof o=="number"&&o<=n,message:`${t} must be at most ${n}`,constraints:[n]}),e)}var pe=p("positive",n=>typeof n=="number"&&n>0,n=>`${n} must be positive`),fe=p("negative",n=>typeof n=="number"&&n<0,n=>`${n} must be negative`);function me(n,e){return d(t=>({name:"isDivisibleBy",validate:o=>typeof o=="number"&&Number.isFinite(o)&&n!==0&&o%n===0,message:`${t} must be divisible by ${n}`,constraints:[n]}),e)}var ye=p("isPort",n=>{let e=typeof n=="string"&&n.trim()!==""?Number(n):n;return typeof e=="number"&&Number.isInteger(e)&&e>=0&&e<=65535},n=>`${n} must be a valid port number`),ge=p("isLatitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-90&&n<=90,n=>`${n} must be a latitude between -90 and 90`),he=p("isLongitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-180&&n<=180,n=>`${n} must be a longitude between -180 and 180`);function be(n,e){return d(t=>({name:"minLength",validate:o=>typeof o=="string"&&o.length>=n,message:`${t} must be longer than or equal to ${n} characters`,constraints:[n]}),e)}function xe(n,e){return d(t=>({name:"maxLength",validate:o=>typeof o=="string"&&o.length<=n,message:`${t} must be shorter than or equal to ${n} characters`,constraints:[n]}),e)}function we(n,e,t){return d(o=>({name:"length",validate:r=>typeof r=="string"&&r.length>=n&&(e===void 0||r.length<=e),message:e===void 0?`${o} must be at least ${n} characters`:`${o} must be between ${n} and ${e} characters`,constraints:e===void 0?[n]:[n,e]}),t)}var Oe=A("isEmail",/^[^\s@]+@[^\s@]+\.[^\s@]+$/,n=>`${n} must be a valid email`),Ce=A("isAlpha",/^[A-Za-z]+$/,n=>`${n} must contain only letters`),Te=A("isAlphanumeric",/^[A-Za-z0-9]+$/,n=>`${n} must contain only letters and numbers`),De=A("isSemVer",/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,n=>`${n} must be a valid semantic version`),Ve=A("isHexColor",/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i,n=>`${n} must be a hex color`),$e=p("isLowercase",n=>typeof n=="string"&&n===n.toLowerCase(),n=>`${n} must be lowercase`),ke=p("isUppercase",n=>typeof n=="string"&&n===n.toUpperCase(),n=>`${n} must be uppercase`),Se=p("isNumberString",n=>typeof n=="string"&&n.trim()!==""&&Number.isFinite(Number(n)),n=>`${n} must be a number string`),Ee=p("isDateString",n=>typeof n=="string"&&!isNaN(Date.parse(n)),n=>`${n} must be a valid ISO 8601 date string`),Ie=p("isJson",n=>{if(typeof n!="string")return!1;try{return JSON.parse(n),!0}catch{return!1}},n=>`${n} must be a JSON string`),Ae=p("isUrl",n=>{try{return new URL(n),!0}catch{return!1}},n=>`${n} must be a valid URL`);function Ne(n,e){let t=n.flags.includes("g")||n.flags.includes("y")?new RegExp(n.source,n.flags.replace(/[gy]/g,"")):n;return d(o=>({name:"matches",validate:r=>typeof r=="string"&&t.test(r),message:`${o} must match ${n} regular expression`,constraints:[n]}),e)}var Me="00000000-0000-0000-0000-000000000000",Pe="ffffffff-ffff-ffff-ffff-ffffffffffff";function Re(n,e){let t=n?new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${n}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,"i"):/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return d(o=>({name:"isUuid",validate:r=>typeof r!="string"?!1:!n&&(r.toLowerCase()===Me||r.toLowerCase()===Pe)?!0:t.test(r),message:`${o} must be a valid UUID${n?` (version ${n})`:""}`,...n?{constraints:[n]}:{}}),e)}var sn=/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,ln=n=>{try{return new URL(`http://[${n}]`).hostname===`[${n.toLowerCase()}]`||/^[0-9a-f:.]+$/i.test(n)&&n.includes(":")}catch{return!1}};function ve(n,e){return d(t=>({name:"isIp",validate:o=>typeof o!="string"?!1:n===4?sn.test(o):n===6?ln(o):sn.test(o)||ln(o),message:`${t} must be a valid IP${n?`v${n}`:""} address`,...n?{constraints:[n]}:{}}),e)}function z(n,e,t){function o(r,a){return d(i=>({name:n,validate:s=>typeof s=="string"&&e(s,r),message:t(i,r),constraints:[r]}),a)}return o}var Fe=z("contains",(n,e)=>n.includes(e),(n,e)=>`${n} must contain ${JSON.stringify(e)}`),Je=z("notContains",(n,e)=>!n.includes(e),(n,e)=>`${n} must not contain ${JSON.stringify(e)}`),ze=z("startsWith",(n,e)=>n.startsWith(e),(n,e)=>`${n} must start with ${JSON.stringify(e)}`),Ue=z("endsWith",(n,e)=>n.endsWith(e),(n,e)=>`${n} must end with ${JSON.stringify(e)}`);function Be(n,e){return d(t=>({name:"equals",validate:o=>o===n,message:`${t} must be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function je(n,e){return d(t=>({name:"notEquals",validate:o=>o!==n,message:`${t} must not be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function _e(n,e){return d(t=>({name:"isIn",validate:o=>n.includes(o),message:`${t} must be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Ke(n,e){return d(t=>({name:"isNotIn",validate:o=>!n.includes(o),message:`${t} must not be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Le(n,e){let t=Object.keys(n).filter(o=>typeof n[n[o]]!="number").map(o=>n[o]);return d(o=>({name:"isEnum",validate:r=>t.includes(r),message:`${o} must be one of the following values: ${t.join(", ")}`,constraints:[t]}),e)}function qe(n,e){return d(t=>({name:"isInstance",validate:o=>o instanceof n,message:`${t} must be an instance of ${n.name}`,constraints:[n]}),e)}function $(n,e,t,o){return r=>d(a=>({name:n,validate:i=>Array.isArray(i)&&e(i),message:t(a),...o?{constraints:o}:{}}),r)}var We=n=>d(e=>({name:"isArray",validate:t=>Array.isArray(t),message:`${e} must be an array`}),n),Ze=n=>$("arrayNotEmpty",e=>e.length>0,e=>`${e} should not be empty`)(n),Ge=(n,e)=>$("arrayMinSize",t=>t.length>=n,t=>`${t} must contain at least ${n} elements`,[n])(e),He=(n,e)=>$("arrayMaxSize",t=>t.length<=n,t=>`${t} must contain at most ${n} elements`,[n])(e);function Xe(n,e){return $("arrayUnique",t=>{let o=n?t.map(n):t;return new Set(o).size===o.length},t=>`${t} must not contain duplicate values`)(e)}function Ye(n,e){return $("arrayContains",t=>n.every(o=>t.includes(o)),t=>`${t} must contain the following values: ${n.join(", ")}`,[n])(e)}function Qe(n,e){return $("arrayNotContains",t=>n.every(o=>!t.includes(o)),t=>`${t} must not contain any of the following values: ${n.join(", ")}`,[n])(e)}var J=n=>typeof n=="function"?n():n;function nt(n,e){return d(()=>({name:"minDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()>=J(n).getTime(),message:t=>`${t.property} must not be earlier than ${J(n).toISOString()}`,constraints:[n]}),e)}function et(n,e){return d(()=>({name:"maxDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()<=J(n).getTime(),message:t=>`${t.property} must not be later than ${J(n).toISOString()}`,constraints:[n]}),e)}function tt(n,e,t){let o=Array.isArray(e)?e:[],r=Array.isArray(e)?t:e;if(typeof n=="function"&&!n.prototype?.validate){let s=n;return d(()=>({name:"custom",validate:(l,u)=>s(l,u),message:l=>`${l.property} is invalid`,constraints:o}),r)}let i=new n;return d(()=>({name:n.name,validate:(s,l)=>i.validate(s,l),message:s=>i.defaultMessage?i.defaultMessage(s):`${s.property} is invalid`,constraints:o}),r)}function Z(n){let e={},t=(o,r)=>{for(let a of o){let i=a.property.startsWith("[")?`${r}${a.property}`:r?`${r}.${a.property}`:a.property,s=Object.values(a.constraints);s.length>0&&(e[i]??=[]).push(...s),a.children?.length&&t(a.children,i)}};return t(n,""),e}function ot(n){let e=Z(n);return Object.entries(e).flatMap(([t,o])=>o.map(r=>`${t}: ${r}`)).join(` -`)}function rt(n){return Object.values(Z(n)).flat()}var C=class extends Error{constructor(t,o){super(t);this.errors=o;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},b=class extends Error{constructor(e){super(e),this.name="JsonMappingError"}},at=new Set(["__proto__","constructor","prototype"]),yn="[redacted]";function gn(n,e){return n[e]?.access??"readwrite"}function hn(n,e,t){return n[e]?.name??t(e)}var un=new WeakMap;function it(n,e,t){let o=un.get(n);(!o||o.version!==T())&&(o={version:T(),byStrategy:new Map},un.set(n,o));let r=o.byStrategy.get(t.namingKey);r||(r=new Map,o.byStrategy.set(t.namingKey,r));let a=r.get(e);if(!a){let i=gn(n,e),s=n[e]?.serializer;a={name:hn(n,e,t.naming),skip:i==="none"||i==="writeonly",...s?{serializer:s}:{}},r.set(e,a)}return a}var cn=new WeakMap;function st(n,e){let t=cn.get(n);(!t||t.version!==T())&&(t={version:T(),byStrategy:new Map},cn.set(n,t));let o=t.byStrategy.get(e.namingKey);if(o)return o;let r=new Map,a=new Set,i=new Map,s=(u,c)=>{let y=r.get(u);if(y&&y!==c)throw new b(`Properties "${y}" and "${c}" both map to the JSON name ${JSON.stringify(u)}. Give one of them a distinct @JsonProperty name.`);r.set(u,c)};for(let[u,c]of Object.entries(n)){let y=[hn(n,u,e.naming),...c.aliases??[]],m=gn(n,u);if(m==="none"||m==="readonly"){for(let f of y)a.add(f);continue}for(let f of y)s(f,u);(c.deserializer||c.polymorphic||c.type)&&i.set(u,{...c.deserializer?{deserializer:c.deserializer}:{},...c.polymorphic?{polymorphic:c.polymorphic}:{},...c.type?{typeFn:c.type}:{}})}let l={accept:r,blocked:a,props:i};return t.byStrategy.set(e.namingKey,l),l}function N(n){return n!==null&&typeof n=="object"&&typeof n.then=="function"}async function H(n){for(;n.length>0;){let e=n.splice(0,n.length);await Promise.all(e)}}function X(n,e,t){if(n.length!==0){for(let o of n)o.catch(()=>{});throw n.length=0,new b(`${e} requires every serializer, deserializer and validator to be synchronous, but one returned a Promise. Use ${t} instead, or make the hook synchronous.`)}}function U(n,e,t,o,r){if(n==null||typeof n!="object")return n;if(o>t.maxDepth)throw new b(`Maximum nesting depth of ${t.maxDepth} exceeded while serializing. Raise it with the maxDepth option if this structure is legitimate.`);if(n instanceof Date)return n.toISOString();if(e.has(n))throw new b("Circular reference detected during serialization. Break the cycle with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for that property.");e.add(n);try{if(Array.isArray(n)){let s=[];for(let l of n)s.push(U(l,e,t,o+1,r));return s}let a=v(n),i={};for(let s of Object.keys(n)){let l=it(a,s,t);if(l.skip)continue;let u=n[s];if(l.serializer&&u!==null&&u!==void 0){let c=bn(l.serializer).serialize(u);if(N(c)){let y=l.name;i[y]=void 0,r.push(c.then(m=>{i[y]=m}))}else i[l.name]=c}else i[l.name]=U(u,e,t,o+1,r)}return i}finally{e.delete(n)}}function k(n,e,t,o,r){if(e==null)return e;if(o>t.maxDepth)throw new b(`Maximum nesting depth of ${t.maxDepth} exceeded while deserializing. Raise it with the maxDepth option if this structure is legitimate.`);if(Array.isArray(e))return e.map(s=>k(n,s,t,o+1,r));if(typeof e!="object")return e;let a=new n,i=st(R(n),t);for(let s of Object.keys(e)){if(at.has(s)||i.blocked.has(s))continue;let l=i.accept.get(s);if(l===void 0){if(t.unknownKeys==="strip")continue;if(t.unknownKeys==="error")throw new b(`Unknown property ${JSON.stringify(s)} for ${n.name}. Allowed: ${[...i.accept.keys()].map(f=>JSON.stringify(f)).join(", ")||"(none declared)"}.`);a[s]=e[s];continue}let u=e[s],c=i.props.get(l);if(c?.deserializer){let f=bn(c.deserializer).deserialize(u);if(N(f)){let g=l;a[g]=void 0,r.push(f.then(D=>{a[g]=D}))}else a[l]=f;continue}let y=c?.polymorphic;if(y&&u!==null&&u!==void 0){let{discriminator:f,subTypes:g,onUnknown:D,fallback:V}=y,en=w=>{if(w==null||typeof w!="object")return w;let tn=g.find(K=>w[f]===K.name);if(tn)return k(tn.value,w,t,o+1,r);if(V)return k(V,w,t,o+1,r);if(D==="error")throw new b(`Unknown discriminator value ${JSON.stringify(w[f])} for property "${l}". Known values: ${g.map(K=>JSON.stringify(K.name)).join(", ")}.`);return w};a[l]=Array.isArray(u)?u.map(en):en(u);continue}let m=c?.typeFn;if(m&&u!==null&&u!==void 0){let f=m();a[l]=k(f,u,t,o+1,r);continue}a[l]=u}return a}var dn=new WeakMap;function lt(n){let e=[],t=new Set;for(let o of n){if(typeof o.message=="string"){let r=`${o.name}|${String(o.constraints)}|${o.message}|${o.each??!1}`;if(t.has(r))continue;t.add(r)}e.push(o)}return e}function ut(n){let e=dn.get(n);if(e&&e.version===T())return e.plan;let t=[];for(let[o,r]of Object.entries(n)){let a=r.access??"readwrite";t.push({key:o,constraints:lt(r.constraints),isOptional:!!r.optional,isNested:!!r.nested,redact:a==="writeonly"||a==="none",...r.condition?{condition:r.condition}:{}})}return dn.set(n,{version:T(),plan:t}),t}var pn=new WeakMap;function bn(n){let e=pn.get(n);return e||(e=new n,pn.set(n,e)),e}function fn(n,e,t){if(!(e in n)){n[e]=t;return}let o=2;for(;`${e}_${o}`in n;)o++;n[`${e}_${o}`]=t}function mn(n,e,t){let o=typeof n.message=="function"?n.message(e):n.message;return n.each&&!n.hasCustomMessage&&(o=t>=0?`each element in ${o} (failed at index ${t})`:`each element in ${o}`),o}function ct(n,e,t){for(let o=0;o0?t.children=o:delete t.children,(Object.keys(t.constraints).length>0||t.children)&&e.push(t)}return e}function B(n,e,t,o,r){let a=[];if(n==null||typeof n!="object")return a;if(t>o)throw new b(`Maximum nesting depth of ${o} exceeded while validating. Raise it with the maxDepth option if this structure is legitimate.`);if(e.has(n))return a;e.add(n);try{if(Array.isArray(n)){for(let i=0;i0&&a.push({property:`[${i}]`,value:n[i],constraints:{},children:s})}return a}for(let i of ut(v(n))){let s=i.key,l=n[s];if(i.condition&&!i.condition(n)||i.isOptional&&l==null)continue;let u={property:s,value:i.redact?yn:l,constraints:{}},c={value:l,object:n,property:s,constraints:[]},y=!1;for(let m of i.constraints){c.constraints=m.constraints||[];let f=m.each&&Array.isArray(l)?ct(m,l,c):(()=>{let g=m.validate(l,c);return N(g)?g.then(D=>({ok:D,index:-1})):{ok:g,index:-1}})();if(N(f)){y=!0;let g={value:l,object:n,property:s,constraints:m.constraints||[]};r.push(f.then(({ok:D,index:V})=>{D||(V>=0&&(g.value=l[V]),fn(u.constraints,m.name,mn(m,g,V)))}));continue}if(!f.ok){f.index>=0&&(c.value=l[f.index]);let g=mn(m,c,f.index);c.value=l,fn(u.constraints,m.name,g)}}if(i.isNested&&l!==null&&l!==void 0){let m=B(l,e,t+1,o,r);m.length>0&&(u.children=m)}(y||Object.keys(u.constraints).length>0||u.children)&&a.push(u)}return a}finally{e.delete(n)}}function wn(n){let e=x(n);return{naming:F(e.namingStrategy),namingKey:e.namingStrategy,maxDepth:e.maxDepth}}function On(n){let e=x(n);return{naming:F(e.namingStrategy),namingKey:e.namingStrategy,unknownKeys:e.unknownKeys,maxDepth:e.maxDepth}}async function M(n,e){let t=[],o=B(n,new Set,0,x(e).maxDepth,t);return t.length===0?o:(await H(t),xn(o))}function j(n,e){let t=[],o=B(n,new Set,0,x(e).maxDepth,t);return X(t,"validateSync()","validate()"),o}async function Cn(n,e){let t=await M(n,e);if(t.length>0)throw new C("Validation failed",t)}function pt(n,e){let t=j(n,e);if(t.length>0)throw new C("Validation failed",t)}async function Y(n,e){if(n==null)return n;if(x(e).validate){let r=await M(n,e);if(r.length>0)throw new C("Validation failed during serialization",r)}let t=[],o=U(n,new Set,wn(e),0,t);return await H(t),o}function Tn(n,e){if(n==null)return n;if(x(e).validate){let r=j(n,e);if(r.length>0)throw new C("Validation failed during serialization",r)}let t=[],o=U(n,new Set,wn(e),0,t);return X(t,"toPlainSync()","toPlain()"),o}async function Dn(n,e){return JSON.stringify(await Y(n,e))}function ft(n,e){return JSON.stringify(Tn(n,e))}async function P(n,e,t){let o=[],r=k(n,e,On(t),0,o);if(await H(o),x(t).validate){let a=await M(r,t);if(a.length>0)throw new C("Validation failed during deserialization",a)}return r}function Q(n,e,t){let o=[],r=k(n,e,On(t),0,o);if(X(o,"toInstanceSync()","toInstance()"),x(t).validate){let a=j(r,t);if(a.length>0)throw new C("Validation failed during deserialization",a)}return r}function Vn(n,e){if(!Array.isArray(e))throw new b(`Expected an array to map to ${n.name}[], received ${typeof e}.`)}async function nn(n,e,t){return Vn(n,e),await P(n,e,t)}function $n(n,e,t){return Vn(n,e),Q(n,e,t)}async function kn(n,e,t){return P(n,_(e),t)}function mt(n,e,t){return Q(n,_(e),t)}async function Sn(n,e,t){return nn(n,_(e),t)}function yt(n,e,t){return $n(n,_(e),t)}function _(n){try{return JSON.parse(n)}catch(e){throw new b(`Input is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}}async function En(n,e,t){let o;try{o=await e.json()}catch(r){throw new b(`Request body is not valid JSON: ${r instanceof Error?r.message:String(r)}`)}return P(n,o,t)}var G=class{static toPlain=Y;static toJson=Dn;static toInstance=P;static toInstanceArray=nn;static fromJson=kn;static fromJsonArray=Sn;static fromRequest=En;static validate=M;static validateOrReject=Cn};return Rn(gt);})(); +"use strict";var Cereale=(()=>{var Z=Object.defineProperty;var Fn=Object.getOwnPropertyDescriptor;var Jn=Object.getOwnPropertyNames;var zn=Object.prototype.hasOwnProperty;var Bn=(n,e)=>{for(var t in e)Z(n,t,{get:e[t],enumerable:!0})},Un=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Jn(e))!zn.call(n,r)&&r!==t&&Z(n,r,{get:()=>e[r],enumerable:!(o=Fn(e,r))||o.enumerable});return n};var jn=n=>Un(Z({},"__esModule",{value:!0}),n);var kt={};Bn(kt,{Allow:()=>ae,ArrayContains:()=>at,ArrayMaxSize:()=>ot,ArrayMinSize:()=>tt,ArrayNotContains:()=>it,ArrayNotEmpty:()=>et,ArrayUnique:()=>rt,Contains:()=>Ke,Email:()=>Ve,EndsWith:()=>We,Equals:()=>Ge,IsAlpha:()=>Ee,IsAlphanumeric:()=>Ie,IsArray:()=>nt,IsBigInt:()=>de,IsBoolean:()=>ce,IsDate:()=>pe,IsDateString:()=>ve,IsDefined:()=>ye,IsDivisibleBy:()=>Oe,IsEmpty:()=>ge,IsEnum:()=>Xe,IsHexColor:()=>Ne,IsIP:()=>_e,IsIn:()=>He,IsInstance:()=>Qe,IsInt:()=>ue,IsJSON:()=>Fe,IsLatitude:()=>Te,IsLongitude:()=>ke,IsLowercase:()=>Me,IsNotEmpty:()=>me,IsNotIn:()=>Ye,IsNumber:()=>le,IsNumberString:()=>Pe,IsObject:()=>fe,IsOptional:()=>oe,IsPort:()=>Ce,IsSemVer:()=>Ae,IsString:()=>se,IsUUID:()=>je,IsUppercase:()=>Re,IsUrl:()=>Je,JsonAlias:()=>Zn,JsonDeserialize:()=>ne,JsonIgnore:()=>Hn,JsonMapper:()=>nn,JsonMappingError:()=>h,JsonPolymorphic:()=>te,JsonProperty:()=>Gn,JsonReadOnly:()=>Yn,JsonSerialize:()=>Qn,JsonType:()=>ee,JsonValidationError:()=>D,JsonWriteOnly:()=>Xn,Length:()=>$e,Matches:()=>ze,Max:()=>be,MaxDate:()=>lt,MaxLength:()=>Se,Min:()=>he,MinDate:()=>st,MinLength:()=>De,Negative:()=>xe,NotContains:()=>Le,NotEquals:()=>Ze,Positive:()=>we,REDACTED:()=>xn,StartsWith:()=>qe,Validate:()=>ut,ValidateIf:()=>re,ValidateNested:()=>ie,addConstraint:()=>M,collectErrorMessages:()=>dt,configure:()=>Ln,defineRule:()=>Kn,fieldMetadata:()=>b,flattenErrors:()=>X,formatErrors:()=>ct,fromJson:()=>Rn,fromJsonArray:()=>Pn,fromJsonArraySync:()=>Tt,fromJsonSync:()=>Ct,fromRequest:()=>vn,getConfig:()=>qn,modelOf:()=>z,modelOfInstance:()=>B,modelVersion:()=>S,propertyModel:()=>w,resetConfig:()=>Wn,resolveNamingStrategy:()=>U,resolveOptions:()=>x,toInstance:()=>J,toInstanceArray:()=>an,toInstanceArraySync:()=>Mn,toInstanceSync:()=>rn,toJson:()=>An,toJsonSync:()=>Ot,toPlain:()=>on,toPlainSync:()=>In,validate:()=>F,validateOrReject:()=>En,validateOrRejectSync:()=>xt,validateSync:()=>q});var $=Symbol.metadata??Symbol.for("Symbol.metadata");Symbol.metadata??=$;var N=Symbol.for("cereale.model"),ln=0;function S(){return ln}function _n(n){if(!Object.hasOwn(n,N)){let e=n[N],t={};for(let[o,r]of Object.entries(e??{}))t[o]={...r,constraints:[...r.constraints]};n[N]=t}return n[N]}function b(n){let e=n;if(typeof e!="object"||e===null||typeof e.kind!="string")throw new TypeError('cereale needs TC39 standard decorators, but the compiler emitted legacy ones. Set "experimentalDecorators": false in tsconfig.json (and drop "emitDecoratorMetadata"). The two decorator systems cannot coexist in one program, so a project that still needs legacy decorators for another library cannot use cereale yet.');if(e.kind!=="field")throw new TypeError(`cereale decorators apply to fields, but this one was applied to a ${e.kind}.`+(e.kind==="accessor"?" An `accessor` field keeps its value in a private slot that mapping and validation cannot reach \u2014 declare it as a plain field instead.":""));if(typeof e.metadata!="object"||e.metadata===null)throw new TypeError(`The decorator context for "${String(e.name)}" carries no metadata object, so cereale has nowhere to record the rule. The compiler emitted its decorator helpers without metadata support: make sure cereale is imported before the decorated class is evaluated (importing it installs the Symbol.metadata fallback) and that the build targets ES2022 or later.`);return e.metadata}function w(n,e){ln++;let t=_n(n);return t[e]??={constraints:[]}}function M(n,e,t,o){o?.each&&(t.each=!0),o?.message&&(t.message=o.message,t.hasCustomMessage=!0),w(n,e).constraints.push(t)}function z(n){return typeof n!="function"?{}:n[$]?.[N]??{}}function B(n){let e=Object.getPrototypeOf(n);if(!e)return{};let t=Object.getOwnPropertyDescriptor(e,"constructor");return z(t?.value)}function Kn(n,e,t,o){let r=n;Object.hasOwn(r,$)||(r[$]=Object.create(r[$]??null)),M(r[$],e,t,o)}function R(n){return n.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g,"$1 $2").replace(/[_\-\s]+/g," ").trim().split(" ").filter(Boolean).map(e=>e.toLowerCase())}var un=n=>n&&n.charAt(0).toUpperCase()+n.slice(1),H={identity:n=>n,camelCase:n=>{let e=R(n);return e.length===0?n:e[0]+e.slice(1).map(un).join("")},PascalCase:n=>R(n).map(un).join("")||n,snake_case:n=>R(n).join("_")||n,SCREAMING_SNAKE_CASE:n=>R(n).join("_").toUpperCase()||n,"kebab-case":n=>R(n).join("-")||n};function U(n){if(!n)return H.identity;if(typeof n=="function")return n;let e=H[n];if(!e)throw new Error(`Unknown naming strategy ${JSON.stringify(n)}. Use one of: ${Object.keys(H).join(", ")}, or pass your own function.`);return e}var cn={namingStrategy:"identity",unknownKeys:"allow",validate:!0,maxDepth:64},k={...cn};function Ln(n){k={...k,...n}}function qn(){return{...k}}function Wn(){k={...cn}}function x(n){return n?{namingStrategy:n.namingStrategy??k.namingStrategy,unknownKeys:n.unknownKeys??k.unknownKeys,validate:n.validate??k.validate,maxDepth:n.maxDepth??k.maxDepth}:k}function y(n,e){return((t,o)=>{let r=String(o.name);M(b(o),r,n(r),e)})}function m(n,e,t){return(o=>y(r=>({name:n,validate:e,message:t(r)}),o))}function P(n,e,t){return m(n,o=>typeof o=="string"&&e.test(o),t)}function Gn(n){return((e,t)=>{w(b(t),String(t.name)).name=n})}function Zn(...n){return((e,t)=>{let o=w(b(t),String(t.name));o.aliases=[...o.aliases??[],...n]})}function Y(n){return((e,t)=>{w(b(t),String(t.name)).access=n})}var Hn=()=>Y("none"),Yn=()=>Y("readonly"),Xn=()=>Y("writeonly");function Qn(n){return((e,t)=>{w(b(t),String(t.name)).serializer=n})}function ne(n){return((e,t)=>{w(b(t),String(t.name)).deserializer=n})}function ee(n){return((e,t)=>{w(b(t),String(t.name)).type=n})}function te(n,e,t){return((o,r)=>{let a={discriminator:n,subTypes:e,onUnknown:t?.onUnknown??"keep",...t?.fallback?{fallback:t.fallback}:{}};w(b(r),String(r.name)).polymorphic=a})}function oe(){return((n,e)=>{w(b(e),String(e.name)).optional=!0})}function re(n){return((e,t)=>{w(b(t),String(t.name)).condition=n})}function ae(){return((n,e)=>{w(b(e),String(e.name))})}function ie(n){return((e,t)=>{let o=b(t),r=String(t.name);w(o,r).nested=!0,n?.each&&M(o,r,{name:"nestedEach",validate:a=>Array.isArray(a),message:`${r} must be an array`})})}var se=m("isString",n=>typeof n=="string",n=>`${n} must be a string`),le=m("isNumber",n=>typeof n=="number"&&!isNaN(n),n=>`${n} must be a number`),ue=m("isInt",n=>Number.isInteger(n),n=>`${n} must be an integer`),ce=m("isBoolean",n=>typeof n=="boolean",n=>`${n} must be a boolean`),de=m("isBigInt",n=>typeof n=="bigint",n=>`${n} must be a bigint`),pe=m("isDate",n=>n instanceof Date&&!isNaN(n.getTime()),n=>`${n} must be a valid Date object`),fe=m("isObject",n=>typeof n=="object"&&n!==null&&!Array.isArray(n),n=>`${n} must be an object`),ye=m("isDefined",n=>n!=null,n=>`${n} should not be null or undefined`),me=m("isNotEmpty",n=>n!=null&&n!=="",n=>`${n} should not be empty`),ge=m("isEmpty",n=>n==null||n===""?!0:Array.isArray(n)?n.length===0:typeof n=="object"?Object.keys(n).length===0:!1,n=>`${n} must be empty`);function he(n,e){return y(t=>({name:"min",validate:o=>typeof o=="number"&&o>=n,message:`${t} must be at least ${n}`,constraints:[n]}),e)}function be(n,e){return y(t=>({name:"max",validate:o=>typeof o=="number"&&o<=n,message:`${t} must be at most ${n}`,constraints:[n]}),e)}var we=m("positive",n=>typeof n=="number"&&n>0,n=>`${n} must be positive`),xe=m("negative",n=>typeof n=="number"&&n<0,n=>`${n} must be negative`);function Oe(n,e){return y(t=>({name:"isDivisibleBy",validate:o=>typeof o=="number"&&Number.isFinite(o)&&n!==0&&o%n===0,message:`${t} must be divisible by ${n}`,constraints:[n]}),e)}var Ce=m("isPort",n=>{let e=typeof n=="string"&&n.trim()!==""?Number(n):n;return typeof e=="number"&&Number.isInteger(e)&&e>=0&&e<=65535},n=>`${n} must be a valid port number`),Te=m("isLatitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-90&&n<=90,n=>`${n} must be a latitude between -90 and 90`),ke=m("isLongitude",n=>typeof n=="number"&&Number.isFinite(n)&&n>=-180&&n<=180,n=>`${n} must be a longitude between -180 and 180`);function De(n,e){return y(t=>({name:"minLength",validate:o=>typeof o=="string"&&o.length>=n,message:`${t} must be longer than or equal to ${n} characters`,constraints:[n]}),e)}function Se(n,e){return y(t=>({name:"maxLength",validate:o=>typeof o=="string"&&o.length<=n,message:`${t} must be shorter than or equal to ${n} characters`,constraints:[n]}),e)}function $e(n,e,t){return y(o=>({name:"length",validate:r=>typeof r=="string"&&r.length>=n&&(e===void 0||r.length<=e),message:e===void 0?`${o} must be at least ${n} characters`:`${o} must be between ${n} and ${e} characters`,constraints:e===void 0?[n]:[n,e]}),t)}var Ve=P("isEmail",/^[^\s@]+@[^\s@]+\.[^\s@]+$/,n=>`${n} must be a valid email`),Ee=P("isAlpha",/^[A-Za-z]+$/,n=>`${n} must contain only letters`),Ie=P("isAlphanumeric",/^[A-Za-z0-9]+$/,n=>`${n} must contain only letters and numbers`),Ae=P("isSemVer",/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/,n=>`${n} must be a valid semantic version`),Ne=P("isHexColor",/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i,n=>`${n} must be a hex color`),Me=m("isLowercase",n=>typeof n=="string"&&n===n.toLowerCase(),n=>`${n} must be lowercase`),Re=m("isUppercase",n=>typeof n=="string"&&n===n.toUpperCase(),n=>`${n} must be uppercase`),Pe=m("isNumberString",n=>typeof n=="string"&&n.trim()!==""&&Number.isFinite(Number(n)),n=>`${n} must be a number string`),ve=m("isDateString",n=>typeof n=="string"&&!isNaN(Date.parse(n)),n=>`${n} must be a valid ISO 8601 date string`),Fe=m("isJson",n=>{if(typeof n!="string")return!1;try{return JSON.parse(n),!0}catch{return!1}},n=>`${n} must be a JSON string`),Je=m("isUrl",n=>{try{return new URL(n),!0}catch{return!1}},n=>`${n} must be a valid URL`);function ze(n,e){let t=n.flags.includes("g")||n.flags.includes("y")?new RegExp(n.source,n.flags.replace(/[gy]/g,"")):n;return y(o=>({name:"matches",validate:r=>typeof r=="string"&&t.test(r),message:`${o} must match ${n} regular expression`,constraints:[n]}),e)}var Be="00000000-0000-0000-0000-000000000000",Ue="ffffffff-ffff-ffff-ffff-ffffffffffff";function je(n,e){let t=n?new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-${n}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,"i"):/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;return y(o=>({name:"isUuid",validate:r=>typeof r!="string"?!1:!n&&(r.toLowerCase()===Be||r.toLowerCase()===Ue)?!0:t.test(r),message:`${o} must be a valid UUID${n?` (version ${n})`:""}`,...n?{constraints:[n]}:{}}),e)}var dn=/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/,pn=n=>{try{return new URL(`http://[${n}]`).hostname===`[${n.toLowerCase()}]`||/^[0-9a-f:.]+$/i.test(n)&&n.includes(":")}catch{return!1}};function _e(n,e){return y(t=>({name:"isIp",validate:o=>typeof o!="string"?!1:n===4?dn.test(o):n===6?pn(o):dn.test(o)||pn(o),message:`${t} must be a valid IP${n?`v${n}`:""} address`,...n?{constraints:[n]}:{}}),e)}function _(n,e,t){function o(r,a){return y(i=>({name:n,validate:s=>typeof s=="string"&&e(s,r),message:t(i,r),constraints:[r]}),a)}return o}var Ke=_("contains",(n,e)=>n.includes(e),(n,e)=>`${n} must contain ${JSON.stringify(e)}`),Le=_("notContains",(n,e)=>!n.includes(e),(n,e)=>`${n} must not contain ${JSON.stringify(e)}`),qe=_("startsWith",(n,e)=>n.startsWith(e),(n,e)=>`${n} must start with ${JSON.stringify(e)}`),We=_("endsWith",(n,e)=>n.endsWith(e),(n,e)=>`${n} must end with ${JSON.stringify(e)}`);function Ge(n,e){return y(t=>({name:"equals",validate:o=>o===n,message:`${t} must be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function Ze(n,e){return y(t=>({name:"notEquals",validate:o=>o!==n,message:`${t} must not be equal to ${JSON.stringify(n)}`,constraints:[n]}),e)}function He(n,e){return y(t=>({name:"isIn",validate:o=>n.includes(o),message:`${t} must be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Ye(n,e){return y(t=>({name:"isNotIn",validate:o=>!n.includes(o),message:`${t} must not be one of the following values: ${n.join(", ")}`,constraints:[n]}),e)}function Xe(n,e){let t=Object.keys(n).filter(o=>typeof n[n[o]]!="number").map(o=>n[o]);return y(o=>({name:"isEnum",validate:r=>t.includes(r),message:`${o} must be one of the following values: ${t.join(", ")}`,constraints:[t]}),e)}function Qe(n,e){return y(t=>({name:"isInstance",validate:o=>o instanceof n,message:`${t} must be an instance of ${n.name}`,constraints:[n]}),e)}function V(n,e,t,o){return r=>y(a=>({name:n,validate:i=>Array.isArray(i)&&e(i),message:t(a),...o?{constraints:o}:{}}),r)}var nt=n=>y(e=>({name:"isArray",validate:t=>Array.isArray(t),message:`${e} must be an array`}),n),et=n=>V("arrayNotEmpty",e=>e.length>0,e=>`${e} should not be empty`)(n),tt=(n,e)=>V("arrayMinSize",t=>t.length>=n,t=>`${t} must contain at least ${n} elements`,[n])(e),ot=(n,e)=>V("arrayMaxSize",t=>t.length<=n,t=>`${t} must contain at most ${n} elements`,[n])(e);function rt(n,e){return V("arrayUnique",t=>{let o=n?t.map(n):t;return new Set(o).size===o.length},t=>`${t} must not contain duplicate values`)(e)}function at(n,e){return V("arrayContains",t=>n.every(o=>t.includes(o)),t=>`${t} must contain the following values: ${n.join(", ")}`,[n])(e)}function it(n,e){return V("arrayNotContains",t=>n.every(o=>!t.includes(o)),t=>`${t} must not contain any of the following values: ${n.join(", ")}`,[n])(e)}var j=n=>typeof n=="function"?n():n;function st(n,e){return y(()=>({name:"minDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()>=j(n).getTime(),message:t=>`${t.property} must not be earlier than ${j(n).toISOString()}`,constraints:[n]}),e)}function lt(n,e){return y(()=>({name:"maxDate",validate:t=>t instanceof Date&&!isNaN(t.getTime())&&t.getTime()<=j(n).getTime(),message:t=>`${t.property} must not be later than ${j(n).toISOString()}`,constraints:[n]}),e)}function ut(n,e,t){let o=Array.isArray(e)?e:[],r=Array.isArray(e)?t:e;if(typeof n=="function"&&!n.prototype?.validate){let s=n;return y(()=>({name:"custom",validate:(l,u)=>s(l,u),message:l=>`${l.property} is invalid`,constraints:o}),r)}let i=new n;return y(()=>({name:n.name,validate:(s,l)=>i.validate(s,l),message:s=>i.defaultMessage?i.defaultMessage(s):`${s.property} is invalid`,constraints:o}),r)}function X(n){let e={},t=(o,r)=>{for(let a of o){let i=a.property.startsWith("[")?`${r}${a.property}`:r?`${r}.${a.property}`:a.property,s=Object.values(a.constraints);s.length>0&&(e[i]??=[]).push(...s),a.children?.length&&t(a.children,i)}};return t(n,""),e}function ct(n){let e=X(n);return Object.entries(e).flatMap(([t,o])=>o.map(r=>`${t}: ${r}`)).join(` +`)}function dt(n){return Object.values(X(n)).flat()}var D=class extends Error{constructor(t,o){super(t);this.errors=o;this.name="JsonValidationError"}toString(){return`${this.message}: ${JSON.stringify(this.errors,null,2)}`}},h=class extends Error{constructor(e){super(e),this.name="JsonMappingError"}},pt=new Set(["__proto__","constructor","prototype"]),xn="[redacted]";function On(n,e){return n[e]?.access??"readwrite"}function Cn(n,e,t){return n[e]?.name??t(e)}var fn=new WeakMap;function ft(n,e,t){let o=fn.get(n);(!o||o.version!==S())&&(o={version:S(),byStrategy:new Map},fn.set(n,o));let r=o.byStrategy.get(t.namingKey);r||(r=new Map,o.byStrategy.set(t.namingKey,r));let a=r.get(e);if(!a){let i=On(n,e),s=n[e]?.serializer;a={name:Cn(n,e,t.naming),skip:i==="none"||i==="writeonly",...s?{serializer:s}:{}},r.set(e,a)}return a}var yn=new WeakMap;function yt(n,e){let t=yn.get(n);(!t||t.version!==S())&&(t={version:S(),byStrategy:new Map},yn.set(n,t));let o=t.byStrategy.get(e.namingKey);if(o)return o;let r=new Map,a=new Set,i=new Map,s=(u,c)=>{let p=r.get(u);if(p&&p!==c)throw new h(`Properties "${p}" and "${c}" both map to the JSON name ${JSON.stringify(u)}. Give one of them a distinct @JsonProperty name.`);r.set(u,c)};for(let[u,c]of Object.entries(n)){let p=[Cn(n,u,e.naming),...c.aliases??[]],f=On(n,u);if(f==="none"||f==="readonly"){for(let d of p)a.add(d);continue}for(let d of p)s(d,u);(c.deserializer||c.polymorphic||c.type)&&i.set(u,{...c.deserializer?{deserializer:c.deserializer}:{},...c.polymorphic?{polymorphic:c.polymorphic}:{},...c.type?{typeFn:c.type}:{}})}let l={accept:r,blocked:a,props:i};return t.byStrategy.set(e.namingKey,l),l}function v(n){return n!==null&&typeof n=="object"&&typeof n.then=="function"}async function en(n){for(;n.length>0;){let e=n.splice(0,n.length);await Promise.all(e)}}function tn(n,e,t){if(n.length!==0){for(let o of n)o.catch(()=>{});throw n.length=0,new h(`${e} requires every serializer, deserializer and validator to be synchronous, but one returned a Promise. Use ${t} instead, or make the hook synchronous.`)}}var mt={Map:"a Map",Set:"a Set",WeakMap:"a WeakMap",WeakSet:"a WeakSet",WeakRef:"a WeakRef",Promise:"a Promise",ArrayBuffer:"an ArrayBuffer",SharedArrayBuffer:"a SharedArrayBuffer",DataView:"a DataView",Generator:"a generator",AsyncGenerator:"an async generator"};function Tn(n){let e=n[Symbol.toStringTag];if(typeof e=="string"){let t=mt[e];if(t!==void 0)return t;if(ArrayBuffer.isView(n))return`a ${e}`}return n instanceof RegExp?"a RegExp":n instanceof Error?"an Error, whose message and stack are not enumerable":null}function mn(n){switch(typeof n){case"bigint":return kn;case"symbol":return"a symbol";case"function":return"a function";case"object":return n===null?null:Tn(n);default:return null}}var kn="a bigint, which JSON has no representation for";function Q(n){if(n.length===0)return"the value passed in";let e="";for(let t of n)typeof t=="number"?e+=`[${t}]`:e+=e===""?t:`.${t}`;return e}function E(n,e){throw new h(`${Q(e)} is ${n}, which cannot be serialized to JSON. Give the property a @JsonSerialize() serializer that converts it, or drop it from the output with @JsonIgnore().`)}function K(n,e,t,o,r,a){if(n==null)return n;let i=typeof n;if(i!=="object")return i==="bigint"&&E(kn,a),i==="symbol"&&E("a symbol",a),i==="function"&&E("a function",a),n;if(o>t.maxDepth)throw new h(`Maximum nesting depth of ${t.maxDepth} exceeded while serializing at ${Q(a)}. Raise it with the maxDepth option if this structure is legitimate.`);if(n instanceof Date)return n.toISOString();let s=Array.isArray(n);if(!s){let l=Tn(n);l!==null&&E(l,a)}if(e.has(n))throw new h(`Circular reference detected during serialization at ${Q(a)}. Break the cycle with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for that property.`);e.add(n);try{if(s){let c=[];for(let p=0;p{let A=mn(C);A!==null&&E(A,O),u[g]=C}))}else{let g=mn(d);g!==null&&E(g,a),u[p.name]=d}}else u[p.name]=K(f,e,t,o+1,r,a);a.pop()}return u}finally{e.delete(n)}}function I(n,e,t,o,r){if(e==null)return e;if(o>t.maxDepth)throw new h(`Maximum nesting depth of ${t.maxDepth} exceeded while deserializing. Raise it with the maxDepth option if this structure is legitimate.`);if(Array.isArray(e))return e.map(s=>I(n,s,t,o+1,r));if(typeof e!="object")return e;let a=new n,i=yt(z(n),t);for(let s of Object.keys(e)){if(pt.has(s)||i.blocked.has(s))continue;let l=i.accept.get(s);if(l===void 0){if(t.unknownKeys==="strip")continue;if(t.unknownKeys==="error")throw new h(`Unknown property ${JSON.stringify(s)} for ${n.name}. Allowed: ${[...i.accept.keys()].map(d=>JSON.stringify(d)).join(", ")||"(none declared)"}.`);a[s]=e[s];continue}let u=e[s],c=i.props.get(l);if(c?.deserializer){let d=Dn(c.deserializer).deserialize(u);if(v(d)){let g=l;a[g]=void 0,r.push(d.then(O=>{a[g]=O}))}else a[l]=d;continue}let p=c?.polymorphic;if(p&&u!==null&&u!==void 0){let{discriminator:d,subTypes:g,onUnknown:O,fallback:C}=p,A=T=>{if(T==null||typeof T!="object")return T;let sn=g.find(G=>T[d]===G.name);if(sn)return I(sn.value,T,t,o+1,r);if(C)return I(C,T,t,o+1,r);if(O==="error")throw new h(`Unknown discriminator value ${JSON.stringify(T[d])} for property "${l}". Known values: ${g.map(G=>JSON.stringify(G.name)).join(", ")}.`);return T};a[l]=Array.isArray(u)?u.map(A):A(u);continue}let f=c?.typeFn;if(f&&u!==null&&u!==void 0){let d=f();a[l]=I(d,u,t,o+1,r);continue}a[l]=u}return a}var gn=new WeakMap;function gt(n){let e=[],t=new Set;for(let o of n){if(typeof o.message=="string"){let r=`${o.name}|${String(o.constraints)}|${o.message}|${o.each??!1}`;if(t.has(r))continue;t.add(r)}e.push(o)}return e}function ht(n){let e=gn.get(n);if(e&&e.version===S())return e.plan;let t=[];for(let[o,r]of Object.entries(n)){let a=r.access??"readwrite";t.push({key:o,constraints:gt(r.constraints),isOptional:!!r.optional,isNested:!!r.nested,redact:a==="writeonly"||a==="none",...r.condition?{condition:r.condition}:{}})}return gn.set(n,{version:S(),plan:t}),t}var hn=new WeakMap;function Dn(n){let e=hn.get(n);return e||(e=new n,hn.set(n,e)),e}function bn(n,e,t){if(!(e in n)){n[e]=t;return}let o=2;for(;`${e}_${o}`in n;)o++;n[`${e}_${o}`]=t}function wn(n,e,t){let o=typeof n.message=="function"?n.message(e):n.message;return n.each&&!n.hasCustomMessage&&(o=t>=0?`each element in ${o} (failed at index ${t})`:`each element in ${o}`),o}function bt(n,e,t){for(let o=0;o0?t.children=o:delete t.children,(Object.keys(t.constraints).length>0||t.children)&&e.push(t)}return e}function L(n,e,t,o,r){let a=[];if(n==null||typeof n!="object")return a;if(t>o)throw new h(`Maximum nesting depth of ${o} exceeded while validating. Raise it with the maxDepth option if this structure is legitimate.`);if(e.has(n))return a;e.add(n);try{if(Array.isArray(n)){for(let i=0;i0&&a.push({property:`[${i}]`,value:n[i],constraints:{},children:s})}return a}for(let i of ht(B(n))){let s=i.key,l=n[s];if(i.condition&&!i.condition(n)||i.isOptional&&l==null)continue;let u={property:s,value:i.redact?xn:l,constraints:{}},c={value:l,object:n,property:s,constraints:[]},p=!1;for(let f of i.constraints){c.constraints=f.constraints||[];let d=f.each&&Array.isArray(l)?bt(f,l,c):(()=>{let g=f.validate(l,c);return v(g)?g.then(O=>({ok:O,index:-1})):{ok:g,index:-1}})();if(v(d)){p=!0;let g={value:l,object:n,property:s,constraints:f.constraints||[]};r.push(d.then(({ok:O,index:C})=>{O||(C>=0&&(g.value=l[C]),bn(u.constraints,f.name,wn(f,g,C)))}));continue}if(!d.ok){d.index>=0&&(c.value=l[d.index]);let g=wn(f,c,d.index);c.value=l,bn(u.constraints,f.name,g)}}if(i.isNested&&l!==null&&l!==void 0){let f=L(l,e,t+1,o,r);f.length>0&&(u.children=f)}(p||Object.keys(u.constraints).length>0||u.children)&&a.push(u)}return a}finally{e.delete(n)}}function $n(n){let e=x(n);return{naming:U(e.namingStrategy),namingKey:e.namingStrategy,maxDepth:e.maxDepth}}function Vn(n){let e=x(n);return{naming:U(e.namingStrategy),namingKey:e.namingStrategy,unknownKeys:e.unknownKeys,maxDepth:e.maxDepth}}async function F(n,e){let t=[],o=L(n,new Set,0,x(e).maxDepth,t);return t.length===0?o:(await en(t),Sn(o))}function q(n,e){let t=[],o=L(n,new Set,0,x(e).maxDepth,t);return tn(t,"validateSync()","validate()"),o}async function En(n,e){let t=await F(n,e);if(t.length>0)throw new D("Validation failed",t)}function xt(n,e){let t=q(n,e);if(t.length>0)throw new D("Validation failed",t)}async function on(n,e){if(n==null)return n;if(x(e).validate){let r=await F(n,e);if(r.length>0)throw new D("Validation failed during serialization",r)}let t=[],o=K(n,new Set,$n(e),0,t,[]);return await en(t),o}function In(n,e){if(n==null)return n;if(x(e).validate){let r=q(n,e);if(r.length>0)throw new D("Validation failed during serialization",r)}let t=[],o=K(n,new Set,$n(e),0,t,[]);return tn(t,"toPlainSync()","toPlain()"),o}async function An(n,e){return JSON.stringify(await on(n,e))}function Ot(n,e){return JSON.stringify(In(n,e))}async function J(n,e,t){let o=[],r=I(n,e,Vn(t),0,o);if(await en(o),x(t).validate){let a=await F(r,t);if(a.length>0)throw new D("Validation failed during deserialization",a)}return r}function rn(n,e,t){let o=[],r=I(n,e,Vn(t),0,o);if(tn(o,"toInstanceSync()","toInstance()"),x(t).validate){let a=q(r,t);if(a.length>0)throw new D("Validation failed during deserialization",a)}return r}function Nn(n,e){if(!Array.isArray(e))throw new h(`Expected an array to map to ${n.name}[], received ${typeof e}.`)}async function an(n,e,t){return Nn(n,e),await J(n,e,t)}function Mn(n,e,t){return Nn(n,e),rn(n,e,t)}async function Rn(n,e,t){return J(n,W(e),t)}function Ct(n,e,t){return rn(n,W(e),t)}async function Pn(n,e,t){return an(n,W(e),t)}function Tt(n,e,t){return Mn(n,W(e),t)}function W(n){try{return JSON.parse(n)}catch(e){throw new h(`Input is not valid JSON: ${e instanceof Error?e.message:String(e)}`)}}async function vn(n,e,t){let o;try{o=await e.json()}catch(r){throw new h(`Request body is not valid JSON: ${r instanceof Error?r.message:String(r)}`)}return J(n,o,t)}var nn=class{static toPlain=on;static toJson=An;static toInstance=J;static toInstanceArray=an;static fromJson=Rn;static fromJsonArray=Pn;static fromRequest=vn;static validate=F;static validateOrReject=En};return jn(kt);})(); diff --git a/package-lock.json b/package-lock.json index 518e587..67ae936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "cereale", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cereale", - "version": "0.1.0", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", + "@swc/core": "^1.15.47", "@types/node": "^25.6.0", "@vitest/coverage-v8": "^4.1.4", "esbuild": "^0.25.0", @@ -1034,6 +1035,268 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/core": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz", + "integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.27" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.47", + "@swc/core-darwin-x64": "1.15.47", + "@swc/core-linux-arm-gnueabihf": "1.15.47", + "@swc/core-linux-arm64-gnu": "1.15.47", + "@swc/core-linux-arm64-musl": "1.15.47", + "@swc/core-linux-ppc64-gnu": "1.15.47", + "@swc/core-linux-s390x-gnu": "1.15.47", + "@swc/core-linux-x64-gnu": "1.15.47", + "@swc/core-linux-x64-musl": "1.15.47", + "@swc/core-win32-arm64-msvc": "1.15.47", + "@swc/core-win32-ia32-msvc": "1.15.47", + "@swc/core-win32-x64-msvc": "1.15.47" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz", + "integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz", + "integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz", + "integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz", + "integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz", + "integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz", + "integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz", + "integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz", + "integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz", + "integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz", + "integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz", + "integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.47", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz", + "integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", diff --git a/package.json b/package.json index b39e7c4..efdb4e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cereale", - "version": "0.2.0", - "description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data", + "version": "0.3.0", + "description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.", "type": "module", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -11,6 +11,11 @@ "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" + }, + "./vite": { + "types": "./dist/esm/vite.d.ts", + "import": "./dist/esm/vite.js", + "require": "./dist/cjs/vite.js" } }, "sideEffects": [ @@ -44,11 +49,13 @@ "json", "validation", "decorators", + "standard-decorators", "typescript", - "zod-alternative", "dto", "serialization", - "class-validator" + "class-validator", + "class-transformer", + "class-validator-alternative" ], "author": "Avalon Vanguard", "license": "MIT", @@ -58,6 +65,7 @@ "homepage": "https://github.com/Avalon-Vanguard/cereale#readme", "devDependencies": { "@eslint/js": "^10.0.1", + "@swc/core": "^1.15.47", "@types/node": "^25.6.0", "@vitest/coverage-v8": "^4.1.4", "esbuild": "^0.25.0", diff --git a/src/decorators.ts b/src/decorators.ts index 11b5600..b619a73 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -2,7 +2,7 @@ import type { ClassConstructor, FieldDecorator, JsonDeserializer, JsonSerializer, } from './interfaces.js'; import { - addConstraint, propertyModel, + addConstraint, fieldMetadata, propertyModel, type EachValidationOptions, type PolymorphicInfo, type ValidationArguments, type ValidationConstraint, type ValidationOptions, type ValidatorConstraintInterface, } from './metadata.js'; @@ -35,7 +35,7 @@ function decorate( ): FieldDecorator { return ((_target: undefined, context: ClassFieldDecoratorContext) => { const property = String(context.name); - addConstraint(context.metadata, property, build(property), options); + addConstraint(fieldMetadata(context), property, build(property), options); }) as FieldDecorator; } @@ -72,7 +72,7 @@ function pattern(name: string, regex: RegExp, message: (property: string) => str */ export function JsonProperty(name: string): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).name = name; + propertyModel(fieldMetadata(context), String(context.name)).name = name; }) as FieldDecorator; } @@ -84,14 +84,14 @@ export function JsonProperty(name: string): FieldDecorator { */ export function JsonAlias(...names: string[]): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - const model = propertyModel(context.metadata, String(context.name)); + const model = propertyModel(fieldMetadata(context), String(context.name)); model.aliases = [...(model.aliases ?? []), ...names]; }) as FieldDecorator; } function access(value: 'none' | 'readonly' | 'writeonly'): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).access = value; + propertyModel(fieldMetadata(context), String(context.name)).access = value; }) as FieldDecorator; } @@ -123,7 +123,7 @@ export function JsonSerialize( serializer: ClassConstructor> ): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).serializer = serializer; + propertyModel(fieldMetadata(context), String(context.name)).serializer = serializer; }) as FieldDecorator; } @@ -137,7 +137,7 @@ export function JsonDeserialize( deserializer: ClassConstructor> ): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).deserializer = deserializer; + propertyModel(fieldMetadata(context), String(context.name)).deserializer = deserializer; }) as FieldDecorator; } @@ -151,7 +151,7 @@ export function JsonType( typeFunction: () => ClassConstructor ): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).type = typeFunction; + propertyModel(fieldMetadata(context), String(context.name)).type = typeFunction; }) as FieldDecorator; } @@ -194,7 +194,7 @@ export function JsonPolymorphic( onUnknown: options?.onUnknown ?? 'keep', ...(options?.fallback ? { fallback: options.fallback as ClassConstructor } : {}), }; - propertyModel(context.metadata, String(context.name)).polymorphic = info; + propertyModel(fieldMetadata(context), String(context.name)).polymorphic = info; }) as FieldDecorator; } @@ -205,7 +205,7 @@ export function JsonPolymorphic( /** Skips every other rule on this field when the value is `null` or `undefined`. */ export function IsOptional(): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).optional = true; + propertyModel(fieldMetadata(context), String(context.name)).optional = true; }) as FieldDecorator; } @@ -221,7 +221,7 @@ export function IsOptional(): FieldDecorator { */ export function ValidateIf(condition: (object: This) => boolean): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)).condition = condition as (o: any) => boolean; + propertyModel(fieldMetadata(context), String(context.name)).condition = condition as (o: any) => boolean; }) as FieldDecorator; } @@ -233,7 +233,7 @@ export function ValidateIf(condition: (object: This) => boolean): FieldDec */ export function Allow(): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { - propertyModel(context.metadata, String(context.name)); + propertyModel(fieldMetadata(context), String(context.name)); }) as FieldDecorator; } @@ -244,10 +244,11 @@ export function Allow(): FieldDecorator { */ export function ValidateNested(options?: ValidationOptions): FieldDecorator { return ((_t: undefined, context: ClassFieldDecoratorContext) => { + const metadata = fieldMetadata(context); const property = String(context.name); - propertyModel(context.metadata, property).nested = true; + propertyModel(metadata, property).nested = true; if (options?.each) { - addConstraint(context.metadata, property, { + addConstraint(metadata, property, { name: 'nestedEach', validate: v => Array.isArray(v), message: `${property} must be an array`, diff --git a/src/hardening.test.ts b/src/hardening.test.ts index 3e04f3e..14ae1f9 100644 --- a/src/hardening.test.ts +++ b/src/hardening.test.ts @@ -219,3 +219,44 @@ describe('validate() accepts options', () => { expect(await validate(order)).toHaveLength(1); }); }); + +describe('decorator context guards', () => { + // Every decorator resolves its metadata through one checkpoint, so one representative + // decorator per shape is enough to cover the rule. + const shapes: [string, unknown][] = [ + ['a method', { kind: 'method', name: 'run', metadata: {} }], + ['a getter', { kind: 'getter', name: 'total', metadata: {} }], + ['an accessor', { kind: 'accessor', name: 'value', metadata: {} }], + ['a class', { kind: 'class', name: 'Thing', metadata: {} }], + ]; + + for (const [label, context] of shapes) { + it(`refuses being applied to ${label}`, () => { + expect(() => (IsString() as any)(undefined, context)).toThrow(/apply to fields/); + }); + } + + it('explains why `accessor` in particular cannot work', () => { + const context = { kind: 'accessor', name: 'value', metadata: {} }; + expect(() => (IsString() as any)(undefined, context)).toThrow(/private slot/); + }); + + it('refuses a legacy decorator call shape', () => { + // What `experimentalDecorators: true` emits: (prototype, propertyKey). + expect(() => (IsString() as any)({}, 'name')).toThrow(/experimentalDecorators/); + }); + + it('refuses a standard context that carries no metadata', () => { + const context = { kind: 'field', name: 'value', metadata: undefined }; + expect(() => (IsString() as any)(undefined, context)).toThrow(/no metadata object/); + }); + + it('names the field it could not record', () => { + const context = { kind: 'field', name: 'nickname', metadata: null }; + expect(() => (IsString() as any)(undefined, context)).toThrow(/"nickname"/); + }); + + it('guards the mapping decorators too, not just the rules', () => { + expect(() => (JsonSerialize(class {} as any) as any)({}, 'name')).toThrow(/experimentalDecorators/); + }); +}); diff --git a/src/metadata.ts b/src/metadata.ts index d3f73b8..50c261a 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -126,6 +126,58 @@ function ownModel(metadata: DecoratorMetadata): ClassModel { return (metadata as Record)[MODEL]!; } +/** + * Validates a decorator context and returns the metadata object to record into. + * + * Every decorator goes through here rather than reading `context.metadata` directly, because + * each of the three failures below is a configuration mistake with a one-line fix, and the + * error you get without the check — `TypeError: Cannot convert undefined or null to object`, + * raised somewhere inside cereale — points at none of them. + * + * Typed as `unknown` deliberately: the whole point is to inspect a context that may not have + * the shape the type says it has, because it came from the wrong decorator transform. + */ +export function fieldMetadata(context: unknown): DecoratorMetadata { + const ctx = context as { kind?: unknown; name?: unknown; metadata?: unknown } | null | undefined; + + // A legacy (`experimentalDecorators: true`) field decorator is invoked as + // `(prototype, "propertyName")`, so the second argument is a string, not a context object. + if (typeof ctx !== 'object' || ctx === null || typeof ctx.kind !== 'string') { + throw new TypeError( + 'cereale needs TC39 standard decorators, but the compiler emitted legacy ones. ' + + 'Set "experimentalDecorators": false in tsconfig.json (and drop "emitDecoratorMetadata"). ' + + 'The two decorator systems cannot coexist in one program, so a project that still needs ' + + 'legacy decorators for another library cannot use cereale yet.' + ); + } + + if (ctx.kind !== 'field') { + throw new TypeError( + `cereale decorators apply to fields, but this one was applied to a ${ctx.kind}.` + + (ctx.kind === 'accessor' + ? ' An `accessor` field keeps its value in a private slot that mapping and validation ' + + 'cannot reach — declare it as a plain field instead.' + : '') + ); + } + + // Standard decorators are specified to always carry a metadata object, but the emitted + // helpers create it conditionally: tsc writes `Symbol.metadata ? Object.create(...) : void 0`. + // Importing cereale installs the `Symbol.metadata` fallback, so this only fires if the + // decorated class somehow evaluates first. + if (typeof ctx.metadata !== 'object' || ctx.metadata === null) { + throw new TypeError( + `The decorator context for "${String(ctx.name)}" carries no metadata object, so cereale ` + + 'has nowhere to record the rule. The compiler emitted its decorator helpers without ' + + 'metadata support: make sure cereale is imported before the decorated class is evaluated ' + + '(importing it installs the Symbol.metadata fallback) and that the build targets ES2022 ' + + 'or later.' + ); + } + + return ctx.metadata as DecoratorMetadata; +} + /** Returns (creating if needed) the model entry for one field. */ export function propertyModel(metadata: DecoratorMetadata, property: string): PropertyModel { version++; @@ -181,6 +233,13 @@ export function defineRule( options?: ValidationOptions ): void { const holder = clazz as unknown as Record; - holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata; + // `hasOwn`, not `??=`: a subclass with no decorators of its own *inherits* its base's + // metadata object through the static side of the prototype chain, and `??=` would find it + // non-nullish and write the rule straight into the base. Creating an own object that + // prototype-chains to the inherited one is what the decorator transform itself does, and it + // is what lets `ownModel` copy-on-write the base's rules instead of mutating them. + if (!Object.hasOwn(holder, METADATA_KEY)) { + holder[METADATA_KEY] = Object.create(holder[METADATA_KEY] ?? null) as DecoratorMetadata; + } addConstraint(holder[METADATA_KEY]!, property, constraint, options); } diff --git a/src/representable.test.ts b/src/representable.test.ts new file mode 100644 index 0000000..46ec76f --- /dev/null +++ b/src/representable.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { + IsString, JsonIgnore, JsonSerialize, JsonSerializer, JsonMappingError, + toPlain, toPlainSync, defineRule, modelOf, validateSync, +} from './index.js'; + +/** + * Before 0.3.0 every case in this file produced `{}` (or index-keyed noise, or a bigint that + * made the caller's own `JSON.stringify` throw somewhere unrelated) with nothing logged and + * no error raised. A mapping layer that loses data quietly is worse than one that stops. + */ +describe('values JSON cannot carry', () => { + class Basket { + // Typed loosely on purpose: the point is what happens at runtime, and the decorators are + // deliberately absent so nothing is claiming to handle these. + items: any; + } + + const withItems = (items: unknown) => Object.assign(new Basket(), { items }); + + const cases: [string, unknown, RegExp][] = [ + ['a Map', new Map([['a', 1]]), /is a Map/], + ['a Set', new Set([1, 2]), /is a Set/], + ['a WeakMap', new WeakMap(), /is a WeakMap/], + ['a WeakSet', new WeakSet(), /is a WeakSet/], + ['a Promise', Promise.resolve(1), /is a Promise/], + ['a RegExp', /abc/g, /is a RegExp/], + ['an Error', new Error('boom'), /is an Error/], + ['a TypeError', new TypeError('boom'), /is an Error/], + ['an ArrayBuffer', new ArrayBuffer(8), /is an ArrayBuffer/], + ['a DataView', new DataView(new ArrayBuffer(8)), /is a DataView/], + ['a Uint8Array', new Uint8Array([1, 2, 3]), /is a Uint8Array/], + ['a Float64Array', new Float64Array([1.5]), /is a Float64Array/], + ['a bigint', 10n, /is a bigint/], + ['a symbol', Symbol('x'), /is a symbol/], + ['a function', () => 1, /is a function/], + ]; + + for (const [label, value, expected] of cases) { + it(`refuses ${label}`, () => { + expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(JsonMappingError); + expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(expected); + }); + } + + it('names the property in the message and points at the way out', () => { + expect(() => toPlainSync(withItems(new Map()), { validate: false })) + .toThrow(/items is a Map.*@JsonSerialize\(\).*@JsonIgnore\(\)/s); + }); + + it('names the full path through nested objects and arrays', () => { + class Line { tags: any } + class Order { lines: any } + const order = Object.assign(new Order(), { + lines: [Object.assign(new Line(), { tags: [] }), Object.assign(new Line(), { tags: [new Set(['a'])] })], + }); + + expect(() => toPlainSync(order, { validate: false })).toThrow(/lines\[1\]\.tags\[0\] is a Set/); + }); + + it('reports the root when the offending value is the argument itself', () => { + expect(() => toPlainSync(new Map(), { validate: false })).toThrow(/the value passed in is a Map/); + }); + + it('still allows the built-ins that do map cleanly', () => { + class Fine { + when = new Date('2024-01-01T00:00:00.000Z'); + list = [1, 'two', true, null]; + nested = { deep: { deeper: [{ ok: true }] } }; + empty = {}; + } + + expect(toPlainSync(new Fine(), { validate: false })).toEqual({ + when: '2024-01-01T00:00:00.000Z', + list: [1, 'two', true, null], + nested: { deep: { deeper: [{ ok: true }] } }, + empty: {}, + }); + }); + + it('accepts a Map once a serializer converts it', () => { + class TagsSerializer implements JsonSerializer, Record> { + serialize(value: Map) { return Object.fromEntries(value); } + } + + class Post { + @JsonSerialize(TagsSerializer) + tags!: Map; + } + + const post = new Post(); + post.tags = new Map([['a', 1], ['b', 2]]); + expect(toPlainSync(post, { validate: false })).toEqual({ tags: { a: 1, b: 2 } }); + }); + + it('accepts a Map once the property is ignored', () => { + class Cache { + @IsString() name = 'x'; + @JsonIgnore() entries = new Map([['a', 1]]); + } + + expect(toPlainSync(new Cache(), { validate: false })).toEqual({ name: 'x' }); + }); + + it('refuses a serializer that hands back something unrepresentable', () => { + class BadSerializer implements JsonSerializer { + serialize() { return new Set(['still a Set']); } + } + + class Thing { + @JsonSerialize(BadSerializer) + label!: string; + } + + const thing = new Thing(); + thing.label = 'x'; + expect(() => toPlainSync(thing, { validate: false })).toThrow(/label is a Set/); + }); + + it('refuses an async serializer that resolves to something unrepresentable', async () => { + class SlowBadSerializer implements JsonSerializer { + async serialize() { return new Map([['a', 1]]); } + } + + class Thing { + @JsonSerialize(SlowBadSerializer) + label!: string; + } + + const thing = new Thing(); + thing.label = 'x'; + await expect(toPlain(thing, { validate: false })).rejects.toThrow(/label is a Map/); + }); +}); + +describe('serialization error paths', () => { + it('names where the cycle was found', () => { + class Node { name = 'root'; child: any = null; parent: any = null } + const root = new Node(); + const child = new Node(); + child.name = 'child'; + child.parent = root; + root.child = child; + + expect(() => toPlainSync(root, { validate: false })).toThrow(/at child\.parent/); + }); + + it('names where the depth limit was hit', () => { + class Deep { next: any = null } + const root = new Deep(); + let tip = root; + for (let i = 0; i < 5; i++) { + tip.next = new Deep(); + tip = tip.next; + } + + expect(() => toPlainSync(root, { validate: false, maxDepth: 3 })) + .toThrow(/exceeded while serializing at next\.next\.next/); + }); +}); + +describe('defineRule', () => { + // `??=` on an inherited static symbol property finds the base class's metadata object and + // never creates an own one, so the rule lands on the base and every sibling inherits it. + it('does not write a subclass rule into its base class', () => { + class Base { + @IsString() name!: string; + } + class Sub extends Base { extra!: string } + class Sibling extends Base { } + + defineRule(Sub, 'extra', { + name: 'isShouty', + validate: (v: any) => typeof v === 'string' && v === v.toUpperCase(), + message: 'extra must be upper case', + }); + + expect(Object.keys(modelOf(Sub)).sort()).toEqual(['extra', 'name']); + expect(Object.keys(modelOf(Base))).toEqual(['name']); + expect(Object.keys(modelOf(Sibling))).toEqual(['name']); + + const sibling = Object.assign(new Sibling(), { name: 'ok' }); + expect(validateSync(sibling)).toEqual([]); + }); +}); diff --git a/src/toolchain.test.ts b/src/toolchain.test.ts new file mode 100644 index 0000000..9e44a0a --- /dev/null +++ b/src/toolchain.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { transform } from 'esbuild'; +import { transform as swcTransform } from '@swc/core'; + +import { IsString, modelOf } from './index.js'; +import { standardDecorators } from './vite.js'; + +/** + * The support matrix in the README, executed. + * + * cereale reads `context.metadata`, which only exists if the compiler emitted TC39 standard + * decorators — so which compiler a consumer uses, and how it is configured, decides whether + * the library works at all. Claiming that in prose is not worth much; each row below actually + * compiles a decorated class with the tool in question and checks the metadata arrived. + * + * The one row that cannot run here is oxc, the transformer Vite 8 and Vitest 4 use, because it + * ships inside a native binary with no standalone transform API. Its behaviour is why + * `cereale/vite` exists, and the plugin is covered further down. + */ +const PROBE = ` +const Rule = globalThis.__cerealeProbeRule; + +export class Probe { + @Rule() name; +} +`; + +/** Compiler options a consumer needs for cereale to work. */ +const STANDARD = { experimentalDecorators: false, useDefineForClassFields: true }; +/** What most existing TypeScript projects still have, because class-validator required it. */ +const LEGACY = { experimentalDecorators: true, useDefineForClassFields: false }; + +const emit = { + tsc(source: string, options: typeof STANDARD): string { + return ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + ...options, + }, + }).outputText; + }, + async esbuild(source: string, options: typeof STANDARD): Promise { + const result = await transform(source, { + loader: 'ts', + target: 'es2022', + tsconfigRaw: { compilerOptions: options }, + }); + return result.code; + }, + async swc(source: string, options: typeof STANDARD): Promise { + const result = await swcTransform(source, { + filename: 'probe.ts', + jsc: { + parser: { syntax: 'typescript', decorators: true }, + target: 'es2022', + // swc spells the choice as a proposal date rather than a boolean. + transform: { decoratorVersion: options.experimentalDecorators ? '2021-12' : '2022-03' }, + }, + module: { type: 'es6' }, + }); + return result.code; + }, +}; + +let workspace: string; +let counter = 0; + +beforeAll(async () => { + workspace = await mkdtemp(path.join(tmpdir(), 'cereale-toolchain-')); + // The emitted probe reaches the decorator through a global rather than an import, so that + // it needs no module resolution back into a package that has not been built yet. + (globalThis as Record).__cerealeProbeRule = IsString; +}); + +afterAll(async () => { + delete (globalThis as Record).__cerealeProbeRule; + await rm(workspace, { recursive: true, force: true }); +}); + +/** Writes emitted JavaScript to disk and imports it, the way a consumer's runtime would. */ +async function load(code: string): Promise<{ Probe: unknown }> { + const file = path.join(workspace, `probe-${counter++}.mjs`); + await writeFile(file, code); + return import(pathToFileURL(file).href) as Promise<{ Probe: unknown }>; +} + +describe('compilers that emit standard decorators', () => { + it('tsc records the rule', async () => { + const { Probe } = await load(emit.tsc(PROBE, STANDARD)); + expect(Object.keys(modelOf(Probe))).toEqual(['name']); + }); + + it('esbuild records the rule', async () => { + const { Probe } = await load(await emit.esbuild(PROBE, STANDARD)); + expect(Object.keys(modelOf(Probe))).toEqual(['name']); + }); + + it('swc records the rule', async () => { + const { Probe } = await load(await emit.swc(PROBE, STANDARD)); + expect(Object.keys(modelOf(Probe))).toEqual(['name']); + }); +}); + +describe('compilers configured for legacy decorators', () => { + // Left unguarded, both of these die inside cereale with `TypeError: Cannot convert undefined + // or null to object`, which names neither the cause nor the setting that fixes it. + it('tsc emit is refused by name', async () => { + await expect(load(emit.tsc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/); + }); + + it('esbuild emit is refused by name', async () => { + await expect(load(await emit.esbuild(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/); + }); + + it('swc emit is refused by name', async () => { + await expect(load(await emit.swc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/); + }); +}); + +describe('cereale/vite', () => { + const plugin = (options?: Parameters[0]) => standardDecorators(options); + + it('lowers decorator syntax that oxc would pass through untouched', async () => { + const result = await plugin().transform(PROBE, '/app/src/model.ts'); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/@Rule\(\)/); + + const { Probe } = await load(result!.code); + expect(Object.keys(modelOf(Probe))).toEqual(['name']); + }); + + it('produces working output through the TypeScript compiler too', async () => { + const result = await plugin({ transformer: 'typescript' }).transform(PROBE, '/app/src/model.ts'); + const { Probe } = await load(result!.code); + expect(Object.keys(modelOf(Probe))).toEqual(['name']); + }); + + it('emits a source map', async () => { + const result = await plugin().transform(PROBE, '/app/src/model.ts'); + expect(result!.map).toBeTruthy(); + }); + + // tsc appends one pointing at a file that was never written; Vite follows it and logs a + // failure to read the map for every transformed module. + it('does not leave a sourceMappingURL comment behind', async () => { + for (const transformer of ['esbuild', 'typescript'] as const) { + const result = await plugin({ transformer }).transform(PROBE, '/app/src/model.ts'); + expect(result!.code, transformer).not.toMatch(/sourceMappingURL/); + } + }); + + for (const id of ['/app/src/model.ts', '/app/src/model.mts', '/app/src/model.cts', '/app/src/model.ts?v=123']) { + it(`transforms ${id}`, async () => { + expect(await plugin().transform(PROBE, id)).not.toBeNull(); + }); + } + + for (const id of ['/app/node_modules/dep/model.ts', '/app/src/model.js', '/app/src/model.tsx', '/app/src/style.css']) { + it(`leaves ${id} alone`, async () => { + expect(await plugin().transform(PROBE, id)).toBeNull(); + }); + } + + it('honours a caller-supplied include', async () => { + const onlyModels = plugin({ include: id => id.includes('/models/') }); + expect(await onlyModels.transform(PROBE, '/app/src/models/user.ts')).not.toBeNull(); + expect(await onlyModels.transform(PROBE, '/app/src/routes/user.ts')).toBeNull(); + }); + + it('runs before Vite’s own transform', () => { + expect(plugin().enforce).toBe('pre'); + }); + + it('rejects a target the compiler does not know', async () => { + const bad = plugin({ transformer: 'typescript', target: 'es1999' }); + await expect(bad.transform(PROBE, '/app/src/model.ts')).rejects.toThrow(/es1999/); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 7ee0557..e7a9ff7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -242,14 +242,113 @@ function refuseAsync(deferred: Deferred, operation: string, asyncName: string): ); } -function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: number, deferred: Deferred): any { - if (obj === null || obj === undefined || typeof obj !== 'object') { +/** + * Values that carry their data in internal slots rather than in own enumerable properties. + * + * Walking one of these with `Object.keys` yields `{}` — a populated `Map` becomes an empty + * object and nothing anywhere says so. Keyed by `Symbol.toStringTag`, which every one of them + * defines on its prototype, so the lookup costs a single property read and still recognises + * instances that came from another realm. + */ +const UNREPRESENTABLE: Record = { + Map: 'a Map', + Set: 'a Set', + WeakMap: 'a WeakMap', + WeakSet: 'a WeakSet', + WeakRef: 'a WeakRef', + Promise: 'a Promise', + ArrayBuffer: 'an ArrayBuffer', + SharedArrayBuffer: 'a SharedArrayBuffer', + DataView: 'a DataView', + Generator: 'a generator', + AsyncGenerator: 'an async generator', +}; + +/** + * Describes why a value cannot be represented in JSON, or returns null if it can. + * + * The alternative to raising this is what the engine used to do: emit `{}` for a `Map`, + * index-keyed noise for a `Uint8Array`, and a bigint that makes the caller's own + * `JSON.stringify` throw somewhere else entirely. This library's position is that silent + * success is the worst failure mode a mapping layer can have, and that has to include its own. + */ +function unrepresentableObject(value: object): string | null { + const tag = (value as Record)[Symbol.toStringTag]; + if (typeof tag === 'string') { + const known = UNREPRESENTABLE[tag]; + if (known !== undefined) return known; + // Typed arrays are tagged with their own name and would serialize to `{"0":…,"1":…}`. + if (ArrayBuffer.isView(value)) return `a ${tag}`; + } + + // RegExp and Error get their `Object.prototype.toString` tag from a spec special case + // rather than from `Symbol.toStringTag`, so neither is caught above. + if (value instanceof RegExp) return 'a RegExp'; + if (value instanceof Error) return 'an Error, whose message and stack are not enumerable'; + + return null; +} + +const BIGINT_REASON = 'a bigint, which JSON has no representation for'; + +/** The same question for a value of any type. `serialize` inlines the primitive half. */ +function unrepresentable(value: unknown): string | null { + switch (typeof value) { + case 'bigint': return BIGINT_REASON; + case 'symbol': return 'a symbol'; + case 'function': return 'a function'; + case 'object': return value === null ? null : unrepresentableObject(value); + default: return null; + } +} + +/** + * The trail of keys walked to reach a value: property names as strings, array positions as + * numbers. Numbers are kept unformatted so that walking an array costs no string building. + */ +type Path = (string | number)[]; + +/** Renders a {@link Path} for error messages. */ +function describePath(path: readonly (string | number)[]): string { + if (path.length === 0) return 'the value passed in'; + let out = ''; + for (const segment of path) { + if (typeof segment === 'number') out += `[${segment}]`; + else out += out === '' ? segment : `.${segment}`; + } + return out; +} + +function refuseUnrepresentable(why: string, path: readonly (string | number)[]): never { + throw new JsonMappingError( + `${describePath(path)} is ${why}, which cannot be serialized to JSON. ` + + 'Give the property a @JsonSerialize() serializer that converts it, or drop it from the ' + + 'output with @JsonIgnore().' + ); +} + +/** + * @param path The keys walked to reach `obj`, kept as a stack so that errors can name the + * offending property. Pushed and popped rather than concatenated, so the bookkeeping costs + * no string building on the way down. + */ +function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: number, deferred: Deferred, path: Path): any { + if (obj === null || obj === undefined) return obj; + + // Primitives dominate the walk, so their check is inline: one `typeof` and, for the three + // types JSON cannot carry, a throw. Everything else defers to `unrepresentableObject`, + // which is only reached once per object and skipped entirely for arrays and dates. + const type = typeof obj; + if (type !== 'object') { + if (type === 'bigint') refuseUnrepresentable(BIGINT_REASON, path); + if (type === 'symbol') refuseUnrepresentable('a symbol', path); + if (type === 'function') refuseUnrepresentable('a function', path); return obj; } if (depth > ctx.maxDepth) { throw new JsonMappingError( - `Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` + + `Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing at ${describePath(path)}. ` + `Raise it with the maxDepth option if this structure is legitimate.` ); } @@ -258,19 +357,28 @@ function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: return obj.toISOString(); } + const isArray = Array.isArray(obj); + if (!isArray) { + const why = unrepresentableObject(obj); + if (why !== null) refuseUnrepresentable(why, path); + } + if (ancestors.has(obj)) { throw new JsonMappingError( - 'Circular reference detected during serialization. Break the cycle with @JsonIgnore() ' + - 'on the back-reference, or supply a @JsonSerialize() serializer for that property.' + `Circular reference detected during serialization at ${describePath(path)}. Break the cycle ` + + 'with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for ' + + 'that property.' ); } ancestors.add(obj); try { - if (Array.isArray(obj)) { + if (isArray) { const out: any[] = []; - for (const item of obj) { - out.push(serialize(item, ancestors, ctx, depth + 1, deferred)); + for (let index = 0; index < obj.length; index++) { + path.push(index); + out.push(serialize(obj[index], ancestors, ctx, depth + 1, deferred, path)); + path.pop(); } return out; } @@ -283,6 +391,7 @@ function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: if (property.skip) continue; const value = obj[key]; + path.push(key); // Custom serializers only see real values. Handing a serializer `undefined` for a // property that was simply never set turns an optional field into a crash. @@ -292,14 +401,24 @@ function serialize(obj: any, ancestors: Set, ctx: SerializeContext, depth: const slot = property.name; // Claim the key now so the deferred write lands in declaration order rather than // being appended after every synchronous property. + const where = [...path]; result[slot] = undefined; - deferred.push(produced.then((settled: any) => { result[slot] = settled; })); + deferred.push(produced.then((settled: any) => { + const bad = unrepresentable(settled); + if (bad !== null) refuseUnrepresentable(bad, where); + result[slot] = settled; + })); } else { + // A serializer that hands back a Map is the same silent `{}` by another route. + const bad = unrepresentable(produced); + if (bad !== null) refuseUnrepresentable(bad, path); result[property.name] = produced; } } else { - result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred); + result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred, path); } + + path.pop(); } return result; @@ -795,7 +914,7 @@ export async function toPlain(obj: T, options?: TransformOptions): Promise(obj: T, options?: TransformOptions): any { } const deferred: Deferred = []; - const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred); + const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []); refuseAsync(deferred, 'toPlainSync()', 'toPlain()'); return plain; } diff --git a/src/vite.ts b/src/vite.ts new file mode 100644 index 0000000..d180bf2 --- /dev/null +++ b/src/vite.ts @@ -0,0 +1,175 @@ +/** + * A Vite plugin that lowers TC39 standard decorators, for projects on Vite 8 or Vitest 4. + * + * Those versions transform TypeScript with oxc, which does not implement the standard + * decorator transform yet. It does not report that: it leaves the decorator syntax in the + * output, so `vitest` prints "0 test" next to a bare `SyntaxError`, and `vite build` reports + * success while emitting a bundle that throws `SyntaxError` the moment anything imports it. + * + * Nothing here is specific to cereale — any library built on standard decorators needs it — + * but cereale ships it because a consumer's first experience of the library should not be a + * syntax error with no obvious cause. Delete it once oxc supports the transform. + * + * ```ts + * // vite.config.ts / vitest.config.ts + * import { standardDecorators } from 'cereale/vite'; + * + * export default defineConfig({ plugins: [standardDecorators()] }); + * ``` + * + * The transform is done by esbuild if it is installed, otherwise by the TypeScript compiler. + * cereale depends on neither; one of the two is present in essentially every TypeScript + * project, and the plugin says which to install if somehow neither is. + */ + +/** + * The shape Vite expects of a plugin, declared here rather than imported. + * + * `cereale/vite` must not drag `vite` into a consumer's type-checking just to describe its own + * return value — this object is structurally assignable to Vite's `Plugin`. + */ +export interface StandardDecoratorsPlugin { + name: string; + enforce: 'pre'; + transform(code: string, id: string): Promise<{ code: string; map: string } | null>; +} + +export interface StandardDecoratorsOptions { + /** + * Decides which modules to transform. Receives the resolved module id. + * + * The default takes `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded + * because lowering decorators there means also deciding what happens to the JSX, and + * getting that wrong is worse than not handling it; pass an `include` of your own if you + * declare decorated classes in `.tsx` files. + */ + include?: (id: string) => boolean; + /** ECMAScript target for the emitted code. Defaults to `es2022`, the first with class fields. */ + target?: string; + /** + * Which tool does the transform. `'auto'` (the default) prefers esbuild for speed and falls + * back to the TypeScript compiler; name one explicitly to keep a build reproducible, or to + * fail loudly rather than silently switch if the preferred one is not installed. + */ + transformer?: 'auto' | 'esbuild' | 'typescript'; +} + +const DEFAULT_INCLUDE = (id: string): boolean => + /\.[cm]?ts(\?.*)?$/.test(id) && !id.includes('/node_modules/'); + +type Transformer = (code: string, id: string, target: string) => Promise<{ code: string; map: string }>; + +function isMissingModule(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND'; +} + +async function esbuildTransformer(): Promise { + let esbuild: typeof import('esbuild'); + try { + esbuild = await import('esbuild'); + } catch (error) { + if (isMissingModule(error)) return null; + throw error; + } + return async (code, id, target) => { + const result = await esbuild.transform(code, { + loader: 'ts', + target, + sourcefile: id, + sourcemap: true, + // Standard semantics, not the legacy ones: cereale records into `context.metadata`. + tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } }, + }); + return { code: result.code, map: result.map }; + }; +} + +async function typescriptTransformer(): Promise { + let ts: typeof import('typescript'); + try { + ts = await import('typescript'); + } catch (error) { + if (isMissingModule(error)) return null; + throw error; + } + // `ScriptTarget` members are spelled `ES2022`, `ESNext`; esbuild-style targets are lower + // case. Matched case-insensitively rather than upper-casing, which would miss `ESNext`. + const targetKey = (target: string) => + Object.keys(ts.ScriptTarget).find(key => key.toLowerCase() === target.toLowerCase()); + + return async (code, id, target) => { + const key = targetKey(target); + if (key === undefined) { + throw new Error(`cereale/vite: ${JSON.stringify(target)} is not a target the TypeScript compiler knows.`); + } + const result = ts.transpileModule(code, { + fileName: id.replace(/\?.*$/, ''), + compilerOptions: { + target: ts.ScriptTarget[key as keyof typeof ts.ScriptTarget], + module: ts.ModuleKind.ESNext, + experimentalDecorators: false, + useDefineForClassFields: true, + sourceMap: true, + isolatedModules: true, + }, + }); + // tsc appends `//# sourceMappingURL=.map` even though the map is handed back + // separately. Vite would follow that comment and fail to read a file nobody wrote. + const output = result.outputText.replace(/\r?\n?\/\/# sourceMappingURL=\S*[ \t]*$/, ''); + return { code: output, map: result.sourceMapText ?? '' }; + }; +} + +const FACTORIES = { esbuild: esbuildTransformer, typescript: typescriptTransformer }; + +// Resolution is memoized per choice: the transform hook runs once per module, and neither +// `import('esbuild')` nor `import('typescript')` is cheap enough to repeat. +const resolved = new Map>(); + +function resolveTransformer(choice: 'auto' | 'esbuild' | 'typescript'): Promise { + let pending = resolved.get(choice); + if (!pending) { + pending = (async () => { + if (choice !== 'auto') { + const only = await FACTORIES[choice](); + if (only) return only; + throw new Error( + `cereale/vite was asked to transform with ${choice}, which is not installed. ` + + `Install it (\`npm i -D ${choice}\`) or drop the \`transformer\` option to let the ` + + 'plugin pick whichever is available.' + ); + } + const best = (await esbuildTransformer()) ?? (await typescriptTransformer()); + if (best) return best; + throw new Error( + 'cereale/vite needs a transformer that understands TC39 standard decorators, and found ' + + 'neither esbuild nor typescript. Install one of them as a dev dependency: ' + + '`npm i -D esbuild`.' + ); + })(); + resolved.set(choice, pending); + } + return pending; +} + +/** + * Transforms TypeScript sources with esbuild (or tsc) before Vite's own oxc pass sees them. + * + * `enforce: 'pre'` is what makes this work: the hook runs ahead of Vite's transform, hands + * back plain JavaScript, and oxc is then left with nothing it cannot parse. + */ +export function standardDecorators(options: StandardDecoratorsOptions = {}): StandardDecoratorsPlugin { + const include = options.include ?? DEFAULT_INCLUDE; + const target = options.target ?? 'es2022'; + const choice = options.transformer ?? 'auto'; + + return { + name: 'cereale:standard-decorators', + enforce: 'pre', + async transform(code: string, id: string) { + if (!include(id)) return null; + return (await resolveTransformer(choice))(code, id, target); + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 451586f..3d47a73 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,34 +1,10 @@ import { defineConfig } from 'vitest/config'; -import { transform } from 'esbuild'; +import { standardDecorators } from './src/vite.js'; /** - * Transpiles test sources with esbuild instead of oxc. - * - * Vitest 4 transforms with oxc, which does not yet implement the TC39 standard decorator - * transform — it leaves the syntax in place and Node then fails to parse it, reporting - * "0 test" rather than an error. esbuild and tsc both implement it, so the library's own - * build (`tsc`) and consumers bundling with esbuild or Vite are unaffected; only the test - * runner needs this. Remove it once oxc gains standard-decorator support. + * The library's own tests run through the plugin the library ships, so that `cereale/vite` + * is exercised by every test run rather than only by the one test that asserts it exists. */ -function standardDecorators() { - return { - name: 'cereale:standard-decorators', - enforce: 'pre' as const, - async transform(code: string, id: string) { - if (!/\.ts$/.test(id) || id.includes('node_modules')) return null; - const result = await transform(code, { - loader: 'ts', - target: 'es2022', - sourcefile: id, - sourcemap: true, - // Standard semantics, not the legacy ones: the library reads context.metadata. - tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } }, - }); - return { code: result.code, map: result.map }; - }, - }; -} - export default defineConfig({ plugins: [standardDecorators()], test: {