Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c3fd45b1 | ||
|
|
930694722f |
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
branches: [ main, develop, 0.1.x ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
branches: [ main, develop, 0.1.x ]
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
@@ -37,16 +37,5 @@ 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
|
||||
- name: Published types stand alone
|
||||
run: npm run check:types
|
||||
- name: Landing page loads nothing from the network
|
||||
run: npm run check:docs
|
||||
- name: Landing page bundle is in sync with src/
|
||||
run: |
|
||||
npm run build:docs
|
||||
git diff --exit-code -- docs/ \
|
||||
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
name: Publish to npm
|
||||
|
||||
# Deliberately manual. Pushing a tag does NOT publish — a tag is a decision to cut a release,
|
||||
# not a decision to make it public and immutable, and npm's 72-hour unpublish window makes the
|
||||
# second one hard to take back. Run this workflow from the Actions tab when you mean it.
|
||||
#
|
||||
# Before the first real run:
|
||||
# 1. Create an npm automation token and add it as the NPM_TOKEN repository secret.
|
||||
# 2. Run once with dry_run left as `true` and read the file list it prints.
|
||||
# 3. Run again with dry_run set to `false`.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Resolve and pack everything, but do not publish'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # required for npm provenance
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: ${{ inputs.dry_run && 'Dry run' || 'Publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Tags are not fetched at the default depth, and the version gate below
|
||||
# resolves one — without this it fails on every release, tag or no tag.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# The tag and the manifest disagreeing is the classic way to publish 0.3.0 as 0.2.0.
|
||||
- name: Tag and package.json version must agree
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "package.json version: $VERSION"
|
||||
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||
echo "tag v$VERSION exists"
|
||||
else
|
||||
echo "::error::No tag v$VERSION. Tag the release commit before publishing."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "v$VERSION^{commit}")" ]; then
|
||||
echo "::error::v$VERSION does not point at the commit being published."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Refuse to republish a version already on the registry
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
NAME=$(node -p "require('./package.json').name")
|
||||
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "::error::$NAME@$VERSION is already published. Bump the version."
|
||||
exit 1
|
||||
fi
|
||||
echo "$NAME@$VERSION is not on the registry yet."
|
||||
|
||||
# The same gate that guards every push: type-check, lint, 259 tests, build, and the
|
||||
# checks that the published types stand alone and the landing page has no CDN deps.
|
||||
- name: Verify
|
||||
run: npm run verify
|
||||
|
||||
- name: Show exactly what would ship
|
||||
run: npm publish --dry-run
|
||||
|
||||
- name: Publish
|
||||
if: ${{ inputs.dry_run == false }}
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
{
|
||||
echo "### cereale@$VERSION"
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
echo "Dry run — nothing was published."
|
||||
else
|
||||
echo "Published to https://www.npmjs.com/package/cereale/v/$VERSION"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
+9
-248
@@ -5,256 +5,17 @@ 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.
|
||||
- `fromRequest` was declared as taking the global `Request`, so cereale's own published
|
||||
`.d.ts` raised `Cannot find name 'Request'` in any project whose `lib` and `types` did not
|
||||
happen to supply it — an error inside a dependency, in code the consumer may never call,
|
||||
that they could not fix from the outside. It now takes a structural `JsonBody`
|
||||
(`{ json(): Promise<any> }`), which a `Request` still satisfies. The library's own type
|
||||
tests had been hiding this by enabling both `DOM` and `skipLibCheck`; `npm run check:types`
|
||||
now compiles a consumer against `dist/` with neither.
|
||||
|
||||
### The landing page
|
||||
|
||||
`docs/index.html` was rebuilt. Its playground had been dead for some time and said nothing
|
||||
about it: the page loaded `@babel/standalone` from an **unpinned** CDN URL, which rolled over
|
||||
to Babel 8 and dropped the `proposal-class-properties` plugin the page asked for, so
|
||||
`Babel.transform` threw before it ever reached the decorators — and the decorator config it
|
||||
passed was `{ legacy: true }`, which 0.2.0 had already made wrong. The copy was still selling
|
||||
the 0.1.0 pitch ("Spring-like"), listed about half the decorators, and claimed "Zero overhead"
|
||||
against a README that publishes the real microsecond costs.
|
||||
|
||||
The rebuild is one self-contained page: hand-written CSS, no Tailwind CDN, no CodeMirror, and
|
||||
a vendored compiler pinned by `package.json`. It loads **nothing** from the network, which
|
||||
`npm run check:docs` now enforces in CI. The playground runs the real bundled library across
|
||||
six examples; the reference lists all 68 decorators and the full API, counted from the bundle
|
||||
at runtime so it cannot drift.
|
||||
|
||||
The hero's compiler error is not typed into the HTML — `scripts/build-docs.mjs` compiles the
|
||||
snippet with the real `tsc` and writes the verbatim diagnostic into `docs/diagnostics.js`,
|
||||
failing the build if a snippet the page calls a compile error ever compiles. Two more snippets
|
||||
that must compile guard against the harness passing vacuously.
|
||||
|
||||
### Corrected
|
||||
|
||||
The README's toolchain table said esbuild takes "the same settings via `tsconfigRaw`". It does
|
||||
not: esbuild lowers standard decorators only when its **own top-level `target`** is below
|
||||
`esnext`. A `target` inside `tsconfigRaw` sets the `useDefineForClassFields` default and
|
||||
nothing else, so following that advice leaves decorator syntax in the output — the same silent
|
||||
passthrough the section blames on oxc. Both the table and the landing page now say so, and
|
||||
`src/toolchain.test.ts` asserts both halves, so the trap is documented by a test rather than by
|
||||
a sentence.
|
||||
|
||||
Also corrected in the same pass: the toolchain table is described as executed by a test, but
|
||||
the oxc row — the only ✗ — cannot be, because oxc ships inside a native binary with no
|
||||
standalone transform API. The claim now covers the three rows it actually covers.
|
||||
|
||||
### A rename now actually takes effect
|
||||
|
||||
**Breaking.** The docs said that once a property carries `@JsonProperty`, its original name
|
||||
"is no longer accepted on input". It stopped being *mapped*, but it was not refused: unlike
|
||||
`@JsonReadOnly`, whose JSON name goes into the blocked set, a renamed property's old key fell
|
||||
through to the unknown-key policy, and the default `allow` copied it onto the instance
|
||||
untouched. The value landed on a declared property having skipped everything declared for it —
|
||||
no `@JsonType` conversion, so `@ValidateNested` then inspected a plain object with no model and
|
||||
reported nothing. A payload aimed at the previous version of a class was accepted in part, in
|
||||
silence.
|
||||
|
||||
Names that no longer reach their property are now refused. That covers three routes to the
|
||||
same hole:
|
||||
|
||||
- the property key of a field renamed with `@JsonProperty`
|
||||
- the raw key of a field a naming strategy renders differently (`firstName` under `snake_case`)
|
||||
- the property key of a field that is both renamed and `@JsonReadOnly`, which was still
|
||||
settable under its own key
|
||||
|
||||
Refused, not silently swallowed. A stale name is a mismatch with whatever produced the payload
|
||||
rather than a deliberate refusal like `@JsonReadOnly`, so `unknownKeys: 'error'` still reports
|
||||
it — and now says which property it was reaching for and what that property is called now:
|
||||
|
||||
```
|
||||
JsonMappingError: "ref" is not a JSON name for Order: property "ref" is mapped to
|
||||
"order_ref". Send that name, or add @JsonAlias("ref") to keep accepting this one.
|
||||
```
|
||||
|
||||
`@JsonAlias` remains the way to keep an old name working, and a key that some *other* property
|
||||
legitimately answers to is still mapped to that property.
|
||||
|
||||
Two reference entries on the landing page were also imprecise: `@IsNotEmpty()` and `@IsEmpty()`
|
||||
read as complements but are not (`[]` and `{}` pass both), and `unknownKeys` is
|
||||
deserialization-only.
|
||||
|
||||
### 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.
|
||||
|
||||
### Packaging
|
||||
|
||||
`files` was `["dist"]`, but `dist` carries 256 KB of `.js.map` and `.d.ts.map` files whose
|
||||
`sources` point at `../../src/*.ts` — which was not published. Every shipped sourcemap
|
||||
resolved to nothing: 44% of the tarball, dead. The source is only 116 KB and its comments are
|
||||
the most detailed explanation of why the engine does what it does, so it is now published
|
||||
(tests and the demo excluded) and the maps resolve. Stepping into cereale in a debugger, and
|
||||
"go to definition" from a decorator, both land in the real TypeScript.
|
||||
|
||||
`CHANGELOG.md` ships too. The `repository`, `homepage` and `bugs` URLs said `Avalon-Vanguard`
|
||||
and only worked through GitHub's redirect; they now use the org's actual lowercase name.
|
||||
`publishConfig.access` is set explicitly so a future move to a scoped name cannot quietly
|
||||
attempt a private publish.
|
||||
|
||||
A `Publish to npm` workflow is in place but deliberately manual — pushing a tag does not
|
||||
publish. It checks that the tag exists and points at the commit being published, refuses a
|
||||
version already on the registry, runs the full `verify` gate, prints the file list, and
|
||||
defaults to a dry run. Publishing needs an `NPM_TOKEN` secret and someone choosing to run it.
|
||||
|
||||
## [0.2.0] - 2026-08-04
|
||||
|
||||
> The project stays on 0.x while nothing has been published: under semver that signals the
|
||||
> API may still move, which is honest for software with no real-world users. A breaking
|
||||
> change is therefore a minor bump, which is why this is 0.2.0 rather than 2.0.0.
|
||||
|
||||
**Breaking.** Cereale moves to TC39 standard decorators, which is what makes validation rules
|
||||
type-checked against the fields they are attached to.
|
||||
|
||||
### The headline
|
||||
|
||||
A rule that does not fit its field is now a compile error:
|
||||
|
||||
```ts
|
||||
class User {
|
||||
@IsString() name!: string; // fine
|
||||
@IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||
}
|
||||
```
|
||||
|
||||
Legacy decorators receive `(target: any, key: string)` and lose the field's type entirely, so
|
||||
this was impossible in v1. Standard decorators receive `ClassFieldDecoratorContext<This, Value>`,
|
||||
which carries it. Checked rules include:
|
||||
|
||||
- scalar rules against scalar fields (`@Min` on a string is rejected)
|
||||
- `{ each: true }` against arrays (`@IsString({ each: true })` demands a `string[]`, and a bare
|
||||
`@IsString()` on a `string[]` is rejected)
|
||||
- `@JsonType(() => Address)` against the field's class
|
||||
- `@JsonSerialize` / `@JsonDeserialize` against the field's type
|
||||
- `@IsIn([...])` and `@IsEnum(E)` against the field's value type
|
||||
|
||||
17 tests invoke the real compiler to assert these stay rejected.
|
||||
|
||||
### Migration
|
||||
|
||||
- Remove `"experimentalDecorators": true`; add `"ESNext.Decorators"` to `lib`.
|
||||
- `registerDecorator({ target, propertyName, validator })` is replaced by
|
||||
`defineRule(Class, 'field', constraint)`.
|
||||
- Decorators cannot be applied to `abstract` fields. Declare the field concretely in the base.
|
||||
- Field types may need tightening where a rule narrows them: `@IsIn(['a','b']) x!: string`
|
||||
becomes `x!: 'a' | 'b'`.
|
||||
- `@JsonPolymorphic` takes its base type explicitly to check subtypes:
|
||||
`@JsonPolymorphic<Media>('type', [...])`.
|
||||
- `@ValidateIf` takes the class as a type argument: `@ValidateIf<Movie>(m => ...)`.
|
||||
|
||||
Everything else — the engine, options, naming strategies, access control, error helpers, the
|
||||
sync API — is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- `metadata-storage.ts` and its WeakMap singleton. Metadata now lives on `context.metadata`,
|
||||
the language's own mechanism, which also removes the dual ESM/CJS double-singleton hazard.
|
||||
- `registerDecorator`, replaced by `defineRule`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Inheritance merging is now structural rather than reconstructed: `context.metadata` inherits
|
||||
through the prototype chain, so the subclass-shadowing defect fixed by hand in 0.1.0 cannot
|
||||
reoccur by construction. Identical inherited rules are still collapsed so re-stating a rule
|
||||
on an override does not double-report.
|
||||
|
||||
### Toolchain
|
||||
|
||||
Standard decorators are transformed by `tsc` and by esbuild; **oxc does not implement them
|
||||
yet**. The library builds with `tsc` and consumers bundling with esbuild or Vite are fine, but
|
||||
the test runner (Vitest 4, which uses oxc) needs an esbuild transform plugin — see
|
||||
`vitest.config.ts`. Projects on an oxc-based toolchain should stay on 0.1.x for now.
|
||||
|
||||
## [0.1.0] - 2026-08-05
|
||||
|
||||
The legacy-decorator line, now maintained in parallel with 0.2.x. Functionally identical
|
||||
to the previous state; only the maintenance-line notice was added. See the 0.2.x branch
|
||||
for standard decorators and compile-time rule checking.
|
||||
|
||||
The project stays on 0.x while nothing has been published: under semver that signals the
|
||||
API may still move, which is honest for software with no real-world users yet. A breaking
|
||||
change is therefore a minor bump — 0.1.x to 0.2.x.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
**Synchronous API.** `validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`,
|
||||
|
||||
@@ -1,73 +1,27 @@
|
||||
# Cereale
|
||||
|
||||
**Validated domain objects, not validated data.**
|
||||
> **This is the 0.1.x maintenance line**, which uses TypeScript's legacy
|
||||
> `experimentalDecorators`. It is feature-complete and supported for bug fixes.
|
||||
>
|
||||
> **New projects should use 0.2.x**, which moves to TC39 standard decorators and gains
|
||||
> compile-time checking of validation rules against field types — `@IsString() age: number`
|
||||
> becomes a compile error rather than a runtime surprise.
|
||||
>
|
||||
> Stay on 0.1.x if your toolchain transpiles with **oxc**, which does not yet implement the
|
||||
> standard decorator transform. `tsc` and esbuild both do, so most projects can move.
|
||||
|
||||
Cereale maps JSON onto your own classes and gives you back real instances — with your methods,
|
||||
your inheritance, your `instanceof` checks — and type-checks the validation rules against the
|
||||
fields they are attached to. Zero runtime dependencies.
|
||||
|
||||
```typescript
|
||||
class User {
|
||||
@JsonProperty('display_name')
|
||||
@IsString() @MinLength(2)
|
||||
displayName!: string;
|
||||
|
||||
@IsInt() @Min(0)
|
||||
age!: number;
|
||||
|
||||
@IsString()
|
||||
age2!: number; // ← compile error: Type 'number' is not assignable to type 'string'
|
||||
|
||||
greet() { return `Hi ${this.displayName}`; }
|
||||
}
|
||||
|
||||
const user = fromJsonSync(User, body); // a real User
|
||||
user.greet(); // your methods are still there
|
||||
```
|
||||
|
||||
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
|
||||
interactive playground that runs this library in your browser, the full decorator reference,
|
||||
and the toolchain matrix. The page is self-contained and loads nothing from the network; it is
|
||||
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
|
||||
|
||||
## Where it fits
|
||||
|
||||
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 |
|
||||
| Bidirectional mapping (renaming both ways) | not the focus | first-class |
|
||||
|
||||
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.
|
||||
Cereale is a lightweight TypeScript library that provides Spring-like decorators for JSON mapping and validation. Built with ZERO external dependencies, it simplifies the process of converting between plain JSON and class instances with full validation support.
|
||||
|
||||
## Features
|
||||
|
||||
- **Strongly typed decorators:** a rule that does not fit its field is a compile error.
|
||||
- **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+.
|
||||
- **Spring-like Decorators:** Familiar `@JsonProperty`, `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
|
||||
- **Field-name Mapping:** Map `first_name` to `firstName` per property or with a naming strategy.
|
||||
- **Access Control:** Keep passwords out of responses and server-owned ids out of requests.
|
||||
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
|
||||
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
|
||||
- **Integrated Validation:** 50+ validation decorators, applied during mapping or on demand.
|
||||
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
|
||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -75,129 +29,109 @@ entity, anything with behaviour attached. Reach for Zod when you just want the d
|
||||
npm install cereale
|
||||
```
|
||||
|
||||
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
|
||||
Enable `experimentalDecorators` in your `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ESNext", "ESNext.Decorators"]
|
||||
"experimentalDecorators": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requires TypeScript 5.2+ and Node 20+. `reflect-metadata` is not needed and
|
||||
`emitDecoratorMetadata` is not read.
|
||||
|
||||
`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 the three
|
||||
✅ rows are [checked by a test](src/toolchain.test.ts) rather than asserted here — each compiles a
|
||||
decorated class with that tool and asserts the metadata arrived. The ❌ row cannot be: oxc ships
|
||||
inside a native binary with no standalone transform API.
|
||||
|
||||
| Transformer | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `tsc` | ✅ | With `experimentalDecorators: false` and `target: ES2022`+ |
|
||||
| esbuild | ✅ | `experimentalDecorators: false` via `tsconfigRaw`, **plus** esbuild's own top-level `target: es2022`. Its default `esnext` target leaves decorator syntax in the output |
|
||||
| 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.
|
||||
Cereale stores its own metadata, so `reflect-metadata` is not required and
|
||||
`emitDecoratorMetadata` is not read. Requires Node 20 or later.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define your model
|
||||
### 1. Define your Models
|
||||
|
||||
```typescript
|
||||
import {
|
||||
IsString, IsDate, IsInt, Min, ValidateNested, JsonType,
|
||||
JsonProperty, JsonWriteOnly, JsonPolymorphic,
|
||||
IsString,
|
||||
IsDate,
|
||||
ValidateNested,
|
||||
JsonSerialize,
|
||||
JsonDeserialize,
|
||||
JsonPolymorphic,
|
||||
JsonSerializer,
|
||||
JsonDeserializer
|
||||
} from 'cereale';
|
||||
|
||||
class Address {
|
||||
@IsString() street!: string;
|
||||
@IsString() city!: string;
|
||||
// Custom Date Serializer
|
||||
class DateSerializer implements JsonSerializer<Date, string> {
|
||||
serialize(value: Date): string {
|
||||
return value.toISOString().split('T')[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
format() { return `${this.street}, ${this.city}`; }
|
||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
deserialize(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Media {
|
||||
// Standard decorators cannot decorate an `abstract` member, so declare it concretely.
|
||||
@IsString() type: string = '';
|
||||
@IsString() title: string = '';
|
||||
@IsString()
|
||||
abstract type: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@IsString() override type = 'book';
|
||||
@IsString() author!: string;
|
||||
type = 'book';
|
||||
|
||||
@JsonProperty('published_at')
|
||||
@IsString()
|
||||
author: string;
|
||||
|
||||
@JsonSerialize(DateSerializer)
|
||||
@JsonDeserialize(DateDeserializer)
|
||||
@IsDate()
|
||||
publishedAt!: Date;
|
||||
publishedAt: Date;
|
||||
}
|
||||
|
||||
class Library {
|
||||
@IsString() name!: string;
|
||||
|
||||
@ValidateNested() @JsonType(() => Address)
|
||||
address!: Address; // the class must match the field
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@JsonPolymorphic<Media>('type', [{ value: Book, name: 'book' }])
|
||||
items!: Media[];
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' }
|
||||
])
|
||||
items: Media[];
|
||||
}
|
||||
```
|
||||
|
||||
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s rules as
|
||||
well as its own, and re-stating a rule on an override does not report it twice.
|
||||
Constraints accumulate down an inheritance chain: `Book` is checked against `Media`'s
|
||||
`@IsString() title` as well as its own rules.
|
||||
|
||||
### 2. Map JSON, synchronously or not
|
||||
### 2. Map JSON with Validation
|
||||
|
||||
```typescript
|
||||
import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||
import { fromJson, toJson, JsonValidationError, flattenErrors } from 'cereale';
|
||||
|
||||
async function main() {
|
||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
||||
|
||||
try {
|
||||
const library = fromJsonSync(Library, json);
|
||||
library.address.format(); // your method, on a real Address
|
||||
library.items[0] instanceof Book; // true
|
||||
console.log(toJsonSync(library));
|
||||
// Deserialize JSON to Class Instance
|
||||
const library = await fromJson(Library, json);
|
||||
console.log(library.name); // "Central Library"
|
||||
console.log(library.items[0] instanceof Book); // true
|
||||
|
||||
// Serialize Class Instance back to JSON
|
||||
console.log(await toJson(library));
|
||||
} catch (error) {
|
||||
if (error instanceof JsonValidationError) {
|
||||
console.error(flattenErrors(error.errors));
|
||||
// { "items[0].title": ["title must be a string"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every function has an async form too (`fromJson`, `toJson`, …) for when a serializer,
|
||||
deserializer or validator of yours returns a Promise.
|
||||
|
||||
### 3. Modern Web Frameworks (Request Integration)
|
||||
|
||||
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
||||
@@ -475,48 +409,15 @@ 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;
|
||||
cereale guarantees they agree. If you want the type derived from a schema, that is Zod's
|
||||
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.
|
||||
- **`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:
|
||||
|
||||
```
|
||||
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.
|
||||
- **Circular references** are rejected during serialization with a `JsonMappingError`. Break
|
||||
the cycle 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
|
||||
`@JsonProperty`, its original name no longer reaches it — and is refused rather than copied
|
||||
onto the instance behind the rename's back. Add `@JsonAlias` to keep older clients working.
|
||||
Under `unknownKeys: 'error'` the stale name is reported along with what the property is
|
||||
called now:
|
||||
|
||||
```
|
||||
JsonMappingError: "ref" is not a JSON name for Order: property "ref" is mapped to
|
||||
"order_ref". Send that name, or add @JsonAlias("ref") to keep accepting this one.
|
||||
```
|
||||
`@JsonProperty`, its original name is no longer accepted on input — add `@JsonAlias` to
|
||||
keep older clients working.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
|
||||
// Generated by scripts/build-docs.mjs from real tsc output — do not edit.
|
||||
window.CEREALE_DIAGNOSTICS = {
|
||||
"hero": [
|
||||
{
|
||||
"code": 1240,
|
||||
"messages": [
|
||||
"Unable to resolve signature of property decorator when called as an expression.",
|
||||
"Argument of type 'ClassFieldDecoratorContext<Order, Date> & { name: \"placedAt\"; private: false; static: false; }' is not assignable to parameter of type 'ClassFieldDecoratorContext<Order, string | null | undefined>'.",
|
||||
"The types returned by 'access.get(...)' are incompatible between these types.",
|
||||
"Type 'Date' is not assignable to type 'string'."
|
||||
],
|
||||
"line": 12
|
||||
}
|
||||
],
|
||||
"compare": [
|
||||
{
|
||||
"code": 1240,
|
||||
"messages": [
|
||||
"Unable to resolve signature of property decorator when called as an expression.",
|
||||
"Argument of type 'ClassFieldDecoratorContext<User, number> & { name: \"age\"; private: false; static: false; }' is not assignable to parameter of type 'ClassFieldDecoratorContext<User, string | null | undefined>'.",
|
||||
"The types returned by 'access.get(...)' are incompatible between these types.",
|
||||
"Type 'number' is not assignable to type 'string'."
|
||||
],
|
||||
"line": 4
|
||||
}
|
||||
],
|
||||
"instances": [],
|
||||
"correct": []
|
||||
};
|
||||
+242
-821
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
// Generated by scripts/build-docs.mjs — do not edit.
|
||||
window.CEREALE_META = {
|
||||
"version": "0.3.0",
|
||||
"node": ">=20.0.0"
|
||||
};
|
||||
-556
@@ -1,556 +0,0 @@
|
||||
/* cereale landing page. No dependencies, no network. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var meta = window.CEREALE_META || {};
|
||||
|
||||
/* ------------------------------------------------------------- theme */
|
||||
var root = document.documentElement;
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('cereale-theme'); } catch (e) { /* private mode */ }
|
||||
if (stored === 'light' || stored === 'dark') root.setAttribute('data-theme', stored);
|
||||
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
var current = root.getAttribute('data-theme');
|
||||
if (!current) {
|
||||
current = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
root.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('cereale-theme', next); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- facts on tap */
|
||||
// Read from the bundle rather than written into the page, so they cannot drift.
|
||||
var exportNames = Object.keys(window.Cereale || {}).filter(function (name) {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(name);
|
||||
});
|
||||
var decoratorCount = exportNames.filter(function (name) {
|
||||
return /^[A-Z]/.test(name) && typeof window.Cereale[name] === 'function' &&
|
||||
!/^Json(Mapping|Validation)Error$|^JsonMapper$/.test(name);
|
||||
}).length;
|
||||
|
||||
var countEl = document.getElementById('decorator-count');
|
||||
if (countEl && decoratorCount) countEl.textContent = String(decoratorCount);
|
||||
var versionEl = document.getElementById('version-badge');
|
||||
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
|
||||
var nodeEl = document.getElementById('node-req');
|
||||
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
|
||||
|
||||
/* ------------------------------------------------------- highlighting */
|
||||
var TOKENS = [
|
||||
['comment', /\/\/[^\n]*|\/\*[\s\S]*?\*\//],
|
||||
['string', /'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`|\/(?:[^\/\\\n\[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/],
|
||||
['decorator', /@[A-Za-z_$][\w$]*/],
|
||||
['keyword', /\b(?:class|extends|implements|interface|const|let|var|function|return|new|await|async|import|export|from|type|enum|if|else|for|of|in|try|catch|throw|instanceof|typeof|null|undefined|true|false|this|readonly|private|public|static|default)\b/],
|
||||
['type', /\b(?:string|number|boolean|bigint|symbol|Date|any|unknown|void|never|Promise|Array|Map|Set|Error|TypeError|Record|Partial)\b/],
|
||||
['number', /\b\d[\d_]*(?:\.\d+)?n?\b/]
|
||||
];
|
||||
var TOKEN_RE = new RegExp(TOKENS.map(function (t) {
|
||||
return '(?<' + t[0] + '>' + t[1].source + ')';
|
||||
}).join('|'), 'g');
|
||||
|
||||
function esc(text) {
|
||||
return text.replace(/[&<>]/g, function (c) {
|
||||
return c === '&' ? '&' : c === '<' ? '<' : '>';
|
||||
});
|
||||
}
|
||||
|
||||
function highlight(code) {
|
||||
var out = '', last = 0, match;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
while ((match = TOKEN_RE.exec(code)) !== null) {
|
||||
out += esc(code.slice(last, match.index));
|
||||
var kind = '';
|
||||
for (var key in match.groups) {
|
||||
if (match.groups[key] !== undefined) { kind = key; break; }
|
||||
}
|
||||
out += '<span class="t-' + kind + '">' + esc(match[0]) + '</span>';
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
return out + esc(code.slice(last));
|
||||
}
|
||||
|
||||
// Wraps each line so a single one can be marked as the line the compiler refuses.
|
||||
function renderCode(block) {
|
||||
var source = block.textContent.replace(/\n$/, '');
|
||||
var errorLine = parseInt(block.getAttribute('data-error-line') || '0', 10);
|
||||
var plain = block.getAttribute('data-lang') === 'text';
|
||||
var lines = (plain ? esc(source) : highlight(source)).split('\n');
|
||||
block.innerHTML = lines.map(function (line, index) {
|
||||
var cls = index + 1 === errorLine ? 'ln ln--error' : 'ln';
|
||||
return '<span class="' + cls + '">' + (line || ' ') + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('pre code[data-lang]'), renderCode);
|
||||
|
||||
/* ------------------------------------------------- compiler diagnostics */
|
||||
// Written by scripts/build-docs.mjs from a real `tsc` run over the same snippet, so the
|
||||
// page cannot quote an error the compiler did not produce. Falls back to the markup.
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-case]'), function (el) {
|
||||
var found = (window.CEREALE_DIAGNOSTICS || {})[el.getAttribute('data-case')];
|
||||
if (!found || !found.length) return;
|
||||
var diagnostic = found[0];
|
||||
var headline = diagnostic.messages[0];
|
||||
var leaf = diagnostic.messages[diagnostic.messages.length - 1];
|
||||
var body = '<strong>ts(' + diagnostic.code + ')</strong> ' + esc(headline);
|
||||
if (leaf !== headline) body += '<br> … ' + esc(leaf);
|
||||
el.innerHTML = '<span class="mark" aria-hidden="true">✖</span><span>' + body + '</span>';
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------- reference */
|
||||
var REFERENCE = [
|
||||
['Mapping', 'How a field is named and shaped on the JSON side.', [
|
||||
["@JsonProperty(name)", 'maps this field to a different name in JSON, both directions'],
|
||||
["@JsonAlias(...names)", 'extra names accepted on input only'],
|
||||
["@JsonType(() => Class)", 'declares the class a nested field maps to'],
|
||||
["@JsonPolymorphic<Base>(key, subTypes, options?)", 'picks the concrete subclass from a discriminator'],
|
||||
["@JsonSerialize(Serializer)", 'custom serializer for this field'],
|
||||
["@JsonDeserialize(Deserializer)", 'custom deserializer for this field']
|
||||
]],
|
||||
['Access control', 'Which direction a field is allowed to travel.', [
|
||||
["@JsonIgnore()", 'excluded from mapping in both directions'],
|
||||
["@JsonReadOnly()", 'written to JSON, never populated from it — server-owned ids'],
|
||||
["@JsonWriteOnly()", 'populated from JSON, never written back — passwords']
|
||||
]],
|
||||
['Control flow', 'When the rules on a field apply at all.', [
|
||||
["@IsOptional()", 'skips the other rules when the value is null or undefined'],
|
||||
["@ValidateIf(fn)", 'skips every rule when the predicate returns false'],
|
||||
["@ValidateNested(options?)", 'recursively validates the value, or each element'],
|
||||
["@Allow()", 'declares a field that carries no rules of its own']
|
||||
]],
|
||||
['Type rules', 'What kind of value the field holds.', [
|
||||
["@IsString()", 'must be a string'],
|
||||
["@IsNumber()", 'must be a number'],
|
||||
["@IsInt()", 'must be an integer'],
|
||||
["@IsBoolean()", 'must be a boolean'],
|
||||
["@IsBigInt()", 'must be a bigint'],
|
||||
["@IsDate()", 'must be a valid Date object'],
|
||||
["@IsObject()", 'must be an object'],
|
||||
["@IsDefined()", 'must not be null or undefined'],
|
||||
["@IsNotEmpty()", 'must not be null, undefined or an empty string — [] and {} pass'],
|
||||
["@IsEmpty()", 'must be null, undefined, an empty string, [] or {}']
|
||||
]],
|
||||
['Numbers', 'Constraints on number fields.', [
|
||||
["@Min(n)", 'must be at least n'],
|
||||
["@Max(n)", 'must be at most n'],
|
||||
["@Positive()", 'must be positive'],
|
||||
["@Negative()", 'must be negative'],
|
||||
["@IsDivisibleBy(n)", 'must be divisible by n'],
|
||||
["@IsPort()", 'must be a valid port number — attaches to a number or a numeric string'],
|
||||
["@IsLatitude()", 'must be a latitude between −90 and 90'],
|
||||
["@IsLongitude()", 'must be a longitude between −180 and 180']
|
||||
]],
|
||||
['Strings', 'Constraints on string fields.', [
|
||||
["@MinLength(n)", 'must be longer than or equal to n characters'],
|
||||
["@MaxLength(n)", 'must be shorter than or equal to n characters'],
|
||||
["@Length(min, max?)", 'must be between min and max characters, or at least min if max is omitted'],
|
||||
["@Email()", 'must be a valid email'],
|
||||
["@IsUrl()", 'must be a valid URL'],
|
||||
["@IsUUID(version?)", 'must be a valid UUID'],
|
||||
["@IsIP(version?)", 'must be a valid IP address'],
|
||||
["@Matches(regex)", 'must match the regular expression'],
|
||||
["@IsAlpha()", 'must contain only letters'],
|
||||
["@IsAlphanumeric()", 'must contain only letters and numbers'],
|
||||
["@IsLowercase()", 'must be lowercase'],
|
||||
["@IsUppercase()", 'must be uppercase'],
|
||||
["@IsSemVer()", 'must be a valid semantic version'],
|
||||
["@IsHexColor()", 'must be a hex color'],
|
||||
["@IsNumberString()", 'must be a number string'],
|
||||
["@IsDateString()", 'must be a valid ISO 8601 date string'],
|
||||
["@IsJSON()", 'must be a JSON string'],
|
||||
["@Contains(text)", 'must contain the substring'],
|
||||
["@NotContains(text)", 'must not contain the substring'],
|
||||
["@StartsWith(text)", 'must start with the prefix'],
|
||||
["@EndsWith(text)", 'must end with the suffix']
|
||||
]],
|
||||
['Equality and membership', 'Pinning a field to specific values. These narrow the field’s type too — except @IsNotIn, which accepts any field, because narrowing a deny-list would be backwards.', [
|
||||
["@Equals(value)", 'must equal the value'],
|
||||
["@NotEquals(value)", 'must not equal the value'],
|
||||
["@IsIn(values)", 'must be one of the listed values'],
|
||||
["@IsNotIn(values)", 'must not be one of the listed values'],
|
||||
["@IsEnum(Enum)", 'must be a member of the enum'],
|
||||
["@IsInstance(Class)", 'must be an instance of the class']
|
||||
]],
|
||||
['Arrays', 'Constraints on array fields.', [
|
||||
["@IsArray()", 'must be an array'],
|
||||
["@ArrayNotEmpty()", 'must not be empty'],
|
||||
["@ArrayMinSize(n)", 'must contain at least n elements'],
|
||||
["@ArrayMaxSize(n)", 'must contain at most n elements'],
|
||||
["@ArrayUnique(by?)", 'must not contain duplicate values'],
|
||||
["@ArrayContains(values)", 'must contain all the listed values'],
|
||||
["@ArrayNotContains(values)", 'must not contain any of the listed values']
|
||||
]],
|
||||
['Dates', 'Both take a Date, or a thunk so a moving boundary is evaluated per validation rather than frozen when the class was declared.', [
|
||||
["@MinDate(date | (() => date))", 'must not be earlier than the date'],
|
||||
["@MaxDate(date | (() => date))", 'must not be later than the date']
|
||||
]],
|
||||
['Custom rules', 'When the built-ins run out.', [
|
||||
["@Validate(validator, constraints?, options?)", 'applies a custom validator class or predicate; annotate the predicate\'s parameter to constrain the field type'],
|
||||
["defineRule(Class, field, rule, options?)", 'registers a rule from outside a decorator']
|
||||
]],
|
||||
['Reading JSON', 'Each of these has a …Sync twin that needs no await, except fromRequest.', [
|
||||
["toInstance(Class, plain, options?)", 'plain object → validated instance'],
|
||||
["fromJson(Class, json, options?)", 'JSON string → validated instance'],
|
||||
["toInstanceArray(Class, plain, options?)", 'array of plain objects → instances'],
|
||||
["fromJsonArray(Class, json, options?)", 'JSON array string → instances'],
|
||||
["fromRequest(Class, request, options?)", 'reads and maps a Request body — async only']
|
||||
]],
|
||||
['Writing JSON', 'Also available as toPlainSync and toJsonSync.', [
|
||||
["toPlain(instance, options?)", 'instance → plain object'],
|
||||
["toJson(instance, options?)", 'instance → JSON string']
|
||||
]],
|
||||
['Validating', 'Also available as validateSync and validateOrRejectSync.', [
|
||||
["validate(instance, options?)", 'returns ValidationError[]'],
|
||||
["validateOrReject(instance, options?)", 'throws JsonValidationError on failure']
|
||||
]],
|
||||
['Errors', 'Turning a ValidationError tree into something you can show.', [
|
||||
["flattenErrors(errors)", "nested errors → { 'path.to.field': messages }"],
|
||||
["formatErrors(errors)", 'errors → readable multi-line text'],
|
||||
["collectErrorMessages(errors)", 'every message as a flat array of strings'],
|
||||
["JsonValidationError", 'thrown when validation fails'],
|
||||
["JsonMappingError", 'thrown when a value cannot be mapped at all']
|
||||
]],
|
||||
['Configuration', 'Per call, or once via configure().', [
|
||||
["validate: boolean", 'validate while mapping — default true'],
|
||||
["namingStrategy: strategy", 'identity (default), camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, or your own function'],
|
||||
["unknownKeys: policy", 'allow (default), strip, or error — deserialization only'],
|
||||
["maxDepth: number", 'nesting limit before a JsonMappingError — default 64'],
|
||||
["configure(options)", 'sets the library-wide defaults'],
|
||||
["getConfig()", 'reads the defaults currently in force'],
|
||||
["resetConfig()", 'restores the built-in defaults — the one a test suite needs']
|
||||
]],
|
||||
['Build', 'Only needed on toolchains that transform with oxc.', [
|
||||
["standardDecorators(options?)", "the Vite and Vitest plugin, from 'cereale/vite'"]
|
||||
]]
|
||||
];
|
||||
|
||||
var groupsEl = document.getElementById('ref-groups');
|
||||
var filterEl = document.getElementById('ref-filter');
|
||||
var refCountEl = document.getElementById('ref-count');
|
||||
|
||||
if (groupsEl) {
|
||||
groupsEl.innerHTML = REFERENCE.map(function (group) {
|
||||
var items = group[2].map(function (item) {
|
||||
return '<li data-search="' + esc((item[0] + ' ' + item[1]).toLowerCase()) + '">' +
|
||||
'<code>' + esc(item[0]) + '</code>' +
|
||||
'<span class="sum">' + esc(item[1]) + '</span></li>';
|
||||
}).join('');
|
||||
return '<div class="ref-group" data-group>' +
|
||||
'<h3>' + esc(group[0]) + '</h3>' +
|
||||
'<p class="blurb">' + esc(group[1]) + '</p>' +
|
||||
'<ul>' + items + '</ul></div>';
|
||||
}).join('');
|
||||
|
||||
var allItems = groupsEl.querySelectorAll('li[data-search]');
|
||||
var allGroups = groupsEl.querySelectorAll('[data-group]');
|
||||
var totalEntries = allItems.length;
|
||||
|
||||
var applyFilter = function () {
|
||||
var term = (filterEl ? filterEl.value : '').trim().toLowerCase();
|
||||
var shown = 0;
|
||||
Array.prototype.forEach.call(allGroups, function (group) {
|
||||
var visibleInGroup = 0;
|
||||
Array.prototype.forEach.call(group.querySelectorAll('li[data-search]'), function (li) {
|
||||
var hit = !term || li.getAttribute('data-search').indexOf(term) !== -1;
|
||||
li.hidden = !hit;
|
||||
if (hit) { visibleInGroup++; shown++; }
|
||||
});
|
||||
group.hidden = visibleInGroup === 0;
|
||||
});
|
||||
if (refCountEl) {
|
||||
refCountEl.textContent = term
|
||||
? shown + ' of ' + totalEntries + ' shown'
|
||||
: totalEntries + ' entries';
|
||||
}
|
||||
};
|
||||
|
||||
applyFilter();
|
||||
if (filterEl) filterEl.addEventListener('input', applyFilter);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- playground */
|
||||
var EXAMPLES = [
|
||||
{
|
||||
label: 'Mapping',
|
||||
code: [
|
||||
"// Rename a field, keep a secret out of the response,",
|
||||
"// and get a real instance back — methods and all.",
|
||||
"class User {",
|
||||
" @JsonProperty('display_name')",
|
||||
" @IsString() @MinLength(3)",
|
||||
" displayName!: string;",
|
||||
"",
|
||||
" @IsInt() @Min(18)",
|
||||
" age!: number;",
|
||||
"",
|
||||
" // accepted on input, never written back out",
|
||||
" @JsonWriteOnly() @IsString()",
|
||||
" password!: string;",
|
||||
"",
|
||||
" greet() { return 'Hi ' + this.displayName; }",
|
||||
"}",
|
||||
"",
|
||||
"const body = '{\"display_name\":\"Ada\",\"age\":36,' +",
|
||||
" '\"password\":\"hunter2\"}';",
|
||||
"const user = fromJsonSync(User, body);",
|
||||
"",
|
||||
"console.log('a real User:', user instanceof User);",
|
||||
"console.log('its methods survived:', user.greet());",
|
||||
"console.log('back out again:', toJsonSync(user));"
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
label: 'Validation errors',
|
||||
code: [
|
||||
"class Signup {",
|
||||
" @IsString() @MinLength(3) name!: string;",
|
||||
" @IsInt() @Min(18) age!: number;",
|
||||
" @Email() email!: string;",
|
||||
"}",
|
||||
"",
|
||||
"// Map without validating so we can inspect the damage ourselves.",
|
||||
"const payload = { name: 'Bo', age: 15, email: 'nope' };",
|
||||
"const bad = toInstanceSync(Signup, payload, { validate: false });",
|
||||
"",
|
||||
"console.log(formatErrors(validateSync(bad)));",
|
||||
"console.log('');",
|
||||
"console.log('as a map for a form:', flattenErrors(validateSync(bad)));",
|
||||
"",
|
||||
"// Or let it throw, which is the default.",
|
||||
"try {",
|
||||
" fromJsonSync(Signup, JSON.stringify(payload));",
|
||||
"} catch (error) {",
|
||||
" console.log('');",
|
||||
" console.log('threw:', error.name);",
|
||||
"}"
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
label: 'Nested',
|
||||
code: [
|
||||
"class Line {",
|
||||
" @IsString() sku!: string;",
|
||||
" @IsInt() @Min(1) qty!: number;",
|
||||
"}",
|
||||
"",
|
||||
"class Order {",
|
||||
" @IsString() ref!: string;",
|
||||
"",
|
||||
" @ValidateNested({ each: true })",
|
||||
" @JsonType(() => Line)",
|
||||
" lines!: Line[];",
|
||||
"}",
|
||||
"",
|
||||
"const order = toInstanceSync(Order,",
|
||||
" { ref: 'A-1', lines: [",
|
||||
" { sku: 'grain', qty: 2 },",
|
||||
" { sku: 'oat', qty: 0 }, // ← the one that fails",
|
||||
" ] },",
|
||||
" { validate: false });",
|
||||
"",
|
||||
"console.log('nested items are real:', order.lines[0] instanceof Line);",
|
||||
"console.log('errors keep their path:', flattenErrors(validateSync(order)));"
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
label: 'Polymorphism',
|
||||
code: [
|
||||
"class Media { @IsString() title!: string; }",
|
||||
"",
|
||||
"class Movie extends Media {",
|
||||
" @IsInt() @Min(1) duration!: number;",
|
||||
" hours() { return (this.duration / 60).toFixed(2); }",
|
||||
"}",
|
||||
"class Song extends Media { @IsString() artist!: string; }",
|
||||
"",
|
||||
"class Playlist {",
|
||||
" @JsonPolymorphic('type', [",
|
||||
" { value: Movie, name: 'movie' },",
|
||||
" { value: Song, name: 'song' },",
|
||||
" ])",
|
||||
" @ValidateNested({ each: true })",
|
||||
" items!: Media[];",
|
||||
"}",
|
||||
"",
|
||||
"const list = toInstanceSync(Playlist, { items: [",
|
||||
" { type: 'movie', title: 'Inception', duration: 148 },",
|
||||
" { type: 'song', title: 'Reckoner', artist: 'Radiohead' },",
|
||||
"] });",
|
||||
"",
|
||||
"console.log('first is a Movie:', list.items[0] instanceof Movie);",
|
||||
"console.log('and it has behaviour:', list.items[0].hours() + ' hours');",
|
||||
"console.log('second is a Song:', list.items[1].constructor.name);"
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
label: 'Naming',
|
||||
code: [
|
||||
"// One setting instead of a @JsonProperty on every field.",
|
||||
"class Account {",
|
||||
" @IsString() firstName!: string;",
|
||||
" @IsString() lastName!: string;",
|
||||
" @IsString() emailAddress!: string;",
|
||||
"}",
|
||||
"",
|
||||
"const options = { namingStrategy: 'snake_case' };",
|
||||
"",
|
||||
"const account = toInstanceSync(Account,",
|
||||
" { first_name: 'Ada', last_name: 'Lovelace',",
|
||||
" email_address: 'ada@example.com' },",
|
||||
" options);",
|
||||
"",
|
||||
"console.log('read:', account.firstName, account.lastName);",
|
||||
"console.log('written:', toPlainSync(account, options));"
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
label: 'Nothing fails quietly',
|
||||
code: [
|
||||
"// Each of these used to succeed and lose your data, or fail somewhere",
|
||||
"// unrelated. Run it and read what comes back instead.",
|
||||
"",
|
||||
"class Basket { items: any; }",
|
||||
"",
|
||||
"const basket = new Basket();",
|
||||
"basket.items = new Map([['grain', 2]]);",
|
||||
"",
|
||||
"try { toPlainSync(basket, { validate: false }); }",
|
||||
"catch (error) { console.log(error.name + ': ' + error.message); }",
|
||||
"",
|
||||
"console.log('');",
|
||||
"",
|
||||
"class Node { name = 'root'; child: any = null; parent: any = null; }",
|
||||
"const root = new Node(), child = new Node();",
|
||||
"child.name = 'child'; child.parent = root; root.child = child;",
|
||||
"",
|
||||
"try { toPlainSync(root, { validate: false }); }",
|
||||
"catch (error) { console.log(error.name + ': ' + error.message); }",
|
||||
"",
|
||||
"console.log('');",
|
||||
"",
|
||||
"class Strict { @IsString() a!: string; }",
|
||||
"try { toInstanceSync(Strict, { a: 'x', b: 'y' }, { unknownKeys: 'error' }); }",
|
||||
"catch (error) { console.log(error.name + ': ' + error.message); }"
|
||||
].join('\n')
|
||||
}
|
||||
];
|
||||
|
||||
var editor = document.getElementById('editor');
|
||||
var output = document.getElementById('output');
|
||||
var runBtn = document.getElementById('run-btn');
|
||||
var tabsEl = document.getElementById('tabs');
|
||||
var statusEl = document.getElementById('pg-status');
|
||||
|
||||
if (editor && output && runBtn && tabsEl) {
|
||||
tabsEl.innerHTML = EXAMPLES.map(function (example, index) {
|
||||
return '<button class="tab" type="button" data-example="' + index + '"' +
|
||||
' aria-pressed="' + (index === 0 ? 'true' : 'false') + '">' + esc(example.label) + '</button>';
|
||||
}).join('');
|
||||
|
||||
var selectExample = function (index) {
|
||||
Array.prototype.forEach.call(tabsEl.querySelectorAll('[data-example]'), function (tab) {
|
||||
tab.setAttribute('aria-pressed', tab.getAttribute('data-example') === String(index) ? 'true' : 'false');
|
||||
});
|
||||
editor.value = EXAMPLES[index].code;
|
||||
// The compiler is only fetched on the first run, so the pane starts empty; say why
|
||||
// rather than showing a blank box.
|
||||
output.innerHTML = '<span class="out-dim">Press Run (or ' +
|
||||
(/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl') +
|
||||
'+Enter) to compile this and execute it\nagainst the bundled library.</span>';
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
};
|
||||
|
||||
tabsEl.addEventListener('click', function (event) {
|
||||
var tab = event.target.closest('[data-example]');
|
||||
if (tab) selectExample(parseInt(tab.getAttribute('data-example'), 10));
|
||||
});
|
||||
|
||||
// Tab indents rather than escaping the editor; Escape then Tab still moves focus out.
|
||||
var tabEscapes = false;
|
||||
editor.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') { tabEscapes = true; return; }
|
||||
if (event.key !== 'Tab' || tabEscapes) { tabEscapes = false; return; }
|
||||
event.preventDefault();
|
||||
var start = editor.selectionStart, end = editor.selectionEnd;
|
||||
editor.value = editor.value.slice(0, start) + ' ' + editor.value.slice(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + 2;
|
||||
});
|
||||
|
||||
var write = function (text, cls) {
|
||||
var line = document.createElement('span');
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = text + '\n';
|
||||
output.appendChild(line);
|
||||
};
|
||||
|
||||
var show = function (value) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value instanceof Error) return value.name + ': ' + value.message;
|
||||
try { return JSON.stringify(value, null, 2); } catch (e) { return String(value); }
|
||||
};
|
||||
|
||||
// The compiler is ~540KB gzipped, so it is fetched on the first run rather than
|
||||
// charged to everyone who scrolls past.
|
||||
var compiler = null;
|
||||
var loadCompiler = function () {
|
||||
return compiler || (compiler = new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'vendor/babel.min.js';
|
||||
script.onload = function () { resolve(window.Babel); };
|
||||
script.onerror = function () { reject(new Error('Could not load the compiler (vendor/babel.min.js).')); };
|
||||
document.head.appendChild(script);
|
||||
}));
|
||||
};
|
||||
|
||||
var running = false;
|
||||
var run = function () {
|
||||
if (running) return;
|
||||
running = true;
|
||||
runBtn.disabled = true;
|
||||
output.textContent = '';
|
||||
if (statusEl) statusEl.textContent = window.Babel ? 'running…' : 'loading compiler…';
|
||||
|
||||
loadCompiler().then(function (Babel) {
|
||||
if (statusEl) statusEl.textContent = 'running…';
|
||||
// TypeScript is stripped first, then decorators are lowered: the other order
|
||||
// leaves the decorator transform's initialisers on a `field!: T` declaration,
|
||||
// which the TypeScript plugin then rejects.
|
||||
var compiled = Babel.transform(editor.value, {
|
||||
filename: 'playground.ts',
|
||||
plugins: [['transform-typescript', {}], ['proposal-decorators', { version: '2023-11' }]]
|
||||
}).code;
|
||||
|
||||
var sandboxConsole = {
|
||||
log: function () {
|
||||
write(Array.prototype.map.call(arguments, show).join(' '));
|
||||
}
|
||||
};
|
||||
var body = 'return (async () => {\n' + compiled + '\n})();';
|
||||
var fn = Function.apply(null, ['console'].concat(exportNames, [body]));
|
||||
return fn.apply(null, [sandboxConsole].concat(exportNames.map(function (name) {
|
||||
return window.Cereale[name];
|
||||
})));
|
||||
}).then(function () {
|
||||
if (!output.textContent) write('(the code ran, but logged nothing)', 'out-dim');
|
||||
}).catch(function (error) {
|
||||
write((error && error.name === 'SyntaxError' ? '' : '') + show(error), 'out-err');
|
||||
}).then(function () {
|
||||
running = false;
|
||||
runBtn.disabled = false;
|
||||
if (statusEl) statusEl.textContent = '';
|
||||
});
|
||||
};
|
||||
|
||||
runBtn.addEventListener('click', run);
|
||||
editor.addEventListener('keydown', function (event) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); run(); }
|
||||
});
|
||||
|
||||
selectExample(0);
|
||||
}
|
||||
})();
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
# Vendored assets
|
||||
|
||||
Generated by `npm run build:docs`. Do not edit by hand.
|
||||
|
||||
- `babel.min.js` — @babel/standalone 8.0.4, used by the playground to compile TypeScript with standard decorators in the browser. Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, which silently began serving Babel 8 and broke the playground.
|
||||
|
||||
- `../.nojekyll` — opts the directory out of Jekyll, so GitHub Pages serves it verbatim. Jekyll ignores paths beginning with an underscore and carries default `vendor/` exclusions, and the failure mode is an asset that silently does not publish — for this page, the playground's compiler 404ing while everything else looks fine.
|
||||
Vendored
-4
File diff suppressed because one or more lines are too long
Generated
+2
-276
@@ -1,17 +1,15 @@
|
||||
{
|
||||
"name": "cereale",
|
||||
"version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cereale",
|
||||
"version": "0.3.0",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/standalone": "^8.0.4",
|
||||
"@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",
|
||||
@@ -62,16 +60,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/standalone": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-8.0.4.tgz",
|
||||
"integrity": "sha512-z8WgyJCfEl7qGAfJJksICOL5zQPpfwa6Yew+91Txf1k3xuDtlDdDe5GX2QdIhgx0HD7oYOCLoF8Y6CwNdawJwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
@@ -1046,268 +1034,6 @@
|
||||
"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",
|
||||
|
||||
+13
-36
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cereale",
|
||||
"version": "0.3.0",
|
||||
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
|
||||
"version": "0.1.0",
|
||||
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
@@ -11,27 +11,15 @@
|
||||
"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": [
|
||||
"./dist/esm/metadata.js",
|
||||
"./dist/cjs/metadata.js"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"CHANGELOG.md",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/example.ts"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
||||
"build:docs": "node scripts/build-docs.mjs",
|
||||
"build:docs": "esbuild src/index.ts --bundle --format=iife --global-name=Cereale --minify --tsconfig=tsconfig.json --outfile=docs/cereale.js",
|
||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -39,40 +27,32 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
|
||||
"prepublishOnly": "npm run verify",
|
||||
"check:docs": "node scripts/check-docs.mjs",
|
||||
"check:types": "node scripts/check-types.mjs"
|
||||
"verify": "npm run type-check && npm run lint && npm run test && npm run build",
|
||||
"prepublishOnly": "npm run verify"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/avalon-vanguard/cereale.git"
|
||||
"url": "git+https://github.com/Avalon-Vanguard/cereale.git"
|
||||
},
|
||||
"keywords": [
|
||||
"json",
|
||||
"mapping",
|
||||
"validation",
|
||||
"decorators",
|
||||
"standard-decorators",
|
||||
"typescript",
|
||||
"dto",
|
||||
"serialization",
|
||||
"class-validator",
|
||||
"class-transformer",
|
||||
"class-validator-alternative"
|
||||
"spring",
|
||||
"typescript"
|
||||
],
|
||||
"author": "Avalon Vanguard",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/avalon-vanguard/cereale/issues"
|
||||
"url": "https://github.com/Avalon-Vanguard/cereale/issues"
|
||||
},
|
||||
"homepage": "https://avalon-vanguard.github.io/cereale/",
|
||||
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
|
||||
"devDependencies": {
|
||||
"@babel/standalone": "^8.0.4",
|
||||
"@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",
|
||||
@@ -82,8 +62,5 @@
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Builds the assets the landing page needs, into docs/.
|
||||
*
|
||||
* The page is served by GitHub Pages straight from the repository, so everything it loads has
|
||||
* to be committed — there is no build step on the hosting side. Everything it loads is also
|
||||
* local: the previous page pulled Tailwind, CodeMirror and Babel from three CDNs, and its
|
||||
* playground died silently when the unpinned `@babel/standalone` URL rolled over to Babel 8
|
||||
* and the plugin list it passed stopped existing. Vendoring the compiler pins it to the
|
||||
* version in package.json and to a lockfile.
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { copyFile, mkdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { collectDiagnostics } from './diagnostics.mjs';
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const docs = path.join(root, 'docs');
|
||||
const vendor = path.join(docs, 'vendor');
|
||||
|
||||
const size = async (file) => {
|
||||
const { size: bytes } = await stat(file);
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
};
|
||||
|
||||
await mkdir(vendor, { recursive: true });
|
||||
|
||||
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
// 1. The library itself, as a browser global the playground can pull names out of.
|
||||
await build({
|
||||
entryPoints: [path.join(root, 'src/index.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'Cereale',
|
||||
minify: true,
|
||||
target: 'es2022',
|
||||
tsconfigRaw: {
|
||||
compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true, target: 'es2022' },
|
||||
},
|
||||
outfile: path.join(docs, 'cereale.js'),
|
||||
});
|
||||
|
||||
// 2. Facts the page would otherwise hard-code and then get wrong. Everything else it needs —
|
||||
// the decorator count, the export list — it derives from the bundle at runtime.
|
||||
await writeFile(
|
||||
path.join(docs, 'meta.js'),
|
||||
`// Generated by scripts/build-docs.mjs — do not edit.\n` +
|
||||
`window.CEREALE_META = ${JSON.stringify({ version: pkg.version, node: pkg.engines.node }, null, 2)};\n`
|
||||
);
|
||||
|
||||
// 3. The compiler errors the page quotes, produced by actually running the compiler. If a
|
||||
// snippet the page calls a compile error ever compiles, this fails the build.
|
||||
const { byCase, problems } = await collectDiagnostics(root);
|
||||
if (problems.length > 0) {
|
||||
console.error('The landing page makes a claim the compiler does not support:\n' +
|
||||
problems.map((p) => ` - ${p}`).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(docs, 'diagnostics.js'),
|
||||
`// Generated by scripts/build-docs.mjs from real tsc output — do not edit.\n` +
|
||||
`window.CEREALE_DIAGNOSTICS = ${JSON.stringify(byCase, null, 2)};\n`
|
||||
);
|
||||
|
||||
// 4. The playground's TypeScript compiler, pinned by package.json rather than by a CDN URL.
|
||||
const babel = path.join(root, 'node_modules/@babel/standalone/babel.min.js');
|
||||
await copyFile(babel, path.join(vendor, 'babel.min.js'));
|
||||
await writeFile(
|
||||
path.join(vendor, 'README.md'),
|
||||
`# Vendored assets\n\n` +
|
||||
`Generated by \`npm run build:docs\`. Do not edit by hand.\n\n` +
|
||||
`- \`babel.min.js\` — @babel/standalone ${JSON.parse(await readFile(path.join(root, 'node_modules/@babel/standalone/package.json'), 'utf8')).version}, ` +
|
||||
`used by the playground to compile TypeScript with standard decorators in the browser. ` +
|
||||
`Vendored rather than loaded from a CDN: the previous page referenced an unpinned unpkg URL, ` +
|
||||
`which silently began serving Babel 8 and broke the playground.\n\n` +
|
||||
`- \`../.nojekyll\` — opts the directory out of Jekyll, so GitHub Pages serves it verbatim. ` +
|
||||
`Jekyll ignores paths beginning with an underscore and carries default \`vendor/\` exclusions, ` +
|
||||
`and the failure mode is an asset that silently does not publish — for this page, the ` +
|
||||
`playground's compiler 404ing while everything else looks fine.\n`
|
||||
);
|
||||
|
||||
// 5. Written rather than committed by hand so it cannot be lost in a docs/ rewrite.
|
||||
await writeFile(path.join(docs, '.nojekyll'), '');
|
||||
|
||||
console.log(`docs/cereale.js ${await size(path.join(docs, 'cereale.js'))}`);
|
||||
console.log(`docs/meta.js ${await size(path.join(docs, 'meta.js'))} (v${pkg.version})`);
|
||||
console.log(`docs/vendor/babel.min.js ${await size(path.join(vendor, 'babel.min.js'))}`);
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Guards the two properties the landing page silently lost before.
|
||||
*
|
||||
* 1. It must load nothing from the network. The previous page pulled Tailwind, CodeMirror and
|
||||
* Babel from three CDNs, and its playground died without a sound the day the unpinned
|
||||
* `@babel/standalone` URL started serving Babel 8, whose plugin list no longer had the
|
||||
* plugin the page asked for. Nobody noticed, because nothing on the page said so.
|
||||
* 2. The bundle the playground runs must match the library source. It is built from `src/`,
|
||||
* so a change there leaves the page demonstrating a version that no longer exists.
|
||||
*
|
||||
* Ordinary <a href> links out are fine — a link is not a subresource.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const docs = path.join(root, 'docs');
|
||||
|
||||
const failures = [];
|
||||
|
||||
/** Subresource references — the things a browser fetches without being clicked. */
|
||||
const SUBRESOURCES = [
|
||||
[/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'script src'],
|
||||
[/<(?:img|iframe|video|audio|source|embed)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi, 'media src'],
|
||||
[/@import\s+(?:url\()?["']([^"']+)["']/gi, 'css @import'],
|
||||
[/url\(\s*["']?(https?:\/\/[^)"']+)/gi, 'css url()'],
|
||||
];
|
||||
|
||||
/**
|
||||
* `<link>` relations the browser actually fetches or connects to.
|
||||
*
|
||||
* Checked against `rel` rather than flagging every `<link href>`, because the metadata
|
||||
* relations — `canonical` above all — are declarations about the document, not requests. A
|
||||
* check that cannot tell the difference gets switched off the first time it is wrong.
|
||||
*/
|
||||
const FETCHING_REL = new Set([
|
||||
'stylesheet', 'icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed',
|
||||
'manifest', 'preload', 'modulepreload', 'prefetch', 'prerender', 'preconnect', 'dns-prefetch',
|
||||
]);
|
||||
|
||||
const isRemote = (url) => /^(?:https?:)?\/\//i.test(url);
|
||||
|
||||
const html = (await readdir(docs)).filter((name) => name.endsWith('.html'));
|
||||
if (html.length === 0) failures.push('docs/ contains no HTML page');
|
||||
|
||||
for (const name of html) {
|
||||
const source = await readFile(path.join(docs, name), 'utf8');
|
||||
for (const [pattern, kind] of SUBRESOURCES) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
if (isRemote(match[1])) failures.push(`docs/${name}: remote ${kind} — ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of source.matchAll(/<link\b([^>]*)>/gi)) {
|
||||
const attrs = match[1];
|
||||
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1] ?? '').trim().toLowerCase();
|
||||
const href = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1];
|
||||
if (href && isRemote(href) && FETCHING_REL.has(rel)) {
|
||||
failures.push(`docs/${name}: remote link rel="${rel}" — ${href}`);
|
||||
}
|
||||
}
|
||||
// A fetch to a CDN would not be caught by the markup scan.
|
||||
for (const match of source.matchAll(/\b(?:fetch|importScripts)\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
|
||||
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of (await readdir(docs)).filter((f) => f.endsWith('.js'))) {
|
||||
const source = await readFile(path.join(docs, name), 'utf8');
|
||||
for (const match of source.matchAll(/\.src\s*=\s*["'`](https?:\/\/[^"'`]+)/gi)) {
|
||||
failures.push(`docs/${name}: loads a remote script — ${match[1]}`);
|
||||
}
|
||||
for (const match of source.matchAll(/\bfetch\(\s*["'`](https?:\/\/[^"'`]+)/gi)) {
|
||||
failures.push(`docs/${name}: remote fetch — ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The playground compiles against whatever is in the bundle, so a stale bundle means the
|
||||
// page demonstrates a library that no longer exists.
|
||||
const bundle = await readFile(path.join(docs, 'cereale.js'), 'utf8').catch(() => null);
|
||||
if (bundle === null) {
|
||||
failures.push('docs/cereale.js is missing — run `npm run build:docs`');
|
||||
} else {
|
||||
// Spot-check that the exports the page relies on actually made it into the bundle.
|
||||
for (const name of ['toInstanceSync', 'toPlainSync', 'flattenErrors', 'JsonMappingError', 'IsString']) {
|
||||
if (!bundle.includes(name)) failures.push(`docs/cereale.js does not export ${name} — rebuild it`);
|
||||
}
|
||||
}
|
||||
|
||||
const babel = path.join(docs, 'vendor/babel.min.js');
|
||||
await readFile(babel).catch(() => failures.push('docs/vendor/babel.min.js is missing — run `npm run build:docs`'));
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('docs check failed:\n' + failures.map((f) => ` - ${f}`).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`docs check passed — ${html.length} page(s), no network dependencies.`);
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Compiles a minimal consumer against the built type declarations, in the least forgiving
|
||||
* configuration a real project might have: no `skipLibCheck`, no `DOM` lib, no `types`.
|
||||
*
|
||||
* A zero-dependency library's public types have to stand on their own. `fromRequest` used to
|
||||
* be declared as taking the global `Request`, so cereale's own `.d.ts` raised
|
||||
* `Cannot find name 'Request'` in any project whose `lib` and `types` did not happen to
|
||||
* supply it — an error inside a dependency, in code the consumer may never call, that they
|
||||
* cannot fix from the outside. The library's own test suite hid it by enabling both.
|
||||
*
|
||||
* Run after `npm run build`, since it checks what is actually published.
|
||||
*/
|
||||
import ts from 'typescript';
|
||||
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const types = path.join(root, 'dist/esm/index.d.ts');
|
||||
|
||||
try {
|
||||
await access(types);
|
||||
} catch {
|
||||
console.error('dist/esm/index.d.ts is missing — run `npm run build` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CONSUMER = `
|
||||
import {
|
||||
IsString, MinLength, IsInt, Min, IsDate, JsonProperty, JsonWriteOnly,
|
||||
ValidateNested, JsonType, fromJsonSync, toPlainSync, validateSync, fromRequest,
|
||||
} from ${JSON.stringify(types.replace(/\.d\.ts$/, '.js'))};
|
||||
|
||||
class Address {
|
||||
@IsString() city!: string;
|
||||
}
|
||||
|
||||
export class User {
|
||||
@JsonProperty('display_name')
|
||||
@IsString() @MinLength(2)
|
||||
displayName!: string;
|
||||
|
||||
@IsInt() @Min(0)
|
||||
age!: number;
|
||||
|
||||
@IsDate()
|
||||
joinedAt!: Date;
|
||||
|
||||
@JsonWriteOnly() @IsString()
|
||||
password!: string;
|
||||
|
||||
@ValidateNested() @JsonType(() => Address)
|
||||
address!: Address;
|
||||
|
||||
greet(): string { return 'Hi ' + this.displayName; }
|
||||
}
|
||||
|
||||
export function use(body: string) {
|
||||
const user = fromJsonSync(User, body);
|
||||
return [user.greet(), toPlainSync(user), validateSync(user)];
|
||||
}
|
||||
|
||||
// Declared structurally, so this must type-check without the DOM or Node globals.
|
||||
export function fromAnythingWithJson(source: { json(): Promise<unknown> }) {
|
||||
return fromRequest(User, source);
|
||||
}
|
||||
`;
|
||||
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-consumer-'));
|
||||
try {
|
||||
const file = path.join(dir, 'consumer.ts');
|
||||
await writeFile(file, CONSUMER);
|
||||
|
||||
const program = ts.createProgram([file], {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
// Deliberately bare: no DOM, no node, and lib checking left on.
|
||||
lib: ['lib.esnext.d.ts', 'lib.esnext.decorators.d.ts'],
|
||||
types: [],
|
||||
strict: true,
|
||||
strictPropertyInitialization: false,
|
||||
skipLibCheck: false,
|
||||
noEmit: true,
|
||||
});
|
||||
|
||||
const diagnostics = [
|
||||
...program.getSemanticDiagnostics(),
|
||||
...program.getSyntacticDiagnostics(),
|
||||
...program.getGlobalDiagnostics(),
|
||||
];
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
console.error(
|
||||
'cereale\'s published types do not stand alone. A consumer without DOM lib or @types/node sees:\n' +
|
||||
diagnostics.slice(0, 12).map((d) => {
|
||||
const where = d.file ? `${path.basename(d.file.fileName)}:${d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1} ` : '';
|
||||
return ` - ${where}TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
|
||||
}).join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('type check passed — published types resolve with no DOM lib and no @types/node.');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Runs the real TypeScript compiler over the snippets the landing page quotes, and writes
|
||||
* the verbatim diagnostics into docs/diagnostics.js.
|
||||
*
|
||||
* The page's central claim is that a rule which does not fit its field does not compile. The
|
||||
* honest way to show that is not to type a plausible-looking error into the HTML — it is to
|
||||
* compile the snippet and print whatever the compiler said. If a snippet marked `rejected`
|
||||
* ever starts compiling, or a snippet marked `compiles` stops, the build fails here rather
|
||||
* than the page quietly going on claiming something that is no longer true.
|
||||
*/
|
||||
import ts from 'typescript';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/** Each case is compiled against the real library, not a stub. */
|
||||
export const CASES = [
|
||||
{
|
||||
id: 'hero',
|
||||
expect: 'rejected',
|
||||
// Kept character-for-character in step with the hero block in docs/index.html.
|
||||
source: `import { JsonProperty, IsString, Matches, IsInt, Min, fromJsonSync } from '../src/index.js';
|
||||
declare const body: string;
|
||||
|
||||
class Order {
|
||||
@JsonProperty('order_ref')
|
||||
@IsString() @Matches(/^[A-Z]-\\d+$/)
|
||||
ref!: string;
|
||||
|
||||
@IsInt() @Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@IsString()
|
||||
placedAt!: Date;
|
||||
|
||||
total(): number { return this.quantity * 9.99; }
|
||||
}
|
||||
|
||||
const order = fromJsonSync(Order, body);
|
||||
order.total();
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'compare',
|
||||
expect: 'rejected',
|
||||
source: `import { IsString } from '../src/index.js';
|
||||
|
||||
export class User {
|
||||
@IsString()
|
||||
age!: number;
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'instances',
|
||||
expect: 'compiles',
|
||||
// The "What you get back" section. It is here because an earlier draft showed
|
||||
// `list.items[0].hours()` on a `Media[]`, which tsc rejects with TS2339 — TypeScript that
|
||||
// the compiler refuses, on a page whose whole argument is that the compiler is the authority.
|
||||
source: `import { IsString, IsInt, Min, JsonPolymorphic, ValidateNested, fromJsonSync } from '../src/index.js';
|
||||
declare const body: string;
|
||||
|
||||
class Media {
|
||||
@IsString() title!: string;
|
||||
}
|
||||
class Movie extends Media {
|
||||
@IsInt() @Min(1) duration!: number;
|
||||
hours() { return this.duration / 60; }
|
||||
}
|
||||
class Song extends Media {
|
||||
@IsString() artist!: string;
|
||||
}
|
||||
|
||||
class Playlist {
|
||||
@JsonPolymorphic<Media>('type', [
|
||||
{ value: Movie, name: 'movie' },
|
||||
{ value: Song, name: 'song' },
|
||||
])
|
||||
@ValidateNested({ each: true })
|
||||
items!: Media[];
|
||||
}
|
||||
|
||||
const list = fromJsonSync(Playlist, body);
|
||||
const first = list.items[0];
|
||||
|
||||
first instanceof Movie;
|
||||
|
||||
if (first instanceof Movie) {
|
||||
first.hours();
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'correct',
|
||||
expect: 'compiles',
|
||||
// The same class with the rule that actually fits, so a broken harness cannot make the
|
||||
// two cases above "pass" by failing everything.
|
||||
source: `import { JsonProperty, IsString, Matches, IsInt, Min, IsDate, fromJsonSync } from '../src/index.js';
|
||||
declare const body: string;
|
||||
|
||||
class Order {
|
||||
@JsonProperty('order_ref')
|
||||
@IsString() @Matches(/^[A-Z]-\\d+$/)
|
||||
ref!: string;
|
||||
|
||||
@IsInt() @Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@IsDate()
|
||||
placedAt!: Date;
|
||||
|
||||
total(): number { return this.quantity * 9.99; }
|
||||
}
|
||||
|
||||
const order = fromJsonSync(Order, body);
|
||||
order.total();
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/** Compiles every case in one program and returns its diagnostics, keyed by case id. */
|
||||
export async function collectDiagnostics(root) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-diagnostics-'));
|
||||
try {
|
||||
const files = new Map();
|
||||
for (const testCase of CASES) {
|
||||
const file = path.join(dir, `${testCase.id}.ts`);
|
||||
// The snippets import '../src/index.js' relative to a sibling of src/, so they are
|
||||
// written one directory below the repo root.
|
||||
const target = path.join(root, '.diagnostics', `${testCase.id}.ts`);
|
||||
files.set(testCase.id, target);
|
||||
await writeFile(file, testCase.source);
|
||||
}
|
||||
|
||||
// Write into the repo so that '../src/index.js' resolves the way it does for a consumer.
|
||||
const scratch = path.join(root, '.diagnostics');
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
await mkdir(scratch, { recursive: true });
|
||||
for (const testCase of CASES) {
|
||||
await writeFile(files.get(testCase.id), testCase.source);
|
||||
}
|
||||
|
||||
const configPath = path.join(root, 'tsconfig.json');
|
||||
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, root);
|
||||
|
||||
const program = ts.createProgram([...files.values()], {
|
||||
...parsed.options,
|
||||
noEmit: true,
|
||||
rootDir: root,
|
||||
outDir: undefined,
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
sourceMap: false,
|
||||
});
|
||||
|
||||
const all = [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()];
|
||||
const byCase = {};
|
||||
const problems = [];
|
||||
|
||||
for (const testCase of CASES) {
|
||||
const file = files.get(testCase.id);
|
||||
const mine = all.filter((d) => d.file && path.resolve(d.file.fileName) === path.resolve(file));
|
||||
|
||||
if (testCase.expect === 'rejected' && mine.length === 0) {
|
||||
problems.push(`case "${testCase.id}" was expected to be rejected by tsc, but it compiled. ` +
|
||||
'The landing page claims this is a compile error — either the claim or the library is wrong.');
|
||||
}
|
||||
if (testCase.expect === 'compiles' && mine.length > 0) {
|
||||
problems.push(`case "${testCase.id}" was expected to compile, but tsc reported: ` +
|
||||
ts.flattenDiagnosticMessageText(mine[0].messageText, ' '));
|
||||
}
|
||||
|
||||
byCase[testCase.id] = mine.map((diagnostic) => {
|
||||
const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
||||
return {
|
||||
code: diagnostic.code,
|
||||
// The chain, flattened one message per level, so the page can show the headline
|
||||
// and the "Type X is not assignable to type Y" leaf without inventing either.
|
||||
messages: flattenChain(diagnostic.messageText),
|
||||
line: line + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Diagnostics anywhere else mean the harness itself is broken.
|
||||
const stray = all.filter((d) => !d.file || ![...files.values()].some((f) => path.resolve(d.file.fileName) === path.resolve(f)));
|
||||
if (stray.length > 0) {
|
||||
problems.push(`the diagnostics harness produced ${stray.length} error(s) outside the cases, ` +
|
||||
`starting with: ${ts.flattenDiagnosticMessageText(stray[0].messageText, ' ')}`);
|
||||
}
|
||||
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
return { byCase, problems };
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function flattenChain(messageText) {
|
||||
if (typeof messageText === 'string') return [messageText];
|
||||
const out = [];
|
||||
let node = messageText;
|
||||
while (node) {
|
||||
out.push(node.messageText);
|
||||
node = node.next && node.next[0];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+25
-14
@@ -13,7 +13,7 @@ import {
|
||||
ArrayMaxSize,
|
||||
IsNotIn,
|
||||
Validate,
|
||||
defineRule,
|
||||
registerDecorator,
|
||||
JsonType,
|
||||
JsonPolymorphic,
|
||||
JsonMapper,
|
||||
@@ -244,14 +244,21 @@ describe('Additional Decorators', () => {
|
||||
|
||||
describe('registerDecorator', () => {
|
||||
it('should register a custom decorator with functional validator', async () => {
|
||||
class Test {
|
||||
val: number = 0;
|
||||
}
|
||||
defineRule(Test, 'val', {
|
||||
function IsEven() {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isEven',
|
||||
validate: (value: any) => typeof value === 'number' && value % 2 === 0,
|
||||
message: 'val must be even',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
validator: (value: any) => typeof value === 'number' && value % 2 === 0,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
class Test {
|
||||
@IsEven()
|
||||
val: number;
|
||||
}
|
||||
|
||||
const t = new Test();
|
||||
t.val = 2;
|
||||
@@ -264,15 +271,19 @@ describe('Additional Decorators', () => {
|
||||
class MyValidator implements ValidatorConstraintInterface {
|
||||
validate(v: any) { return v === 'ok'; }
|
||||
}
|
||||
class Test {
|
||||
val: string = '';
|
||||
}
|
||||
const validator = new MyValidator();
|
||||
defineRule(Test, 'val', {
|
||||
function IsOk() {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isOk',
|
||||
validate: (v: any) => validator.validate(v),
|
||||
message: 'val must be ok',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
validator: MyValidator,
|
||||
});
|
||||
};
|
||||
}
|
||||
class Test {
|
||||
@IsOk() val: string;
|
||||
}
|
||||
const t = new Test();
|
||||
t.val = 'ok';
|
||||
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
||||
|
||||
+997
-505
File diff suppressed because it is too large
Load Diff
+19
-12
@@ -25,7 +25,8 @@ import {
|
||||
Validate,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
Matches,
|
||||
registerDecorator,
|
||||
ValidationOptions
|
||||
} from './index.js';
|
||||
|
||||
// --- Custom Validators ---
|
||||
@@ -41,8 +42,17 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/** A custom rule is just a decorator that composes an existing one. */
|
||||
const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' });
|
||||
function IsSlug(options?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isSlug',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
...(options ? { options } : {}),
|
||||
validator: (value: any) => typeof value === 'string' && /^[a-z0-9-]+$/.test(value)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// --- Custom Serializers ---
|
||||
|
||||
@@ -66,14 +76,12 @@ enum Format {
|
||||
}
|
||||
|
||||
abstract class Media {
|
||||
// Standard decorators cannot be applied to an `abstract` member, so the discriminator is a
|
||||
// concrete field the subclasses override.
|
||||
@IsString()
|
||||
type: string = '';
|
||||
abstract type: string;
|
||||
|
||||
// Declared once here. Subclasses inherit the rule without restating it.
|
||||
@IsString()
|
||||
title: string = '';
|
||||
title: string;
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@@ -103,7 +111,7 @@ class Movie extends Media {
|
||||
duration: number;
|
||||
|
||||
// Only checked for films that claim to be part of a series.
|
||||
@ValidateIf<Movie>(movie => movie.duration > 200)
|
||||
@ValidateIf((movie: Movie) => movie.duration > 200)
|
||||
@IsString()
|
||||
intermissionNote?: string;
|
||||
}
|
||||
@@ -114,7 +122,7 @@ class Library {
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsSlug()
|
||||
@IsSlug({ message: 'name must be a lowercase slug' })
|
||||
name: string;
|
||||
|
||||
@JsonProperty('curator_email')
|
||||
@@ -128,12 +136,11 @@ class Library {
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
// Naming the base type has the subtype list checked against it.
|
||||
@JsonPolymorphic<Media>('type', [
|
||||
@JsonPolymorphic('type', [
|
||||
{ value: Book, name: 'book' },
|
||||
{ value: Movie, name: 'movie' }
|
||||
])
|
||||
items: Media[] = [];
|
||||
items: Media[];
|
||||
}
|
||||
|
||||
// --- Execution ---
|
||||
|
||||
+12
-52
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||
import {
|
||||
IsString, IsInt, Min, IsIn, ValidateNested, JsonType, JsonSerialize, JsonDeserialize,
|
||||
JsonSerializer, JsonDeserializer, JsonMappingError,
|
||||
defineRule, validate, toInstance, toPlain, configure, resetConfig,
|
||||
registerDecorator, validate, toInstance, toPlain, configure, resetConfig,
|
||||
} from './index.js';
|
||||
|
||||
afterEach(() => resetConfig());
|
||||
@@ -20,10 +20,11 @@ describe('plan caching', () => {
|
||||
expect(await validate(before)).toEqual([]);
|
||||
|
||||
// Register a rule after the plan has already been built and cached.
|
||||
defineRule(Late, 'value', {
|
||||
registerDecorator({
|
||||
name: 'isEven',
|
||||
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||
message: 'value must be even',
|
||||
target: Late,
|
||||
propertyName: 'value',
|
||||
validator: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||
});
|
||||
|
||||
const after = new Late();
|
||||
@@ -147,11 +148,11 @@ describe('each: true error reporting', () => {
|
||||
it('names the index of the element that failed', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags!: ('a' | 'b')[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b'];
|
||||
basket.tags = ['a', 'b', 'a', 'nope', 'b'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors).toHaveLength(1);
|
||||
@@ -161,10 +162,10 @@ describe('each: true error reporting', () => {
|
||||
it('leaves a caller-supplied message untouched', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||
tags!: 'a'[];
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz' as 'a'];
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||
@@ -173,10 +174,10 @@ describe('each: true error reporting', () => {
|
||||
it('gives the failing element to a message function, not the whole array', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a'], { each: true, message: (args) => `rejected ${JSON.stringify(args.value)}` })
|
||||
tags!: 'a'[];
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'zzz' as 'a'];
|
||||
basket.tags = ['a', 'zzz'];
|
||||
|
||||
const errors = await validate(basket);
|
||||
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||
@@ -185,7 +186,7 @@ describe('each: true error reporting', () => {
|
||||
it('reports nothing when every element passes', async () => {
|
||||
class Basket {
|
||||
@IsIn(['a', 'b'], { each: true })
|
||||
tags!: ('a' | 'b')[];
|
||||
tags: string[];
|
||||
}
|
||||
const basket = new Basket();
|
||||
basket.tags = ['a', 'b'];
|
||||
@@ -219,44 +220,3 @@ 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/);
|
||||
});
|
||||
});
|
||||
|
||||
+6
-8
@@ -41,18 +41,16 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
||||
|
||||
// --- Domain Models ---
|
||||
abstract class Media {
|
||||
// Standard decorators cannot be applied to an `abstract` member, so the base declares a
|
||||
// concrete field the subclasses override.
|
||||
@IsString()
|
||||
type: string = '';
|
||||
abstract type: string;
|
||||
|
||||
@IsString()
|
||||
title: string = '';
|
||||
title: string;
|
||||
}
|
||||
|
||||
class Book extends Media {
|
||||
@IsString()
|
||||
override type: string = 'book';
|
||||
type: string = 'book';
|
||||
|
||||
@IsString()
|
||||
author: string;
|
||||
@@ -65,7 +63,7 @@ class Book extends Media {
|
||||
|
||||
class Movie extends Media {
|
||||
@IsString()
|
||||
override type: string = 'movie';
|
||||
type: string = 'movie';
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@@ -164,7 +162,7 @@ describe('JsonMapper', () => {
|
||||
|
||||
@ArrayNotEmpty()
|
||||
@IsIn(['admin', 'user', 'guest'], { each: true })
|
||||
roles!: ('admin' | 'user' | 'guest')[];
|
||||
roles: string[];
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
@@ -226,7 +224,7 @@ describe('JsonMapper', () => {
|
||||
user.username = 'johndoe';
|
||||
user.email = 'john@example.com';
|
||||
user.active = true;
|
||||
user.roles = ['superadmin' as 'admin'];
|
||||
user.roles = ['superadmin'];
|
||||
|
||||
const errors = await JsonMapper.validate(user);
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './metadata.js';
|
||||
export * from './naming.js';
|
||||
export * from './config.js';
|
||||
export * from './decorators.js';
|
||||
|
||||
@@ -38,42 +38,3 @@ export interface JsonDeserializer<T = any, R = any> {
|
||||
export type ClassConstructor<T> = {
|
||||
new (...args: any[]): T;
|
||||
};
|
||||
|
||||
/**
|
||||
* A decorator that may only be applied to a field whose type is assignable to `Allowed`.
|
||||
*
|
||||
* This is what makes cereale's rules type-checked rather than merely declared. Standard
|
||||
* decorators receive a `ClassFieldDecoratorContext<This, Value>` that carries the field's
|
||||
* declared type, so applying `@IsString()` to a `number` field is a compile error rather
|
||||
* than a runtime surprise:
|
||||
*
|
||||
* ```ts
|
||||
* class User {
|
||||
* @IsString() name!: string; // fine
|
||||
* @IsString() age!: number; // Type 'number' is not assignable to type 'string'
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `null` and `undefined` are included in the `Allowed` union of every built-in rule so
|
||||
* optional fields (`nickname?: string`) still accept the rule that describes them.
|
||||
*/
|
||||
export type FieldDecorator<Allowed> = <This, Value extends Allowed>(
|
||||
target: undefined,
|
||||
context: ClassFieldDecoratorContext<This, Value>
|
||||
) => void;
|
||||
|
||||
/** A field holding a string, or nothing. */
|
||||
export type StringField = string | null | undefined;
|
||||
/** A field holding a number, or nothing. */
|
||||
export type NumberField = number | null | undefined;
|
||||
/** A field holding a boolean, or nothing. */
|
||||
export type BooleanField = boolean | null | undefined;
|
||||
/** A field holding a bigint, or nothing. */
|
||||
export type BigIntField = bigint | null | undefined;
|
||||
/** A field holding a Date, or nothing. */
|
||||
export type DateField = Date | null | undefined;
|
||||
/** A field holding an array, or nothing. */
|
||||
export type ArrayField = readonly unknown[] | null | undefined;
|
||||
|
||||
/** The element type of an array field, used by rules that run per element. */
|
||||
export type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
|
||||
|
||||
@@ -447,136 +447,3 @@ describe('a realistic API payload', () => {
|
||||
expect(response).not.toContain('hunter2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renaming and the unknown-key policy', () => {
|
||||
class Address {
|
||||
@IsString() city!: string;
|
||||
}
|
||||
|
||||
class Order {
|
||||
@JsonProperty('order_ref')
|
||||
@IsString()
|
||||
ref!: string;
|
||||
|
||||
@JsonProperty('home_address')
|
||||
@JsonType(() => Address)
|
||||
@ValidateNested()
|
||||
address!: Address;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rename has to actually take effect.
|
||||
*
|
||||
* The old key used to fall through to the unknown-key policy, and the default `allow`
|
||||
* copied it onto the instance untouched — landing a value on a declared property having
|
||||
* skipped the `@JsonType` declared for it, so `@ValidateNested` then inspected a plain
|
||||
* object with no model and reported nothing. A payload aimed at the previous version of
|
||||
* this class was accepted in part, in silence.
|
||||
*/
|
||||
it('does not write the old key after a rename', async () => {
|
||||
const order = await toInstance(
|
||||
Order,
|
||||
{ ref: 'A-1', address: { city: 'Paris' } },
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
expect(order.ref).toBeUndefined();
|
||||
expect(order.address).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets validation report the fields the stale payload failed to fill', async () => {
|
||||
const order = await toInstance(Order, { ref: 'A-1' }, { validate: false });
|
||||
const errors = flattenErrors(await validate(order));
|
||||
|
||||
expect(Object.keys(errors)).toContain('ref');
|
||||
});
|
||||
|
||||
it('maps the declared names properly, producing real instances', async () => {
|
||||
const order = await toInstance(
|
||||
Order,
|
||||
{ order_ref: 'A-1', home_address: { city: 'Paris' } },
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
expect(order.ref).toBe('A-1');
|
||||
expect(order.address instanceof Address).toBe(true);
|
||||
});
|
||||
|
||||
// A stale name is a mismatch with whatever produced the payload, not a deliberate refusal
|
||||
// like @JsonReadOnly, so a caller who asked to hear about unrecognised keys hears about it —
|
||||
// and is told which property it was reaching for and what that property is called now.
|
||||
it('names the property and its current JSON name under unknownKeys: error', async () => {
|
||||
await expect(
|
||||
toInstance(Order, { ref: 'A-1' }, { validate: false, unknownKeys: 'error' })
|
||||
).rejects.toThrow(/"ref" is not a JSON name for Order.*mapped to "order_ref".*@JsonAlias\("ref"\)/s);
|
||||
});
|
||||
|
||||
it('drops the old key silently under strip and allow alike', async () => {
|
||||
for (const unknownKeys of ['strip', 'allow'] as const) {
|
||||
const order = await toInstance(Order, { ref: 'A-1' }, { validate: false, unknownKeys });
|
||||
expect(order.ref, unknownKeys).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the old name working when @JsonAlias declares it', async () => {
|
||||
class Kept {
|
||||
@JsonProperty('order_ref')
|
||||
@JsonAlias('ref')
|
||||
@IsString()
|
||||
ref!: string;
|
||||
}
|
||||
|
||||
const kept = await toInstance(Kept, { ref: 'A-1' }, { validate: false });
|
||||
expect(kept.ref).toBe('A-1');
|
||||
expect(await validate(kept)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a property key that a naming strategy renders differently', async () => {
|
||||
class Account {
|
||||
@IsString() firstName!: string;
|
||||
}
|
||||
|
||||
const snake = { namingStrategy: 'snake_case' as const, validate: false };
|
||||
expect((await toInstance(Account, { firstName: 'Ada' }, snake)).firstName).toBeUndefined();
|
||||
expect((await toInstance(Account, { first_name: 'Ada' }, snake)).firstName).toBe('Ada');
|
||||
});
|
||||
|
||||
// The same protection by a different route: a read-only property that was also renamed was
|
||||
// still settable under its own key.
|
||||
it('blocks the property key of a renamed read-only field', async () => {
|
||||
class Server {
|
||||
@JsonProperty('server_id')
|
||||
@JsonReadOnly()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString() name!: string;
|
||||
}
|
||||
|
||||
const server = await toInstance(
|
||||
Server,
|
||||
{ id: 'client-supplied', server_id: 'also-client-supplied', name: 'x' },
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
expect(server.id).toBeUndefined();
|
||||
expect(server.name).toBe('x');
|
||||
});
|
||||
|
||||
// A key one property no longer answers to may be exactly what another property is called.
|
||||
it('still maps a key that another property legitimately claims', async () => {
|
||||
class Shuffled {
|
||||
@JsonProperty('other')
|
||||
@IsString()
|
||||
a!: string;
|
||||
|
||||
@JsonAlias('a')
|
||||
@IsString()
|
||||
b!: string;
|
||||
}
|
||||
|
||||
const shuffled = await toInstance(Shuffled, { other: 'x', a: 'y' }, { validate: false });
|
||||
expect(shuffled.a).toBe('x');
|
||||
expect(shuffled.b).toBe('y');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
export class MetadataStorage {
|
||||
private static instance: MetadataStorage;
|
||||
|
||||
/**
|
||||
* Bumped whenever any metadata is written.
|
||||
*
|
||||
* Decorators run at class-definition time, so in practice this stops changing once the
|
||||
* application has loaded. Derived structures (see the validation plan cache in utils.ts)
|
||||
* record the version they were built from and rebuild if it moves, which keeps caching
|
||||
* safe even for metadata registered late through `registerDecorator`.
|
||||
*/
|
||||
private _version = 0;
|
||||
|
||||
get version(): number {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
// Maps a prototype to its property names
|
||||
private properties = new WeakMap<any, string[]>();
|
||||
|
||||
// Maps a prototype and property name to its metadata
|
||||
// Map<Prototype, Map<PropertyKey, Map<MetadataKey, Value>>>
|
||||
private propertyMetadata = new WeakMap<any, Map<string, Map<string, any>>>();
|
||||
|
||||
// Maps a prototype to its class-level metadata
|
||||
private classMetadata = new WeakMap<any, Map<string, any>>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MetadataStorage {
|
||||
if (!MetadataStorage.instance) {
|
||||
MetadataStorage.instance = new MetadataStorage();
|
||||
}
|
||||
return MetadataStorage.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines metadata for a specific property on a target.
|
||||
*/
|
||||
defineMetadata(key: string, value: any, target: any, propertyKey?: string) {
|
||||
this._version++;
|
||||
if (propertyKey) {
|
||||
let targetMap = this.propertyMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
targetMap = new Map();
|
||||
this.propertyMetadata.set(target, targetMap);
|
||||
}
|
||||
|
||||
let propertyMap = targetMap.get(propertyKey);
|
||||
if (!propertyMap) {
|
||||
propertyMap = new Map();
|
||||
targetMap.set(propertyKey, propertyMap);
|
||||
}
|
||||
|
||||
propertyMap.set(key, value);
|
||||
} else {
|
||||
let targetMap = this.classMetadata.get(target);
|
||||
if (!targetMap) {
|
||||
targetMap = new Map();
|
||||
this.classMetadata.set(target, targetMap);
|
||||
}
|
||||
targetMap.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets metadata for a specific property on a target, including from the prototype chain.
|
||||
*/
|
||||
getMetadata(key: string, target: any, propertyKey?: string): any {
|
||||
let current = target;
|
||||
while (current) {
|
||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects a metadata value from every level of the prototype chain that defines one.
|
||||
*
|
||||
* Unlike {@link getMetadata}, which stops at the first (most derived) match, this returns
|
||||
* every value found, ordered from the BASE class down to the most derived one. It exists
|
||||
* for metadata that must accumulate across an inheritance chain rather than be overridden —
|
||||
* validation constraints in particular, where a subclass re-decorating an inherited property
|
||||
* must add to the base class's rules instead of silently replacing them.
|
||||
*/
|
||||
getMetadataChain(key: string, target: any, propertyKey?: string): any[] {
|
||||
const chain: any[] = [];
|
||||
let current = target;
|
||||
while (current) {
|
||||
const value = this.getOwnMetadata(key, current, propertyKey);
|
||||
if (value !== undefined) {
|
||||
// Walking derived -> base, so prepend to end up base-first.
|
||||
chain.unshift(value);
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets metadata defined directly on the target.
|
||||
*/
|
||||
getOwnMetadata(key: string, target: any, propertyKey?: string): any {
|
||||
if (propertyKey) {
|
||||
return this.propertyMetadata.get(target)?.get(propertyKey)?.get(key);
|
||||
} else {
|
||||
return this.classMetadata.get(target)?.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a property for a target.
|
||||
*/
|
||||
registerProperty(target: any, propertyKey: string) {
|
||||
this._version++;
|
||||
let props = this.properties.get(target);
|
||||
if (!props) {
|
||||
props = [];
|
||||
this.properties.set(target, props);
|
||||
}
|
||||
if (!props.includes(propertyKey)) {
|
||||
props.push(propertyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all registered properties for a target, including from the prototype chain.
|
||||
*/
|
||||
getProperties(target: any): string[] {
|
||||
const allProps = new Set<string>();
|
||||
let current = target;
|
||||
while (current) {
|
||||
const props = this.properties.get(current);
|
||||
if (props) {
|
||||
props.forEach(p => allProps.add(p));
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
return Array.from(allProps);
|
||||
}
|
||||
}
|
||||
|
||||
export const metadataStorage = MetadataStorage.getInstance();
|
||||
-245
@@ -1,245 +0,0 @@
|
||||
import type { ClassConstructor } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* The key decorator metadata is stored under.
|
||||
*
|
||||
* Resolved into a binding rather than read as `Symbol.metadata` at each use. If the well-known
|
||||
* symbol is absent, `Symbol.metadata` evaluates to `undefined` and `clazz[undefined]` quietly
|
||||
* reads a property literally named "undefined" — `modelOf` would return an empty model and
|
||||
* every object would validate clean. Silent success is the worst failure mode a validation
|
||||
* library can have, so the fallback is baked into the value the code actually uses.
|
||||
*
|
||||
* `Symbol.for` matches what the decorator transforms emit (esbuild's `__knownSymbol` uses the
|
||||
* same fallback), and keeps the key identical across duplicate copies of the library, which
|
||||
* the dual ESM/CJS build can otherwise produce.
|
||||
*/
|
||||
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
||||
|
||||
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
|
||||
// directly. package.json marks this module as having side effects so it survives bundling.
|
||||
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
||||
|
||||
export interface ValidationArguments {
|
||||
value: any;
|
||||
object: any;
|
||||
property: string;
|
||||
constraints: any[];
|
||||
}
|
||||
|
||||
export interface ValidationOptions {
|
||||
/** Apply the rule to each element of an array rather than to the array itself. */
|
||||
each?: boolean;
|
||||
/** Replaces the built-in message. Reported verbatim — the engine never decorates it. */
|
||||
message?: string | ((args: ValidationArguments) => string);
|
||||
}
|
||||
|
||||
/** Narrowed form used by the `each: true` decorator overloads. */
|
||||
export interface EachValidationOptions extends ValidationOptions {
|
||||
each: true;
|
||||
}
|
||||
|
||||
export type ValidationConstraint = {
|
||||
name: string;
|
||||
validate: (value: any, args: ValidationArguments) => boolean | Promise<boolean>;
|
||||
message: string | ((args: ValidationArguments) => string);
|
||||
constraints?: any[];
|
||||
each?: boolean;
|
||||
/**
|
||||
* True when the message came from the caller. The engine only decorates its own default
|
||||
* wording with the "each element in ..." prefix.
|
||||
*/
|
||||
hasCustomMessage?: boolean;
|
||||
};
|
||||
|
||||
export interface ValidatorConstraintInterface {
|
||||
validate(value: any, args: ValidationArguments): boolean | Promise<boolean>;
|
||||
defaultMessage?(args: ValidationArguments): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which directions a property participates in.
|
||||
*
|
||||
* - `readwrite` (default): mapped both ways.
|
||||
* - `readonly`: written to JSON, never populated from incoming JSON (server-assigned ids).
|
||||
* - `writeonly`: populated from incoming JSON, never written back out (passwords).
|
||||
* - `none`: ignored entirely.
|
||||
*/
|
||||
export type PropertyAccess = 'readwrite' | 'readonly' | 'writeonly' | 'none';
|
||||
|
||||
export interface PolymorphicInfo {
|
||||
discriminator: string;
|
||||
subTypes: { value: ClassConstructor<any>; name: string }[];
|
||||
onUnknown: 'keep' | 'error';
|
||||
fallback?: ClassConstructor<any>;
|
||||
}
|
||||
|
||||
/** Everything cereale knows about one field. */
|
||||
export interface PropertyModel {
|
||||
constraints: ValidationConstraint[];
|
||||
optional?: boolean;
|
||||
nested?: boolean;
|
||||
condition?: (object: any) => boolean;
|
||||
/** Explicit JSON name from `@JsonProperty`. */
|
||||
name?: string;
|
||||
aliases?: string[];
|
||||
access?: PropertyAccess;
|
||||
serializer?: ClassConstructor<any>;
|
||||
deserializer?: ClassConstructor<any>;
|
||||
type?: () => ClassConstructor<any>;
|
||||
polymorphic?: PolymorphicInfo;
|
||||
}
|
||||
|
||||
export type ClassModel = Record<string, PropertyModel>;
|
||||
|
||||
const MODEL = Symbol.for('cereale.model');
|
||||
|
||||
/**
|
||||
* Bumped whenever a model is written. Derived structures (the plans in engine.ts) record the
|
||||
* version they were built from and rebuild if it moves, so programmatic registration after a
|
||||
* class has already been used stays correct.
|
||||
*/
|
||||
let version = 0;
|
||||
|
||||
export function modelVersion(): number {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model owned by this class, creating it if necessary.
|
||||
*
|
||||
* `context.metadata` inherits from the base class's metadata through the prototype chain, so
|
||||
* a subclass starts out seeing everything its base declared. Writing requires an own copy —
|
||||
* otherwise a subclass would mutate its parent — and the inherited entries are deep-copied so
|
||||
* that a subclass re-decorating an inherited field *adds to* the base's rules instead of
|
||||
* replacing them. That inheritance-merging behaviour is structural here; the previous
|
||||
* WeakMap-based storage had to reconstruct it by walking prototypes on every read.
|
||||
*/
|
||||
function ownModel(metadata: DecoratorMetadata): ClassModel {
|
||||
if (!Object.hasOwn(metadata, MODEL)) {
|
||||
const inherited = (metadata as Record<symbol, ClassModel | undefined>)[MODEL];
|
||||
const own: ClassModel = {};
|
||||
for (const [key, property] of Object.entries(inherited ?? {})) {
|
||||
own[key] = { ...property, constraints: [...property.constraints] };
|
||||
}
|
||||
(metadata as Record<symbol, ClassModel>)[MODEL] = own;
|
||||
}
|
||||
return (metadata as Record<symbol, ClassModel>)[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++;
|
||||
const model = ownModel(metadata);
|
||||
return (model[property] ??= { constraints: [] });
|
||||
}
|
||||
|
||||
/** Appends a validation rule to a field, honouring `each` and a caller-supplied message. */
|
||||
export function addConstraint(
|
||||
metadata: DecoratorMetadata,
|
||||
property: string,
|
||||
constraint: ValidationConstraint,
|
||||
options?: ValidationOptions
|
||||
): void {
|
||||
if (options?.each) constraint.each = true;
|
||||
if (options?.message) {
|
||||
constraint.message = options.message;
|
||||
constraint.hasCustomMessage = true;
|
||||
}
|
||||
propertyModel(metadata, property).constraints.push(constraint);
|
||||
}
|
||||
|
||||
/** Reads the model declared on a class. Returns an empty model for undecorated classes. */
|
||||
export function modelOf(clazz: unknown): ClassModel {
|
||||
if (typeof clazz !== 'function') return {};
|
||||
const metadata = (clazz as unknown as Record<symbol, DecoratorMetadata | undefined>)[METADATA_KEY];
|
||||
return (metadata as Record<symbol, ClassModel> | undefined)?.[MODEL] ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the model that applies to an instance.
|
||||
*
|
||||
* Guarded rather than reading `obj.constructor` directly: null-prototype objects have no
|
||||
* constructor, and an instance whose `constructor` property has been overwritten would lie.
|
||||
*/
|
||||
export function modelOfInstance(obj: object): ClassModel {
|
||||
const prototype = Object.getPrototypeOf(obj);
|
||||
if (!prototype) return {};
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor');
|
||||
return modelOf(descriptor?.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a rule on a class from outside a decorator.
|
||||
*
|
||||
* The escape hatch for rules that cannot be expressed at the declaration site — built from
|
||||
* configuration, say. Prefer decorators, which are type-checked against the field.
|
||||
*/
|
||||
export function defineRule<T>(
|
||||
clazz: ClassConstructor<T>,
|
||||
property: keyof T & string,
|
||||
constraint: ValidationConstraint,
|
||||
options?: ValidationOptions
|
||||
): void {
|
||||
const holder = clazz as unknown as Record<symbol, DecoratorMetadata | undefined>;
|
||||
// `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);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe('regressions', () => {
|
||||
}
|
||||
class Sub extends Base {
|
||||
@IsString()
|
||||
override name: string = '';
|
||||
declare name: string;
|
||||
}
|
||||
|
||||
const s = new Sub();
|
||||
@@ -35,7 +35,7 @@ describe('regressions', () => {
|
||||
it('enforces base constraints that the subclass never restates', async () => {
|
||||
abstract class Media {
|
||||
@IsString()
|
||||
title: string = '';
|
||||
title: string;
|
||||
}
|
||||
class Book extends Media {
|
||||
@IsString()
|
||||
@@ -57,7 +57,7 @@ describe('regressions', () => {
|
||||
}
|
||||
class Sub extends Base {
|
||||
@IsString()
|
||||
override type: string = '';
|
||||
declare type: string;
|
||||
}
|
||||
|
||||
const s = new Sub();
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
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<Map<string, number>, Record<string, number>> {
|
||||
serialize(value: Map<string, number>) { return Object.fromEntries(value); }
|
||||
}
|
||||
|
||||
class Post {
|
||||
@JsonSerialize(TagsSerializer)
|
||||
tags!: Map<string, number>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -383,14 +383,14 @@ describe('write-only redaction in validation errors', () => {
|
||||
it('does not redact a value that merely sits next to a secret', async () => {
|
||||
class Form {
|
||||
@IsIn(['a', 'b'])
|
||||
choice!: 'a' | 'b';
|
||||
choice: string;
|
||||
|
||||
@JsonWriteOnly()
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
const form = new Form();
|
||||
form.choice = 'zzz' as 'a';
|
||||
form.choice = 'zzz';
|
||||
form.token = 'secret-token';
|
||||
|
||||
const errors = await validate(form);
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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<string> {
|
||||
const result = await transform(source, {
|
||||
loader: 'ts',
|
||||
target: 'es2022',
|
||||
tsconfigRaw: { compilerOptions: options },
|
||||
});
|
||||
return result.code;
|
||||
},
|
||||
async swc(source: string, options: typeof STANDARD): Promise<string> {
|
||||
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<string, unknown>).__cerealeProbeRule = IsString;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete (globalThis as Record<string, unknown>).__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']);
|
||||
});
|
||||
|
||||
// esbuild lowers standard decorators only when its own top-level `target` is below `esnext`.
|
||||
// A `target` inside `tsconfigRaw` sets the `useDefineForClassFields` default and nothing else,
|
||||
// so the natural-looking "put the tsconfig settings in tsconfigRaw" configuration leaves the
|
||||
// decorator syntax in the output — the same silent passthrough oxc produces. Documented here
|
||||
// because the README and the landing page both tell people how to configure esbuild.
|
||||
it('needs esbuild’s own target, not one inside tsconfigRaw', async () => {
|
||||
const withoutTarget = await transform(PROBE, {
|
||||
loader: 'ts',
|
||||
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, target: 'es2022' } },
|
||||
});
|
||||
expect(withoutTarget.code, 'expected the decorator to survive untransformed').toMatch(/@Rule\(\)/);
|
||||
|
||||
const withTarget = await transform(PROBE, {
|
||||
loader: 'ts',
|
||||
target: 'es2022',
|
||||
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||
});
|
||||
expect(withTarget.code).not.toMatch(/@Rule\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
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<typeof standardDecorators>[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/);
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* The headline guarantee of v2 is that a rule cannot be attached to a field it does not fit.
|
||||
* That is a *compile-time* claim, so asserting it needs the compiler: each case below is
|
||||
* type-checked in isolation and must fail.
|
||||
*
|
||||
* These run the real `tsc`, so they are slower than the rest of the suite — but a guarantee
|
||||
* nobody checks is a guarantee that quietly stops holding.
|
||||
*/
|
||||
const TSC = resolve('node_modules/.bin/tsc');
|
||||
const SRC = resolve('src/index.js').replace(/\.js$/, '');
|
||||
|
||||
function typeCheck(body: string): { ok: boolean; output: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cereale-types-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext',
|
||||
// These cases compile the library's *source*, whose implementations call `new URL()`.
|
||||
// Nothing in the published signatures needs DOM — scripts/check-types.mjs compiles a
|
||||
// consumer against dist/ with no DOM lib and no @types/node to keep it that way.
|
||||
lib: ['ESNext', 'ESNext.Decorators', 'DOM'], strict: true,
|
||||
strictPropertyInitialization: false, noEmit: true, skipLibCheck: true,
|
||||
},
|
||||
include: ['case.ts'],
|
||||
}));
|
||||
writeFileSync(join(dir, 'case.ts'), `import {\n IsString, IsInt, Min, MinLength, IsArray, ArrayMinSize, ArrayUnique,\n IsDate, MinDate, IsBoolean, IsIn, IsEnum, JsonType, JsonSerialize,\n JsonDeserialize, JsonSerializer, JsonDeserializer,\n} from ${JSON.stringify(SRC + '.js')};\n\n${body}\n`);
|
||||
try {
|
||||
execFileSync(process.execPath, [TSC, '-p', dir], { stdio: 'pipe' });
|
||||
return { ok: true, output: '' };
|
||||
} catch (error: any) {
|
||||
return { ok: false, output: String(error.stdout ?? '') + String(error.stderr ?? '') };
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const compiles = (body: string) => {
|
||||
const result = typeCheck(body);
|
||||
if (!result.ok) throw new Error(`expected this to compile but it did not:\n${result.output}`);
|
||||
};
|
||||
|
||||
const rejects = (body: string) => {
|
||||
const result = typeCheck(body);
|
||||
expect(result.ok, 'expected a compile error, but it compiled').toBe(false);
|
||||
return result.output;
|
||||
};
|
||||
|
||||
describe('rules are checked against the field type', () => {
|
||||
it('accepts rules that match the field', () => {
|
||||
compiles(`
|
||||
class Ok {
|
||||
@IsString() @MinLength(2) name!: string;
|
||||
@IsInt() @Min(0) age!: number;
|
||||
@IsBoolean() active!: boolean;
|
||||
@IsDate() @MinDate(new Date(0)) when!: Date;
|
||||
@IsArray() @ArrayMinSize(1) tags!: string[];
|
||||
@IsString() nickname?: string;
|
||||
@IsString() maybe!: string | null;
|
||||
}
|
||||
void Ok;
|
||||
`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a string rule on a number field', () => {
|
||||
expect(rejects(`class Bad { @IsString() age!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a number rule on a string field', () => {
|
||||
expect(rejects(`class Bad { @Min(0) label!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an array rule on a non-array field', () => {
|
||||
expect(rejects(`class Bad { @ArrayMinSize(1) count!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a date rule on a string field', () => {
|
||||
expect(rejects(`class Bad { @MinDate(new Date(0)) when!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('each: true moves the rule onto the elements', () => {
|
||||
it('accepts a matching array field', () => {
|
||||
compiles(`class Ok { @IsString({ each: true }) tags!: string[] } void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects each:true on a scalar field', () => {
|
||||
expect(rejects(`class Bad { @IsString({ each: true }) tag!: string } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a bare rule on an array field', () => {
|
||||
expect(rejects(`class Bad { @IsString() tags!: string[] } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an element-type mismatch', () => {
|
||||
expect(rejects(`class Bad { @IsString({ each: true }) nums!: number[] } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('nested types and converters are checked', () => {
|
||||
const shapes = `
|
||||
class Address { street!: string }
|
||||
class Money { amount!: number }
|
||||
`;
|
||||
|
||||
it('accepts the matching class', () => {
|
||||
compiles(`${shapes}
|
||||
class Ok {
|
||||
@JsonType(() => Address) ship!: Address;
|
||||
@JsonType(() => Address) history!: Address[];
|
||||
}
|
||||
void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an unrelated class', () => {
|
||||
expect(rejects(`${shapes}
|
||||
class Bad { @JsonType(() => Money) ship!: Address }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a serializer whose input does not match the field', () => {
|
||||
expect(rejects(`
|
||||
class DateToString implements JsonSerializer<Date, string> {
|
||||
serialize(v: Date) { return v.toISOString(); }
|
||||
}
|
||||
class Bad { @JsonSerialize(DateToString) name!: string }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a deserializer whose output does not match the field', () => {
|
||||
expect(rejects(`
|
||||
class StringToDate implements JsonDeserializer<string, Date> {
|
||||
deserialize(v: string) { return new Date(v); }
|
||||
}
|
||||
class Bad { @JsonDeserialize(StringToDate) name!: string }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('membership rules narrow the field', () => {
|
||||
it('accepts a field typed as the allowed union', () => {
|
||||
compiles(`class Ok { @IsIn(['a', 'b']) choice!: 'a' | 'b' } void Ok;`);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a field that cannot hold the allowed values', () => {
|
||||
expect(rejects(`class Bad { @IsIn(['a', 'b']) choice!: number } void Bad;`))
|
||||
.toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects an enum rule on a mismatched field', () => {
|
||||
expect(rejects(`
|
||||
enum Role { Admin = 'admin' }
|
||||
class Bad { @IsEnum(Role) role!: number }
|
||||
void Bad;`)).toMatch(/not assignable|Unable to resolve/);
|
||||
}, 60_000);
|
||||
|
||||
it('accepts an enum rule on the enum field', () => {
|
||||
compiles(`
|
||||
enum Role { Admin = 'admin', User = 'user' }
|
||||
class Ok { @IsEnum(Role) role!: Role }
|
||||
void Ok;`);
|
||||
}, 60_000);
|
||||
});
|
||||
+126
-278
@@ -1,9 +1,6 @@
|
||||
import { ClassConstructor } from './interfaces.js';
|
||||
import {
|
||||
modelOf, modelOfInstance, modelVersion,
|
||||
type ClassModel, type PropertyAccess, type PropertyModel,
|
||||
type ValidationArguments, type ValidationConstraint,
|
||||
} from './metadata.js';
|
||||
import { METADATA_KEYS, PropertyAccess, ValidationConstraint, ValidationArguments } from './decorators.js';
|
||||
import { metadataStorage } from './metadata-storage.js';
|
||||
import { NamingStrategyFn, resolveNamingStrategy } from './naming.js';
|
||||
import { TransformOptions, UnknownKeyPolicy, resolveOptions } from './config.js';
|
||||
|
||||
@@ -50,18 +47,6 @@ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
*/
|
||||
export const REDACTED = '[redacted]';
|
||||
|
||||
/**
|
||||
* Anything that can hand back a parsed JSON body — a `Request`, a `Response`, or a test double.
|
||||
*
|
||||
* Declared structurally rather than as the global `Request`, which does not exist unless the
|
||||
* consumer's `lib` includes DOM or their `types` includes node. Naming the global here put a
|
||||
* `Cannot find name 'Request'` error inside cereale's own published `.d.ts`, in a project that
|
||||
* may not call `fromRequest` at all and cannot fix it from the outside.
|
||||
*/
|
||||
export interface JsonBody {
|
||||
json(): Promise<any>;
|
||||
}
|
||||
|
||||
// --- Internal Engine ---
|
||||
|
||||
interface SerializeContext {
|
||||
@@ -77,13 +62,25 @@ interface DeserializeContext {
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
function accessOf(model: ClassModel, key: string): PropertyAccess {
|
||||
return model[key]?.access ?? 'readwrite';
|
||||
/**
|
||||
* Resolves the metadata lookup target for a value.
|
||||
*
|
||||
* `Object.getPrototypeOf` rather than `obj.constructor.prototype`: the latter throws on
|
||||
* null-prototype objects (which have no `constructor`) and lies for instances whose
|
||||
* `constructor` property has been overwritten.
|
||||
*/
|
||||
function prototypeOf(obj: any): any {
|
||||
return Object.getPrototypeOf(obj) ?? undefined;
|
||||
}
|
||||
|
||||
function accessOf(target: any, key: string): PropertyAccess {
|
||||
return (target ? metadataStorage.getMetadata(METADATA_KEYS.ACCESS, target, key) : undefined) ?? 'readwrite';
|
||||
}
|
||||
|
||||
/** The name this property takes in JSON: an explicit @JsonProperty, else the naming strategy. */
|
||||
function outboundName(model: ClassModel, key: string, naming: NamingStrategyFn): string {
|
||||
return model[key]?.name ?? naming(key);
|
||||
function outboundName(target: any, key: string, naming: NamingStrategyFn): string {
|
||||
const explicit = target ? metadataStorage.getMetadata(METADATA_KEYS.NAME, target, key) : undefined;
|
||||
return explicit ?? naming(key);
|
||||
}
|
||||
|
||||
/** Per-property serialization facts, resolved once instead of per call. */
|
||||
@@ -96,7 +93,7 @@ interface OutboundProperty {
|
||||
serializer?: any;
|
||||
}
|
||||
|
||||
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||
const outboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
|
||||
|
||||
/**
|
||||
* Resolves how one property is written out, memoized per (prototype, naming strategy).
|
||||
@@ -104,11 +101,16 @@ const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map
|
||||
* Serialization walks the runtime keys of each object, so undeclared properties turn up here
|
||||
* too; they memoize just as well, since the naming strategy is deterministic.
|
||||
*/
|
||||
function outboundFor(model: ClassModel, key: string, ctx: SerializeContext): OutboundProperty {
|
||||
let entry = outboundCache.get(model);
|
||||
if (!entry || entry.version !== modelVersion()) {
|
||||
entry = { version: modelVersion(), byStrategy: new Map() };
|
||||
outboundCache.set(model, entry);
|
||||
function outboundFor(target: any, key: string, ctx: SerializeContext): OutboundProperty {
|
||||
if (!target) {
|
||||
// Null-prototype object: nothing is declared, so there is nothing to cache against.
|
||||
return { name: ctx.naming(key), skip: false };
|
||||
}
|
||||
|
||||
let entry = outboundCache.get(target);
|
||||
if (!entry || entry.version !== metadataStorage.version) {
|
||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||
outboundCache.set(target, entry);
|
||||
}
|
||||
|
||||
let byKey = entry.byStrategy.get(ctx.namingKey);
|
||||
@@ -119,10 +121,10 @@ function outboundFor(model: ClassModel, key: string, ctx: SerializeContext): Out
|
||||
|
||||
let resolved = byKey.get(key);
|
||||
if (!resolved) {
|
||||
const access = accessOf(model, key);
|
||||
const serializer = model[key]?.serializer;
|
||||
const access = accessOf(target, key);
|
||||
const serializer = metadataStorage.getMetadata(METADATA_KEYS.SERIALIZER, target, key);
|
||||
resolved = {
|
||||
name: outboundName(model, key, ctx.naming),
|
||||
name: outboundName(target, key, ctx.naming),
|
||||
// `writeonly` is accepted on input but must never be echoed back out.
|
||||
skip: access === 'none' || access === 'writeonly',
|
||||
...(serializer ? { serializer } : {}),
|
||||
@@ -146,62 +148,38 @@ interface InboundNames {
|
||||
props: Map<string, InboundProperty>;
|
||||
/**
|
||||
* JSON names that belong to a declared property the payload may NOT set
|
||||
* (`@JsonIgnore` / `@JsonReadOnly`), plus those properties' own keys. They are dropped
|
||||
* rather than treated as unknown keys — otherwise the default `unknownKeys: 'allow'` policy
|
||||
* would copy them straight back onto the instance and undo the protection.
|
||||
* (`@JsonIgnore` / `@JsonReadOnly`). They are dropped rather than treated as unknown
|
||||
* keys — otherwise the default `unknownKeys: 'allow'` policy would copy them straight
|
||||
* back onto the instance and undo the protection.
|
||||
*/
|
||||
blocked: Set<string>;
|
||||
/**
|
||||
* Names that used to reach a declared property but no longer do: the property key of a
|
||||
* field renamed by `@JsonProperty`, or its raw key under a naming strategy that renders it
|
||||
* differently.
|
||||
*
|
||||
* These are kept apart from `blocked` because they mean something different. A blocked name
|
||||
* is a deliberate refusal, so it is dropped in silence. A stale name is a mismatch between
|
||||
* this class and whatever produced the payload, which a caller who asked for
|
||||
* `unknownKeys: 'error'` wants to hear about — and can be told precisely, since we know
|
||||
* which property it was reaching for and what that property is called now.
|
||||
*/
|
||||
stale: Map<string, { property: string; accepted: string }>;
|
||||
}
|
||||
|
||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||
// declared, so they are cached per (prototype, naming strategy).
|
||||
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
||||
const inboundCache = new WeakMap<object, { version: number; byStrategy: Map<unknown, InboundNames> }>();
|
||||
|
||||
/**
|
||||
* Builds the JSON-name -> property-key lookup used when reading a payload.
|
||||
*
|
||||
* Only names the class actually declares are mapped: the `@JsonProperty` name (or the naming
|
||||
* strategy's rendering of the property name) plus any `@JsonAlias`.
|
||||
*
|
||||
* A name that no longer reaches its property must not fall through to the unknown-key policy,
|
||||
* because `allow` would then copy it onto the instance raw — landing a value on a declared
|
||||
* property having skipped the `@JsonType` or `@JsonDeserialize` declared for it, so that
|
||||
* `@ValidateNested` finds a plain object with no model and reports nothing. Renaming a
|
||||
* property has to actually take effect. Those names go into `stale` (reported under `error`,
|
||||
* dropped otherwise), and the keys of properties the payload may not set at all go into
|
||||
* `blocked` (always dropped in silence).
|
||||
* Only names the class actually declares are accepted: the `@JsonProperty` name (or the
|
||||
* naming strategy's rendering of the property name) plus any `@JsonAlias`. Renaming a
|
||||
* property therefore stops the old name from being silently accepted — add `@JsonAlias` to
|
||||
* keep it working for older clients.
|
||||
*/
|
||||
function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundNames {
|
||||
let entry = inboundCache.get(model);
|
||||
if (!entry || entry.version !== modelVersion()) {
|
||||
entry = { version: modelVersion(), byStrategy: new Map() };
|
||||
inboundCache.set(model, entry);
|
||||
function inboundNameMap(target: any, ctx: DeserializeContext): InboundNames {
|
||||
let entry = inboundCache.get(target);
|
||||
if (!entry || entry.version !== metadataStorage.version) {
|
||||
entry = { version: metadataStorage.version, byStrategy: new Map() };
|
||||
inboundCache.set(target, entry);
|
||||
}
|
||||
const cached = entry.byStrategy.get(ctx.namingKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const accept = new Map<string, string>();
|
||||
const blocked = new Set<string>();
|
||||
const stale = new Map<string, { property: string; accepted: string }>();
|
||||
const props = new Map<string, InboundProperty>();
|
||||
|
||||
// Whether a property key is also some property's accepted JSON name is only known once
|
||||
// every property has been walked, so these are resolved after the loop.
|
||||
const shadowed: string[] = [];
|
||||
const staleCandidates: { property: string; accepted: string }[] = [];
|
||||
|
||||
const claim = (external: string, key: string) => {
|
||||
const owner = accept.get(external);
|
||||
if (owner && owner !== key) {
|
||||
@@ -213,42 +191,33 @@ function inboundNameMap(model: ClassModel, ctx: DeserializeContext): InboundName
|
||||
accept.set(external, key);
|
||||
};
|
||||
|
||||
for (const [key, property] of Object.entries(model)) {
|
||||
const names = [outboundName(model, key, ctx.naming), ...(property.aliases ?? [])];
|
||||
for (const key of metadataStorage.getProperties(target)) {
|
||||
const names = [
|
||||
outboundName(target, key, ctx.naming),
|
||||
...(metadataStorage.getMetadata(METADATA_KEYS.ALIASES, target, key) || []),
|
||||
];
|
||||
|
||||
const access = accessOf(model, key);
|
||||
const access = accessOf(target, key);
|
||||
if (access === 'none' || access === 'readonly') {
|
||||
for (const name of names) blocked.add(name);
|
||||
// A renamed read-only property would otherwise still be settable under its own key,
|
||||
// which is the protection undone by a different route.
|
||||
shadowed.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of names) claim(name, key);
|
||||
if (!names.includes(key)) staleCandidates.push({ property: key, accepted: names[0]! });
|
||||
|
||||
if (property.deserializer || property.polymorphic || property.type) {
|
||||
const deserializer = metadataStorage.getMetadata(METADATA_KEYS.DESERIALIZER, target, key);
|
||||
const polymorphic = metadataStorage.getMetadata(METADATA_KEYS.POLYMORPHIC, target, key);
|
||||
const typeFn = metadataStorage.getMetadata(METADATA_KEYS.TYPE, target, key);
|
||||
if (deserializer || polymorphic || typeFn) {
|
||||
props.set(key, {
|
||||
...(property.deserializer ? { deserializer: property.deserializer } : {}),
|
||||
...(property.polymorphic ? { polymorphic: property.polymorphic } : {}),
|
||||
...(property.type ? { typeFn: property.type } : {}),
|
||||
...(deserializer ? { deserializer } : {}),
|
||||
...(polymorphic ? { polymorphic } : {}),
|
||||
...(typeFn ? { typeFn } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A property key that another property legitimately answers to stays mapped; only keys that
|
||||
// nothing accepts are refused.
|
||||
for (const key of shadowed) {
|
||||
if (!accept.has(key)) blocked.add(key);
|
||||
}
|
||||
for (const candidate of staleCandidates) {
|
||||
if (!accept.has(candidate.property) && !blocked.has(candidate.property)) {
|
||||
stale.set(candidate.property, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const result = { accept, blocked, stale, props };
|
||||
const result = { accept, blocked, props };
|
||||
entry.byStrategy.set(ctx.namingKey, result);
|
||||
return result;
|
||||
}
|
||||
@@ -293,113 +262,14 @@ function refuseAsync(deferred: Deferred, operation: string, asyncName: string):
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
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, unknown>)[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<any>, 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);
|
||||
function serialize(obj: any, ancestors: Set<any>, ctx: SerializeContext, depth: number, deferred: Deferred): any {
|
||||
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (depth > ctx.maxDepth) {
|
||||
throw new JsonMappingError(
|
||||
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing at ${describePath(path)}. ` +
|
||||
`Maximum nesting depth of ${ctx.maxDepth} exceeded while serializing. ` +
|
||||
`Raise it with the maxDepth option if this structure is legitimate.`
|
||||
);
|
||||
}
|
||||
@@ -408,41 +278,31 @@ function serialize(obj: any, ancestors: Set<any>, 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 at ${describePath(path)}. Break the cycle ` +
|
||||
'with @JsonIgnore() on the back-reference, or supply a @JsonSerialize() serializer for ' +
|
||||
'that property.'
|
||||
'Circular reference detected during serialization. Break the cycle with @JsonIgnore() ' +
|
||||
'on the back-reference, or supply a @JsonSerialize() serializer for that property.'
|
||||
);
|
||||
}
|
||||
|
||||
ancestors.add(obj);
|
||||
try {
|
||||
if (isArray) {
|
||||
if (Array.isArray(obj)) {
|
||||
const out: any[] = [];
|
||||
for (let index = 0; index < obj.length; index++) {
|
||||
path.push(index);
|
||||
out.push(serialize(obj[index], ancestors, ctx, depth + 1, deferred, path));
|
||||
path.pop();
|
||||
for (const item of obj) {
|
||||
out.push(serialize(item, ancestors, ctx, depth + 1, deferred));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const model = modelOfInstance(obj);
|
||||
const target = prototypeOf(obj);
|
||||
|
||||
const result: any = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const property = outboundFor(model, key, ctx);
|
||||
const property = outboundFor(target, key, ctx);
|
||||
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.
|
||||
@@ -452,24 +312,14 @@ function serialize(obj: any, ancestors: Set<any>, 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) => {
|
||||
const bad = unrepresentable(settled);
|
||||
if (bad !== null) refuseUnrepresentable(bad, where);
|
||||
result[slot] = settled;
|
||||
}));
|
||||
deferred.push(produced.then((settled: any) => { 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, path);
|
||||
result[property.name] = serialize(value, ancestors, ctx, depth + 1, deferred);
|
||||
}
|
||||
|
||||
path.pop();
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -497,7 +347,8 @@ function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deserialize
|
||||
if (typeof plain !== 'object') return plain;
|
||||
|
||||
const instance = new clazz();
|
||||
const inbound = inboundNameMap(modelOf(clazz), ctx);
|
||||
const target = clazz.prototype;
|
||||
const inbound = inboundNameMap(target, ctx);
|
||||
|
||||
for (const incoming of Object.keys(plain)) {
|
||||
if (FORBIDDEN_KEYS.has(incoming)) continue;
|
||||
@@ -507,22 +358,6 @@ function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deserialize
|
||||
// than quietly refusing to honour it.
|
||||
if (inbound.blocked.has(incoming)) continue;
|
||||
|
||||
// A name that used to reach a declared property. Never assigned — doing so would land the
|
||||
// value on that property having skipped every conversion declared for it — but a caller
|
||||
// who asked to hear about unrecognised keys hears about this one by name, because it is a
|
||||
// mismatch with whatever produced the payload rather than a deliberate refusal.
|
||||
const outdated = inbound.stale.get(incoming);
|
||||
if (outdated !== undefined) {
|
||||
if (ctx.unknownKeys === 'error') {
|
||||
throw new JsonMappingError(
|
||||
`${JSON.stringify(incoming)} is not a JSON name for ${clazz.name}: property ` +
|
||||
`"${outdated.property}" is mapped to ${JSON.stringify(outdated.accepted)}. ` +
|
||||
`Send that name, or add @JsonAlias(${JSON.stringify(incoming)}) to keep accepting this one.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = inbound.accept.get(incoming);
|
||||
if (key === undefined) {
|
||||
// Not a declared property under the active naming strategy.
|
||||
@@ -593,6 +428,36 @@ function deserialize<T>(clazz: ClassConstructor<T>, plain: any, ctx: Deserialize
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the validation constraints that apply to a property, merged across the whole
|
||||
* prototype chain.
|
||||
*
|
||||
* A subclass that re-decorates an inherited property registers its constraints against its
|
||||
* own prototype. Reading only the nearest set would silently drop everything the base class
|
||||
* declared, so the chain is flattened base-first. Constraints that are genuinely identical
|
||||
* (same rule, same fixed message) are collapsed so that re-stating `@IsString()` on an
|
||||
* override does not report the same failure twice; anything with a computed message — custom
|
||||
* validators in particular — is always kept.
|
||||
*/
|
||||
function collectConstraints(target: any, key: string): ValidationConstraint[] {
|
||||
const levels: ValidationConstraint[][] = metadataStorage.getMetadataChain(METADATA_KEYS.VALIDATION, target, key);
|
||||
const merged: ValidationConstraint[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const level of levels) {
|
||||
for (const constraint of level) {
|
||||
if (typeof constraint.message === 'string') {
|
||||
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
}
|
||||
merged.push(constraint);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the validator needs to know about one property, resolved once.
|
||||
*/
|
||||
@@ -611,53 +476,33 @@ interface CachedPlan {
|
||||
plan: PropertyPlan[];
|
||||
}
|
||||
|
||||
// Turning a class model into a per-property plan is cheap, but doing it on every call was
|
||||
// measurably not: profiling showed roughly half of all validation time re-deriving answers
|
||||
// that cannot change. Plans are memoized per model and invalidated by the model version.
|
||||
const planCache = new WeakMap<ClassModel, CachedPlan>();
|
||||
// Resolving a class's validation rules means walking its prototype chain several times per
|
||||
// property, per call — which profiling showed to be roughly half of all validation time,
|
||||
// recomputing an answer that cannot change. The result is memoized per prototype and
|
||||
// invalidated by MetadataStorage's version counter, so metadata registered late still works.
|
||||
const planCache = new WeakMap<object, CachedPlan>();
|
||||
|
||||
/**
|
||||
* Collapses rules that are genuinely identical.
|
||||
*
|
||||
* Inheritance is structural here: a subclass's model starts as a copy of its base's, so
|
||||
* re-stating `@IsString()` on an override would otherwise report the same failure twice.
|
||||
* Only rules with a fixed message are compared — anything with a computed message, custom
|
||||
* validators in particular, is always kept, since two of them can differ while looking alike.
|
||||
*/
|
||||
function dedupe(constraints: ValidationConstraint[]): ValidationConstraint[] {
|
||||
const kept: ValidationConstraint[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const constraint of constraints) {
|
||||
if (typeof constraint.message === 'string') {
|
||||
const identity = `${constraint.name}|${String(constraint.constraints)}|${constraint.message}|${constraint.each ?? false}`;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
}
|
||||
kept.push(constraint);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function validationPlan(model: ClassModel): PropertyPlan[] {
|
||||
const cached = planCache.get(model);
|
||||
if (cached && cached.version === modelVersion()) {
|
||||
function validationPlan(target: any): PropertyPlan[] {
|
||||
const cached = planCache.get(target);
|
||||
if (cached && cached.version === metadataStorage.version) {
|
||||
return cached.plan;
|
||||
}
|
||||
|
||||
const plan: PropertyPlan[] = [];
|
||||
for (const [key, property] of Object.entries(model) as [string, PropertyModel][]) {
|
||||
const access = property.access ?? 'readwrite';
|
||||
for (const key of metadataStorage.getProperties(target)) {
|
||||
const condition = metadataStorage.getMetadata(METADATA_KEYS.CONDITION, target, key);
|
||||
const access = accessOf(target, key);
|
||||
plan.push({
|
||||
key,
|
||||
constraints: dedupe(property.constraints),
|
||||
isOptional: !!property.optional,
|
||||
isNested: !!property.nested,
|
||||
constraints: collectConstraints(target, key),
|
||||
isOptional: !!metadataStorage.getMetadata(METADATA_KEYS.IS_OPTIONAL, target, key),
|
||||
isNested: !!metadataStorage.getMetadata(METADATA_KEYS.NESTED, target, key),
|
||||
redact: access === 'writeonly' || access === 'none',
|
||||
...(property.condition ? { condition: property.condition } : {}),
|
||||
...(condition ? { condition } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
planCache.set(model, { version: modelVersion(), plan });
|
||||
planCache.set(target, { version: metadataStorage.version, plan });
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -799,7 +644,10 @@ function validateInternal(obj: any, ancestors: Set<any>, depth: number, maxDepth
|
||||
return errors;
|
||||
}
|
||||
|
||||
for (const property of validationPlan(modelOfInstance(obj))) {
|
||||
const target = prototypeOf(obj);
|
||||
if (!target) return errors;
|
||||
|
||||
for (const property of validationPlan(target)) {
|
||||
const key = property.key;
|
||||
const value = obj[key];
|
||||
|
||||
@@ -981,7 +829,7 @@ export async function toPlain<T>(obj: T, options?: TransformOptions): Promise<an
|
||||
}
|
||||
|
||||
const deferred: Deferred = [];
|
||||
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred, []);
|
||||
const plain = serialize(obj, new Set(), serializeContext(options), 0, deferred);
|
||||
await settle(deferred);
|
||||
return plain;
|
||||
}
|
||||
@@ -1002,7 +850,7 @@ export function toPlainSync<T>(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;
|
||||
}
|
||||
@@ -1167,7 +1015,7 @@ function parseJson(json: string): any {
|
||||
*/
|
||||
export async function fromRequest<T>(
|
||||
clazz: ClassConstructor<T>,
|
||||
request: JsonBody,
|
||||
request: Request,
|
||||
options?: TransformOptions
|
||||
): Promise<T> {
|
||||
let plain: any;
|
||||
|
||||
+5
-22
@@ -11,29 +11,12 @@ import {
|
||||
validate, toInstance,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Applies a decorator to a synthetic one-field class and reports which rules failed.
|
||||
*
|
||||
* Standard decorators are invoked as `(undefined, context)` rather than against a prototype,
|
||||
* so the context is built by hand here. Only `name` and `metadata` are read by the library;
|
||||
* the rest satisfies the shape.
|
||||
*/
|
||||
async function check(decorator: any, value: any): Promise<string[]> {
|
||||
const metadata = Object.create(null) as DecoratorMetadata;
|
||||
decorator(undefined, {
|
||||
kind: 'field',
|
||||
name: 'val',
|
||||
static: false,
|
||||
private: false,
|
||||
metadata,
|
||||
access: { has: () => true, get: (o: any) => o.val, set: (o: any, v: any) => { o.val = v; } },
|
||||
addInitializer: () => undefined,
|
||||
});
|
||||
|
||||
/** Builds a one-property class, assigns `value`, and returns the constraint keys that failed. */
|
||||
async function check(decorate: (target: any, key: string) => void, value: any): Promise<string[]> {
|
||||
class Subject {
|
||||
val: any;
|
||||
}
|
||||
(Subject as any)[Symbol.metadata] = metadata;
|
||||
decorate(Subject.prototype, 'val');
|
||||
|
||||
const subject = new Subject();
|
||||
subject.val = value;
|
||||
@@ -249,9 +232,9 @@ describe('arrays', () => {
|
||||
describe('@ValidateIf', () => {
|
||||
class Payment {
|
||||
@IsIn(['card', 'invoice'])
|
||||
method!: 'card' | 'invoice';
|
||||
method: string;
|
||||
|
||||
@ValidateIf<Payment>(o => o.method === 'card')
|
||||
@ValidateIf(o => o.method === 'card')
|
||||
@IsString()
|
||||
cardNumber?: string;
|
||||
}
|
||||
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 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<Transformer | null> {
|
||||
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<Transformer | null> {
|
||||
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=<file>.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<string, Promise<Transformer>>();
|
||||
|
||||
function resolveTransformer(choice: 'auto' | 'esbuild' | 'typescript'): Promise<Transformer> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+2
-4
@@ -8,7 +8,7 @@
|
||||
// Environment Settings
|
||||
"module": "NodeNext",
|
||||
"target": "ES2025",
|
||||
"lib": ["ESNext", "ESNext.Decorators"],
|
||||
"lib": ["ESNext"],
|
||||
"types": ["node"],
|
||||
|
||||
// Other Outputs
|
||||
@@ -33,9 +33,7 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
// NOTE: no `experimentalDecorators`. v2 uses TC39 standard decorators, which is what
|
||||
// gives ClassFieldDecoratorContext<This, Value> and therefore compile-time checking of
|
||||
// rules against field types. The two decorator systems cannot coexist in one program.
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
// NOTE: test files are deliberately included here so that `npm run type-check`
|
||||
// covers them. The two build configs exclude them (and the demo) from `dist`.
|
||||
|
||||
+7
-6
@@ -1,12 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { standardDecorators } from './src/vite.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [standardDecorators()],
|
||||
// Vitest 4 transpiles with oxc, which does not read `experimentalDecorators`
|
||||
// out of tsconfig.json for files the tsconfig does not `include`. Without this
|
||||
// the decorator syntax in the test files fails to parse and every suite is
|
||||
// silently reported as "0 test".
|
||||
oxc: {
|
||||
decorator: { legacy: true },
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
|
||||
Reference in New Issue
Block a user