Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f2e310f61 | ||
|
|
057162e45b | ||
|
|
46e30909c7 | ||
|
|
b2f2de2bd4 | ||
|
|
1b9202edf1 | ||
|
|
730e5902a5 | ||
|
|
f56d2811cb | ||
|
|
7a8109a50e | ||
|
|
6adf26964e | ||
|
|
88e5ee23a9 | ||
|
|
41ea03f09b | ||
|
|
73f0b88997 | ||
|
|
4b910f19bf | ||
|
|
f4c5e214f9 | ||
|
|
60cb8303ed | ||
|
|
c13aff9f2b | ||
|
|
1720f2274d | ||
|
|
a2deaffe0b | ||
|
|
fb84d82c84 | ||
|
|
5714fab42e | ||
|
|
3bd190bde6 | ||
|
|
c48a106a05 | ||
|
|
2a2f7345ad | ||
|
|
b4f0657d09 | ||
|
|
83375f1ae4 | ||
|
|
fd42675d3d | ||
|
|
e3873dcdd3 | ||
|
|
e626a1003a | ||
|
|
364f44f4bf | ||
|
|
2dcc8d74d0 | ||
|
|
8bd770e343 | ||
|
|
b5b5576439 | ||
|
|
9796d599dc | ||
|
|
7aaef4d388 | ||
|
|
478f852b42 | ||
|
|
057f008ac0 | ||
|
|
8c9aea3062 | ||
|
|
c828f5cfe9 | ||
|
|
0938300477 | ||
|
|
551d53912f | ||
|
|
d56c47d55c | ||
|
|
c696f10f74 | ||
|
|
305be7a16f | ||
|
|
297d3bfe77 | ||
|
|
50c08aa556 | ||
|
|
6d30182ca8 | ||
|
|
203e5ec27b | ||
|
|
6e458fdd43 | ||
|
|
26cd6b5707 | ||
|
|
2acdf3b360 | ||
|
|
83ad2a289d | ||
|
|
44c8f28f4b | ||
|
|
666762a146 | ||
|
|
6d04b43964 | ||
|
|
66e19f690f |
@@ -2,17 +2,19 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, develop ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, develop ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
verify:
|
||||||
|
name: Node ${{ matrix.node-version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [18.x, 20.x, 22.x]
|
node-version: [20.x, 22.x, 24.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -25,5 +27,25 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Test
|
||||||
|
run: npm run test:coverage
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Verify published entry points load
|
||||||
|
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');"
|
||||||
|
node --input-type=module -e "import * as m from './dist/cereale.min.js'; if (typeof m.toInstanceSync !== 'function') throw new Error('flat bundle broken');"
|
||||||
- name: Run Demo
|
- name: Run Demo
|
||||||
run: npm 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/
|
||||||
|
# Also fails on NEW untracked files under docs/ — a plain `git diff` does not.
|
||||||
|
run: npm run check:docs-sync
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Junie Review
|
||||||
|
|
||||||
|
# Automated PR review by Junie, JetBrains' coding agent. Advisory only: it posts a
|
||||||
|
# summary and inline comments, and is deliberately not a required check — CI is what
|
||||||
|
# gates a merge, and a review that can block one on a judgement call is a review that
|
||||||
|
# gets rubber-stamped.
|
||||||
|
#
|
||||||
|
# Requires a JUNIE_API_KEY repository secret (Settings → Secrets and variables →
|
||||||
|
# Actions). Without it the action fails at the first step rather than skipping, so
|
||||||
|
# add the secret before merging this file.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
branches: [ main, develop ]
|
||||||
|
|
||||||
|
# A PR that gets three pushes in a minute should end up with one review of the final
|
||||||
|
# state, not three reviews of intermediate ones. Combined with use_single_comment
|
||||||
|
# below, each PR keeps exactly one review comment, rewritten as the diff changes.
|
||||||
|
concurrency:
|
||||||
|
group: junie-review-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
name: Junie
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Secrets are not exposed to `pull_request` runs originating from a fork, so a
|
||||||
|
# fork PR would fail on an empty API key rather than review anything. Skip those
|
||||||
|
# explicitly — a skipped job reads as "not applicable", a failed one as "broken".
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # read the diff; Junie does not push from this workflow
|
||||||
|
pull-requests: write # post the review summary and inline comments
|
||||||
|
issues: write # the PR conversation is an issue timeline to the API
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Review the pull request
|
||||||
|
# Pinned to the commit behind release v1.7.4. A tag is a mutable ref the
|
||||||
|
# publisher can repoint; only the SHA guarantees the version running is the
|
||||||
|
# version that was reviewed — this workflow handles a repo secret. Bump by
|
||||||
|
# resolving the new release's commit, not by moving the tag name alone.
|
||||||
|
uses: JetBrains/junie-github-action@c2ae82fc9fbe0eb81942ceb3d9bd3f89a6b17b95 # v1.7.4
|
||||||
|
with:
|
||||||
|
junie_api_key: ${{ secrets.JUNIE_API_KEY }}
|
||||||
|
# Built-in structured review prompt, as opposed to a free-form instruction.
|
||||||
|
prompt: "code-review"
|
||||||
|
# Update one comment across re-runs instead of appending a new one per push.
|
||||||
|
use_single_comment: "true"
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
name: Deploy Pages
|
||||||
|
|
||||||
|
# Publishes docs/ to GitHub Pages through Actions rather than by serving the branch
|
||||||
|
# directly, so the page is rebuilt from src/ and checked before it goes live instead
|
||||||
|
# of after.
|
||||||
|
#
|
||||||
|
# Triggered by CI completing on main rather than by the push itself, so a deploy
|
||||||
|
# implies the full suite passed — type-check, lint, tests, build, entry points, docs.
|
||||||
|
# A push that breaks a test turns main red and never reaches Pages. workflow_dispatch
|
||||||
|
# stays as the manual escape hatch, and the deploy job refuses any ref that is not main.
|
||||||
|
#
|
||||||
|
# workflow_run cannot carry a `paths:` filter the way `push:` can, so the docs-changed
|
||||||
|
# question is asked in a job instead — see the `changes` job below.
|
||||||
|
#
|
||||||
|
# REQUIRES A ONE-TIME SETTING. Settings → Pages → Build and deployment → Source must
|
||||||
|
# be "GitHub Actions", not "Deploy from a branch". Until it is, the first run fails —
|
||||||
|
# in the build job at configure-pages if Pages was never enabled, or in the deploy
|
||||||
|
# job with "Resource not accessible by integration" if Pages still serves a branch.
|
||||||
|
# Either way the workflow is correct; the repository setting is what needs to move.
|
||||||
|
# The switch cannot be made from here: it needs administration:write, which
|
||||||
|
# GITHUB_TOKEN is not. It is reversible — setting Source back to a branch restores
|
||||||
|
# the old behaviour and this workflow simply stops being able to deploy.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [ CI ]
|
||||||
|
types: [ completed ]
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never cancel a deploy in flight: a half-published site is worse than a stale one.
|
||||||
|
# Queue instead, so the last push wins without interrupting the one already going out.
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
name: Did docs change?
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# workflow_run fires on failure too — deploying is the one thing that must not.
|
||||||
|
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: read
|
||||||
|
outputs:
|
||||||
|
docs: ${{ steps.check.outputs.docs }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# For workflow_run the default checkout is the branch tip, which is not
|
||||||
|
# necessarily the commit CI just validated — two pushes in quick succession
|
||||||
|
# would deploy the newer one under the older one's green tick. Empty on
|
||||||
|
# workflow_dispatch, where the dispatched ref is what we want.
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
# Deep enough to reach whatever commit is currently live.
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: check
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "Manual dispatch — deploying whatever the state of docs/ is."
|
||||||
|
echo "docs=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
api() { curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$@"; }
|
||||||
|
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
|
||||||
|
|
||||||
|
# What is actually live: the commit behind the newest *successful* Pages
|
||||||
|
# deployment. Comparing against that, rather than against HEAD^, is what keeps
|
||||||
|
# this correct when a push carries several commits (the change may be in any of
|
||||||
|
# them) and when the last deploy failed (the site is then a version further
|
||||||
|
# behind than the previous commit suggests).
|
||||||
|
DEPLOYMENTS=$(api "$BASE/deployments?environment=github-pages&per_page=10")
|
||||||
|
LIVE=""
|
||||||
|
for id in $(echo "$DEPLOYMENTS" | jq -r '.[].id'); do
|
||||||
|
state=$(api "$BASE/deployments/$id/statuses?per_page=1" | jq -r '.[0].state // empty')
|
||||||
|
if [ "$state" = "success" ]; then
|
||||||
|
LIVE=$(echo "$DEPLOYMENTS" | jq -r --argjson id "$id" '.[] | select(.id == $id) | .sha')
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$LIVE" ]; then
|
||||||
|
echo "No successful github-pages deployment to compare against — deploying."
|
||||||
|
echo "docs=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git cat-file -e "${LIVE}^{commit}" 2>/dev/null; then
|
||||||
|
echo "Live commit $LIVE is not in this history (force push, or rebuilt branch) — deploying."
|
||||||
|
echo "docs=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Live on Pages: $LIVE"
|
||||||
|
echo "This commit: $GITHUB_SHA"
|
||||||
|
if git diff --quiet "$LIVE" HEAD -- docs/; then
|
||||||
|
echo "docs/ is byte-identical to what is already published — nothing to deploy."
|
||||||
|
echo "docs=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "docs/ changed:"
|
||||||
|
git diff --name-only "$LIVE" HEAD -- docs/ | sed 's/^/ /'
|
||||||
|
echo "docs=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build and check
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.docs == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# This job runs third-party code (npm postinstall scripts, the build toolchain),
|
||||||
|
# so it gets read-only. The Pages/OIDC grants live on the deploy job alone.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: The committed page is in sync with src/
|
||||||
|
# Rebuilds from src/ and fails on any difference, new untracked files included.
|
||||||
|
# Same script CI runs, so the two workflows cannot drift apart.
|
||||||
|
run: npm run check:docs-sync
|
||||||
|
- name: The page loads nothing from the network
|
||||||
|
run: npm run check:docs
|
||||||
|
- uses: actions/configure-pages@v5
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./docs
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# workflow_dispatch can be pointed at any branch; production only ever serves main.
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v5
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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.
|
||||||
|
#
|
||||||
|
# Authentication is by **trusted publishing** (OIDC): npm exchanges the workflow's
|
||||||
|
# short-lived GitHub identity token for a publish token scoped to this package, so no
|
||||||
|
# long-lived npm token has to exist. To enable it, on npmjs.com → the cereale package →
|
||||||
|
# Settings → Trusted publisher → GitHub Actions, set:
|
||||||
|
#
|
||||||
|
# Organization or user: avalon-vanguard
|
||||||
|
# Repository: cereale
|
||||||
|
# Workflow filename: release.yml
|
||||||
|
# Environment: (leave blank — this workflow does not use one)
|
||||||
|
#
|
||||||
|
# The workflow filename must match this file's name, and the Environment field must be
|
||||||
|
# blank unless a matching `environment:` is added to the publish job; npm checks both
|
||||||
|
# against the OIDC claims and refuses the exchange if either disagrees.
|
||||||
|
#
|
||||||
|
# NPM_TOKEN is kept as a fallback. npm's OIDC step is deliberately non-throwing — if the
|
||||||
|
# trusted publisher is not configured, or the CLI is too old, it logs and falls through
|
||||||
|
# to token auth. That makes the switch safe to land before the registry side is set up.
|
||||||
|
# Once a real run shows OIDC working, the NPM_TOKEN secret can be deleted.
|
||||||
|
#
|
||||||
|
# Before a real run:
|
||||||
|
# 1. Run once with dry_run left as `true` and read the file list it prints.
|
||||||
|
# 2. 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 # the OIDC identity npm exchanges, and 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'
|
||||||
|
|
||||||
|
# Node 22 bundles npm 10.x, which has no OIDC support at all — it would skip
|
||||||
|
# trusted publishing in silence and fall back to token auth. Trusted publishing
|
||||||
|
# landed in npm 11.5.0 (lib/utils/oidc.js), so the version is raised and then
|
||||||
|
# asserted rather than assumed.
|
||||||
|
- name: Use an npm that can do trusted publishing
|
||||||
|
run: |
|
||||||
|
npm install -g npm@latest
|
||||||
|
V=$(npm --version)
|
||||||
|
echo "npm $V"
|
||||||
|
MAJ=${V%%.*}; REST=${V#*.}; MIN=${REST%%.*}
|
||||||
|
if [ "$MAJ" -lt 11 ] || { [ "$MAJ" -eq 11 ] && [ "$MIN" -lt 5 ]; }; then
|
||||||
|
echo "::error::npm $V has no OIDC support; trusted publishing needs >= 11.5.0"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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
|
||||||
|
# `npm publish --dry-run` performs the OIDC token exchange before it short-circuits
|
||||||
|
# (publish.js calls oidc() ahead of every dryRun branch), so this step is also the
|
||||||
|
# trusted-publishing smoke test — a dry run proves the exchange without publishing.
|
||||||
|
#
|
||||||
|
# verbose, because npm's OIDC step is non-throwing: at the default log level a
|
||||||
|
# successful exchange and a silent fallback to token auth look identical. Success
|
||||||
|
# prints `oidc Successfully retrieved and set token`; if that line is missing,
|
||||||
|
# trusted publishing did not engage. (The reasons it skips are logged at silly.)
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_LOGLEVEL: verbose
|
||||||
|
run: npm publish --dry-run
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
if: ${{ inputs.dry_run == false }}
|
||||||
|
# --provenance stays explicit. Under OIDC npm would enable it on its own for a
|
||||||
|
# public repo, but only when the flag was left at its default; asking for it
|
||||||
|
# directly just skips that auto-enable and reaches the same place. On the token
|
||||||
|
# fallback path it is the only thing that produces an attestation at all.
|
||||||
|
run: npm publish --provenance --access public
|
||||||
|
env:
|
||||||
|
# Fallback only. Ignored once the trusted publisher is configured, because npm
|
||||||
|
# sets its own short-lived token before reading credentials.
|
||||||
|
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"
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
# Node modules and dependency files
|
# Node modules and dependency files
|
||||||
|
# NOTE: package-lock.json is intentionally committed — CI installs with `npm ci`,
|
||||||
|
# which requires a lockfile to be present in the repository.
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/package-lock.json
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
/dist/
|
/dist/
|
||||||
|
|||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
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.4.1] - 2026-08-20
|
||||||
|
|
||||||
|
No code changes. Every emitted file is byte-identical to 0.4.0 except the banner line of
|
||||||
|
`dist/cereale.min.js`, which carries the version string. This exists because the README
|
||||||
|
inside the 0.4.0 tarball is the one npmjs.com renders, and it said the package was not on
|
||||||
|
npm: *"`npm install cereale` does not resolve to this library — the name is unclaimed on
|
||||||
|
the registry."* True when it was written, nonsense on the package page of the thing it
|
||||||
|
describes.
|
||||||
|
|
||||||
|
0.4.0 was the first publish, so nothing could have carried the corrected text: the docs
|
||||||
|
could only be fixed after the registry proved the claim wrong. The install instructions,
|
||||||
|
the landing page panel, and the "what cereale is not" entry now say `npm install cereale`,
|
||||||
|
and the README carries an npm version badge.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-05
|
||||||
|
|
||||||
|
### `cereale/min` — one file, no bundler
|
||||||
|
|
||||||
|
The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
|
||||||
|
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
|
||||||
|
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
|
||||||
|
flattens and minifies.
|
||||||
|
|
||||||
|
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
|
||||||
|
readable stack traces for anyone not loading source maps, and it is what a bundler should be
|
||||||
|
given.
|
||||||
|
|
||||||
|
The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
|
||||||
|
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
|
||||||
|
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
|
||||||
|
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
|
||||||
|
annotations costs about a kilobyte gzipped and is asserted by the build.
|
||||||
|
|
||||||
|
### Tree-shaking
|
||||||
|
|
||||||
|
Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of
|
||||||
|
1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing
|
||||||
|
warned; the library was simply about three times heavier than it needed to be in every
|
||||||
|
consumer's bundle.
|
||||||
|
|
||||||
|
The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup
|
||||||
|
proves such a call side-effect-free by reading the factory, which is why rollup was already
|
||||||
|
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
|
||||||
|
webpack will not do that analysis, and keep the call. Thirty declarations now carry
|
||||||
|
`/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
|
||||||
|
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
|
||||||
|
which is ordinary bundler variation rather than anything left unshaken.
|
||||||
|
|
||||||
|
Measured, minified, across esbuild / rollup / webpack:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| a typical DTO | 10,395 | 10,402 | 10,360 |
|
||||||
|
| everything | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
The serializer and deserializer drop independently. The validator is kept by both mapping
|
||||||
|
entry points because `validate` defaults to `true`, which is a real reference rather than a
|
||||||
|
missed optimisation.
|
||||||
|
|
||||||
|
`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it
|
||||||
|
names the rules that must not appear — and one case asserts that everything IS present when
|
||||||
|
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
|
||||||
|
the annotations and it fails with `"must be a latitude" should have been shaken out`.
|
||||||
|
|
||||||
|
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
|
||||||
|
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
|
||||||
|
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
|
||||||
|
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
|
||||||
|
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
|
||||||
|
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
|
||||||
|
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
|
||||||
|
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
|
||||||
|
decorated class gets `metadata: undefined`, which is to say no rules at all.
|
||||||
|
|
||||||
|
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
|
||||||
|
reach nothing else; every row of the table above except the first was byte-identical before and
|
||||||
|
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
|
||||||
|
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
|
||||||
|
invisible from the other side.
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
|
||||||
|
with the versions and date it was verified against.
|
||||||
|
|
||||||
|
The finding worth stating first: **Angular works**. The CLI scaffolds
|
||||||
|
`"experimentalDecorators": true`, but Angular does not need it — `ngtsc` erases `@Component`
|
||||||
|
and `@Injectable` into static properties rather than relying on TypeScript's decorator emit.
|
||||||
|
Flip the flag and both systems work in one program. Verified with `ngc` on Angular 21.2 with
|
||||||
|
`strictTemplates`: templates still type-check, and a wrong cereale rule is still a compile
|
||||||
|
error inside the Angular build.
|
||||||
|
|
||||||
|
**Next.js cannot work inline**, and the reason is structural rather than a missing option. It
|
||||||
|
derives *both* the SWC parser's decorator support and the transform mode from the single
|
||||||
|
`experimentalDecorators` flag, so the flag on gives legacy emit that cereale refuses, and the
|
||||||
|
flag off makes `@` a syntax error. There is no third setting.
|
||||||
|
|
||||||
|
**NestJS cannot work inline** either: its dependency injection genuinely needs the
|
||||||
|
`design:type` metadata only `emitDecoratorMetadata` produces.
|
||||||
|
|
||||||
|
Both have the same answer, and it is better than it sounds: put the cereale classes in a
|
||||||
|
package compiled by `tsc` and import the built output. The decorators run at class-definition
|
||||||
|
time inside that package, so the app only ever sees plain JavaScript and its own decorator
|
||||||
|
setting stops mattering. Verified inside a program with **both** legacy flags on, running
|
||||||
|
alongside `@Injectable()` — mapping and validation work normally, and the compile-time
|
||||||
|
guarantee still holds where the rules are written.
|
||||||
|
|
||||||
|
Also verified: **Bun** 1.3 needs no configuration at all, and a real Vite 8 build with the
|
||||||
|
`cereale/vite` plugin produces working output where the same build without it silently leaves
|
||||||
|
decorator syntax in the bundle.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
`FRAMEWORKS.md` ships with the package. `sideEffects` now lists the flat bundle, which inlines
|
||||||
|
the `Symbol.metadata` install.
|
||||||
|
|
||||||
|
### Why 0.4.0 and not 0.3.1
|
||||||
|
|
||||||
|
`cereale/min` is a new public entry point, which is a minor bump under 0.x. It also keeps the
|
||||||
|
existing `v0.3.0` tag meaningful instead of force-moving it onto a commit it was never cut
|
||||||
|
from.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Synchronous API.** `validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`,
|
||||||
|
`toInstanceSync`, `toInstanceArraySync`, `fromJsonSync` and `fromJsonArraySync`. Nothing on
|
||||||
|
the default path is genuinely asynchronous — only a user-supplied serializer, deserializer or
|
||||||
|
validator can be — so requiring `await` everywhere was a tax on the common case.
|
||||||
|
|
||||||
|
The engines are now written synchronously, and anything a hook makes asynchronous is recorded
|
||||||
|
and reconciled once at the end. There is no second copy of the traversal logic to keep in
|
||||||
|
step, and the async entry points stop paying for a microtask per property. If a hook does
|
||||||
|
return a Promise, the `*Sync` call raises a `JsonMappingError` naming the async alternative
|
||||||
|
rather than silently returning a half-built object.
|
||||||
|
|
||||||
|
`fromRequest` has no synchronous counterpart, because reading a request body is inherently
|
||||||
|
asynchronous.
|
||||||
|
|
||||||
|
- `maxDepth` option (default 64) on every mapping function and on `configure()`. All three
|
||||||
|
engines recurse, so a hostile payload nested thousands of levels deep could exhaust the
|
||||||
|
call stack; it now raises a `JsonMappingError`. Cycles were already handled, but legitimate
|
||||||
|
deep nesting was not bounded.
|
||||||
|
- `validate(obj, options?)` accepts options, so `maxDepth` applies to standalone validation.
|
||||||
|
- `REDACTED` export, the placeholder substituted for withheld values.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Validation errors no longer carry the value of a property that is never serialized.**
|
||||||
|
A `@JsonWriteOnly` password that failed `@MinLength` put the rejected password into
|
||||||
|
`ValidationError.value`, and from there into any log that recorded the error. Values for
|
||||||
|
`@JsonWriteOnly` and `@JsonIgnore` properties are replaced with `REDACTED`; the property
|
||||||
|
name and the failure message are unchanged, so the error is still actionable.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Profiling the validator showed roughly **half of all validation time** was spent re-deriving
|
||||||
|
answers that cannot change: `collectConstraints` (22%), `getOwnMetadata` (12%),
|
||||||
|
`getMetadataChain` (9%), `getProperties` (4%) and `getMetadata` (3%), plus 8% garbage
|
||||||
|
collection from the allocation churn. The constraint predicates themselves accounted for
|
||||||
|
under 1%.
|
||||||
|
|
||||||
|
Decorator metadata is fixed once classes are declared, so the derived structures are now
|
||||||
|
memoized per prototype — the validation plan, the serialization plan, the deserialization
|
||||||
|
plan, and serializer/deserializer instances (previously constructed fresh for every property
|
||||||
|
of every object). `MetadataStorage` carries a version counter that invalidates every cache
|
||||||
|
if metadata is registered late, so `registerDecorator` after first use still works.
|
||||||
|
|
||||||
|
Together with the synchronous core, measured on a customer record with a nested address and
|
||||||
|
orders, against `JSON.parse` + `JSON.stringify` (5.8 us) as a fixed reference point:
|
||||||
|
|
||||||
|
| Operation | 0.1.0 | Now | Speedup |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `validate` (50 orders) | 221.6 us | 17.8 us | 12.4x |
|
||||||
|
| `validate` (10 orders) | 47.8 us | 4.5 us | 10.6x |
|
||||||
|
| `toPlain` (50 orders) | 294.4 us | 36.0 us | 8.2x |
|
||||||
|
| `toInstance` (50 orders) | 255.1 us | 31.7 us | 8.0x |
|
||||||
|
| `toInstance` (10 orders) | 64.6 us | 8.2 us | 7.9x |
|
||||||
|
| `toInstance` (single) | 19.8 us | 5.2 us | 3.8x |
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `each: true` failures now report which element failed — `"... (failed at index 3)"`. A bad
|
||||||
|
entry in a 200-item array previously produced a message that could not locate it. A message
|
||||||
|
function now receives the failing element as `args.value` rather than the whole array;
|
||||||
|
caller-supplied string messages are still reported verbatim.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-08-03
|
||||||
|
|
||||||
|
The first release with a working test suite. Everything below the "Fixed" heading was
|
||||||
|
found by writing tests against the previous release; the suite has grown from 40 tests
|
||||||
|
that never executed to 136 that do.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Field-name mapping.** A library whose headline feature is "JSON mapping" could not map
|
||||||
|
a name. It can now.
|
||||||
|
|
||||||
|
- `@JsonProperty(name)` renames a property in both directions.
|
||||||
|
- `@JsonAlias(...names)` accepts extra names on input only, so a field can be renamed
|
||||||
|
without breaking older clients.
|
||||||
|
- Naming strategies — `snake_case`, `kebab-case`, `SCREAMING_SNAKE_CASE`, `PascalCase`,
|
||||||
|
`camelCase`, or your own function — applied to properties with no explicit name.
|
||||||
|
Acronyms split where a reader expects: `parseHTTPResponse` → `parse_http_response`.
|
||||||
|
|
||||||
|
**Access control.**
|
||||||
|
|
||||||
|
- `@JsonIgnore()` — excluded in both directions.
|
||||||
|
- `@JsonWriteOnly()` — accepted from input, never echoed back (passwords).
|
||||||
|
- `@JsonReadOnly()` — serialized, never settable by a client (server-owned ids).
|
||||||
|
|
||||||
|
**Transform options**, per call or globally via `configure()`.
|
||||||
|
|
||||||
|
- `validate: false` maps without validating, for lenient parsing.
|
||||||
|
- `unknownKeys: 'allow' | 'strip' | 'error'` decides what happens to undeclared keys.
|
||||||
|
- `namingStrategy` selects the JSON naming convention.
|
||||||
|
|
||||||
|
**Error ergonomics.** Turning the nested `ValidationError` tree into an HTTP 400 body used
|
||||||
|
to be the caller's problem.
|
||||||
|
|
||||||
|
- `flattenErrors(errors)` → `{ "items[0].qty": ["qty must be at least 1"] }`
|
||||||
|
- `formatErrors(errors)` → one human-readable line per failure
|
||||||
|
- `collectErrorMessages(errors)` → just the messages
|
||||||
|
- `validateOrReject(obj)` throws instead of returning an array you might forget to check
|
||||||
|
|
||||||
|
**30 validation decorators.** `@Equals`, `@NotEquals`, `@IsEmpty`, `@IsEnum`, `@IsInstance`,
|
||||||
|
`@Length`, `@IsAlpha`, `@IsAlphanumeric`, `@IsNumberString`, `@IsLowercase`, `@IsUppercase`,
|
||||||
|
`@Contains`, `@NotContains`, `@StartsWith`, `@EndsWith`, `@IsUUID`, `@IsJSON`,
|
||||||
|
`@IsDateString`, `@IsSemVer`, `@IsHexColor`, `@IsIP`, `@IsDivisibleBy`, `@IsPort`,
|
||||||
|
`@IsLatitude`, `@IsLongitude`, `@IsBigInt`, `@MinDate`, `@MaxDate`, `@ArrayUnique`,
|
||||||
|
`@ArrayContains`, `@ArrayNotContains`.
|
||||||
|
|
||||||
|
**Conditional validation.** `@ValidateIf(o => ...)` makes a property's rules depend on the
|
||||||
|
rest of the object; `@Allow()` declares a property that needs no rules of its own.
|
||||||
|
|
||||||
|
**Correctly typed array entry points.** `toInstanceArray()` and `fromJsonArray()`.
|
||||||
|
`toInstance`/`fromJson` accept arrays at runtime but type the result as `T`, so callers had
|
||||||
|
to cast to reach the elements.
|
||||||
|
|
||||||
|
**`@JsonPolymorphic` options.** `{ onUnknown: 'error' }` and `{ fallback: SomeClass }`.
|
||||||
|
|
||||||
|
**`JsonMappingError`** — raised when a value cannot be mapped at all, as distinct from
|
||||||
|
mapping fine and failing validation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Inheritance silently discarded base-class rules.** A subclass re-decorating an inherited
|
||||||
|
property registered its constraints against its own prototype, and the engine read only the
|
||||||
|
nearest set. Constraints now merge down the whole prototype chain, base first. The
|
||||||
|
library's own example was affected: `Media`'s `@IsString() title` had never been enforced
|
||||||
|
for `Book`.
|
||||||
|
- **A circular reference exhausted the heap.** `serialize()` recursed forever, taking 8 GB
|
||||||
|
and the process with it. It now raises a `JsonMappingError` naming the cause. Diamonds
|
||||||
|
still serialize; `validate()` skips back-edges.
|
||||||
|
- **`@Matches` with a `g` or `y` flag was stateful.** `RegExp.test` advances `lastIndex`, so
|
||||||
|
validating the same value twice gave different answers. Those flags are stripped.
|
||||||
|
- **An unmatched `@JsonPolymorphic` discriminator silently dropped the value.** The
|
||||||
|
single-object branch fell through without assigning; the property came back `undefined`.
|
||||||
|
The raw value is now preserved.
|
||||||
|
- **`@JsonSerialize` serializers ran on `null`/`undefined`**, crashing on any unset optional
|
||||||
|
property. They now only see real values.
|
||||||
|
- **`serialize()` crashed on null-prototype objects.** It read `obj.constructor.prototype`;
|
||||||
|
both engines now agree on `Object.getPrototypeOf`.
|
||||||
|
- **`__proto__`, `constructor` and `prototype` in untrusted JSON** were copied onto the
|
||||||
|
instance, detaching it from its own class. They are dropped.
|
||||||
|
- **Caller-supplied messages were mangled** by the `each element in ...` prefix, producing
|
||||||
|
sentences like "each element in tags must all be strings".
|
||||||
|
- **Two rules sharing a name overwrote each other**, so only one failure was ever reported.
|
||||||
|
- **`fromRequest` leaked a raw `SyntaxError`** for a non-JSON body; it now reports a
|
||||||
|
`JsonMappingError`.
|
||||||
|
- **`@ValidateNested({ each: true })`** was documented in the README but did not compile —
|
||||||
|
`ValidateNested()` accepted no arguments. It now does, and asserts the value is an array.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `toPlain`, `toJson`, `toInstance`, `fromJson`, `fromJsonArray`, `toInstanceArray` and
|
||||||
|
`fromRequest` accept an optional trailing options argument. All defaults preserve the
|
||||||
|
previous behaviour.
|
||||||
|
- `src/example.ts` is no longer published in `dist`. It called `runExample()` at import
|
||||||
|
time — an import side effect in a package declaring `"sideEffects": false`.
|
||||||
|
- Minimum supported Node is 20.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- **The test suite had never run.** Vitest 4 transpiles with oxc, which does not read
|
||||||
|
`experimentalDecorators` from a tsconfig that excludes the files it is transforming, so
|
||||||
|
every decorator-using suite failed to parse and was reported as "0 test" rather than as an
|
||||||
|
error. A `vitest.config.ts` enabling legacy decorators brought all 40 existing tests back
|
||||||
|
to life.
|
||||||
|
- Test files are now type-checked, which surfaced 17 strict-mode errors.
|
||||||
|
- CI runs lint, coverage tests, build and ESM/CJS entry-point smoke checks across Node
|
||||||
|
20/22/24, and `npm ci` works because `package-lock.json` is committed.
|
||||||
|
- `npm run build:docs` regenerates the previously hand-maintained `docs/cereale.js`.
|
||||||
|
|
||||||
|
## [0.0.1]
|
||||||
|
|
||||||
|
Initial release: mapping and validation decorators, polymorphic types, custom
|
||||||
|
serializers/deserializers, and the `toJson` / `fromJson` / `toPlain` / `toInstance` API.
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
# Using cereale with your framework
|
||||||
|
|
||||||
|
Every recipe here was run before it was written. Where something does not work, this says so
|
||||||
|
rather than offering a workaround that has not been tried.
|
||||||
|
|
||||||
|
## The only requirement
|
||||||
|
|
||||||
|
cereale reads the metadata that a **TC39 standard** decorator transform emits. That means your
|
||||||
|
compiler must have `experimentalDecorators` **off** — which is the default in TypeScript 5.0
|
||||||
|
and later, but not what several frameworks scaffold.
|
||||||
|
|
||||||
|
There is no partial credit and no per-file override: `experimentalDecorators` is a property of
|
||||||
|
a TypeScript *program*, so every decorator in one compilation uses the same system. If the flag
|
||||||
|
is on, `tsc` refuses cereale's decorators outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
error TS1240: Unable to resolve signature of property decorator when called as an expression.
|
||||||
|
Argument of type 'User' is not assignable to parameter of type 'undefined'.
|
||||||
|
```
|
||||||
|
|
||||||
|
and if you skip type-checking (esbuild, swc and Babel all strip types without checking them),
|
||||||
|
the emit reaches cereale as a legacy call and it says so by name rather than failing obscurely.
|
||||||
|
|
||||||
|
## Two ways to adopt it
|
||||||
|
|
||||||
|
**Inline** — write cereale decorators directly in your app. Needs `experimentalDecorators: false`.
|
||||||
|
This is what you want, and most toolchains allow it.
|
||||||
|
|
||||||
|
**A precompiled models package** — when the program is committed to legacy decorators for
|
||||||
|
something else (NestJS's DI, TypeORM entities, Next's SWC pipeline), put your cereale classes in
|
||||||
|
a separate package compiled by `tsc`, and import the built output. The decorators run at class
|
||||||
|
definition time inside that package; your app only ever sees plain JavaScript, so its own
|
||||||
|
decorator setting is irrelevant. Verified working inside a program with **both**
|
||||||
|
`experimentalDecorators: true` and `emitDecoratorMetadata: true`.
|
||||||
|
|
||||||
|
You keep the compile-time guarantee where it matters — in the package where the rules are
|
||||||
|
written — and lose nothing at runtime.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Verified on the versions listed, on 2026-08-05. ✅ inline, ⚠️ via a precompiled package.
|
||||||
|
Rows marked — were not tested; they are listed so their absence is not mistaken for a verdict.
|
||||||
|
|
||||||
|
| Toolchain | | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tsc` 5.2+ | ✅ | `experimentalDecorators: false`, `target: ES2022`+ |
|
||||||
|
| **Angular** 21 | ✅ | flip the scaffolded `experimentalDecorators` to `false` — Angular does not need it |
|
||||||
|
| **Vite** 8 | ✅ | add `cereale/vite`; covers React, Vue, Svelte, Solid, Qwik, Astro, Nuxt, SvelteKit |
|
||||||
|
| **Vitest** 4 | ✅ | same plugin |
|
||||||
|
| **Bun** 1.3 | ✅ | works with no configuration |
|
||||||
|
| esbuild 0.25 | ✅ | top-level `target: es2022`+ **and** `experimentalDecorators: false` |
|
||||||
|
| swc 1.15 | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||||
|
| **Next.js** 16 | ⚠️ | no inline support — see below |
|
||||||
|
| **NestJS** 11.1 | ⚠️ | its DI needs `emitDecoratorMetadata` — see below |
|
||||||
|
| Deno | — | untested |
|
||||||
|
| webpack + `ts-loader` | — | untested; follows whatever `tsc` is configured to do |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Angular
|
||||||
|
|
||||||
|
The surprise is that Angular works. The CLI scaffolds `"experimentalDecorators": true`, but
|
||||||
|
Angular's compiler does not need it — `ngtsc` erases `@Component` and `@Injectable` into static
|
||||||
|
properties itself, rather than relying on TypeScript's decorator emit. Turn the flag off and
|
||||||
|
both work in the same program.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": false, // was true; Angular does not need it
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// user.dto.ts
|
||||||
|
import { IsString, MinLength, IsInt, Min, JsonProperty } from 'cereale';
|
||||||
|
|
||||||
|
export class UserDto {
|
||||||
|
@JsonProperty('display_name')
|
||||||
|
@IsString() @MinLength(3)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsInt() @Min(18)
|
||||||
|
age!: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// user.service.ts
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { toInstanceSync } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
load(id: string) {
|
||||||
|
return this.http.get(`/api/users/${id}`).pipe(map(body => toInstanceSync(UserDto, body)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified with `ngc` on Angular 21.2 and `strictTemplates: true`: the component's template is
|
||||||
|
still type-checked, and a wrong cereale rule is still a compile error inside the Angular build —
|
||||||
|
`@IsString()` on `age!: number` fails with TS1240 exactly as it does anywhere else.
|
||||||
|
|
||||||
|
## Any Vite app — React, Vue, Svelte, Solid, Astro, Nuxt, SvelteKit
|
||||||
|
|
||||||
|
Vite 8 transforms with oxc, which does not implement the standard decorator transform **and does
|
||||||
|
not report that**. `vite build` succeeds and leaves the decorator syntax in the bundle, which
|
||||||
|
throws the moment anything imports it. cereale ships the plugin that fixes it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react'; // or vue(), svelte(), solid()…
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [standardDecorators(), react()],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// UserForm.tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toInstanceSync, validateSync, flattenErrors } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto'; // a .ts file, not .tsx — see below
|
||||||
|
|
||||||
|
export function UserForm() {
|
||||||
|
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
function onSubmit(form: FormData) {
|
||||||
|
const draft = toInstanceSync(UserDto, Object.fromEntries(form), { validate: false });
|
||||||
|
setErrors(flattenErrors(validateSync(draft)));
|
||||||
|
}
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin transforms `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded by
|
||||||
|
default, because lowering decorators there means also deciding what happens to the JSX — keep
|
||||||
|
decorated classes in `.ts` files, or pass an `include` of your own:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
standardDecorators({ include: (id) => /\.[cm]?tsx?$/.test(id) && !id.includes('/node_modules/') })
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `include`, `target` (default `es2022`), and `transformer` (`'auto'` prefers esbuild and
|
||||||
|
falls back to the TypeScript compiler; cereale depends on neither).
|
||||||
|
|
||||||
|
## Vitest
|
||||||
|
|
||||||
|
Same plugin, same reason — Vitest 4 uses the same oxc pipeline, and without it prints `0 test`
|
||||||
|
next to a bare `SyntaxError`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vitest.config.ts
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [standardDecorators()] });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node, with tsc
|
||||||
|
|
||||||
|
Nothing to configure beyond the flag.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import express from 'express';
|
||||||
|
import { fromJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto.js';
|
||||||
|
|
||||||
|
app.post('/users', (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = fromJsonSync(UserDto, JSON.stringify(req.body));
|
||||||
|
return res.status(201).json(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JsonValidationError) {
|
||||||
|
return res.status(400).json({ errors: flattenErrors(error.errors) });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bun
|
||||||
|
|
||||||
|
Works with no configuration — Bun's transpiler emits standard decorators when
|
||||||
|
`experimentalDecorators` is off, which is its default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add cereale
|
||||||
|
bun run app.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next.js
|
||||||
|
|
||||||
|
**Not supported inline.** Next derives *both* the SWC parser's decorator support and its
|
||||||
|
transform mode from the single `experimentalDecorators` flag:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const enableDecorators = Boolean(jsConfig?.compilerOptions?.experimentalDecorators);
|
||||||
|
// parser: decorators: enableDecorators
|
||||||
|
// transform: legacyDecorator: enableDecorators
|
||||||
|
```
|
||||||
|
|
||||||
|
So the flag on gives legacy emit that cereale refuses, and the flag off makes `@` a syntax
|
||||||
|
error. There is no third setting, and no exposed `decoratorVersion` option.
|
||||||
|
|
||||||
|
Use a precompiled models package instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
repo/
|
||||||
|
models/ ← compiled by tsc, experimentalDecorators: false
|
||||||
|
package.json { "name": "@acme/models", "exports": { ".": "./dist/index.js" } }
|
||||||
|
tsconfig.json
|
||||||
|
src/user.dto.ts ← your cereale classes live here
|
||||||
|
web/ ← the Next app, untouched
|
||||||
|
package.json { "dependencies": { "@acme/models": "workspace:*" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// models/tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build `models` before `web`. Next only ever sees the compiled JavaScript — no decorator syntax
|
||||||
|
reaches its parser — and mapping and validation work normally. Verified against Next 16.3's own
|
||||||
|
SWC configuration.
|
||||||
|
|
||||||
|
## NestJS
|
||||||
|
|
||||||
|
**Not supported inline.** Nest scaffolds `experimentalDecorators: true` *and*
|
||||||
|
`emitDecoratorMetadata: true`, and its dependency injection genuinely needs the `design:type`
|
||||||
|
metadata that only legacy decorators emit. Turning the flag off breaks Nest.
|
||||||
|
|
||||||
|
The precompiled models package above works here too, and this is the case that proves the
|
||||||
|
pattern: a program compiled with **both** legacy flags on can import cereale DTOs from a
|
||||||
|
precompiled package and validate with them normally, alongside its own `@Injectable()` and
|
||||||
|
`@Inject()` decorators.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { UserDto } from '@acme/models'; // precompiled
|
||||||
|
import { toInstanceSync, validateSync, formatErrors } from 'cereale';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
create(body: unknown) {
|
||||||
|
const draft = toInstanceSync(UserDto, body, { validate: false });
|
||||||
|
const errors = validateSync(draft);
|
||||||
|
if (errors.length) throw new BadRequestException(formatErrors(errors));
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you would rather not split the package, stay on class-validator for now — Nest's own
|
||||||
|
`ValidationPipe` is built around it, and cereale does not try to replace that integration.
|
||||||
|
|
||||||
|
## Other bundlers
|
||||||
|
|
||||||
|
**esbuild** needs its own top-level `target`, not one inside `tsconfigRaw`. A `target` in
|
||||||
|
`tsconfigRaw` sets the `useDefineForClassFields` default and nothing else, so the decorators
|
||||||
|
are left in the output:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await esbuild.build({
|
||||||
|
target: 'es2022', // ← required, and top-level
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**swc** spells the choice as a proposal date:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// .swcrc
|
||||||
|
{
|
||||||
|
"jsc": {
|
||||||
|
"parser": { "syntax": "typescript", "decorators": true },
|
||||||
|
"transform": { "decoratorVersion": "2022-03" },
|
||||||
|
"target": "es2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## A single file, no bundler
|
||||||
|
|
||||||
|
`cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
|
||||||
|
gzipped) for import maps, `<script type="module">`, Deno and Workers.
|
||||||
|
|
||||||
|
**If you are using a bundler, prefer the default entry.** The flat file keeps its
|
||||||
|
`/*#__PURE__*/` annotations, so a bundler can still drop the rules you did not import — pinned
|
||||||
|
by `src/treeshake.test.ts` — but the per-module build shakes slightly leaner (1,837 bytes
|
||||||
|
against 1,996 for one decorator through esbuild) and is the canonical route. See
|
||||||
|
[Bundle size](README.md#bundle-size).
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -1,15 +1,82 @@
|
|||||||
# Cereale
|
# Cereale
|
||||||
|
|
||||||
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.
|
[](https://www.npmjs.com/package/cereale)
|
||||||
|
[](https://github.com/avalon-vanguard/cereale/actions/workflows/ci.yml)
|
||||||
|
[](https://avalon-vanguard.github.io/cereale/)
|
||||||
|
[](https://github.com/avalon-vanguard/cereale/blob/main/package.json)
|
||||||
|
[](https://github.com/avalon-vanguard/cereale#installation)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
**Validated domain objects, not validated data.**
|
||||||
|
|
||||||
|
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 has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
|
||||||
|
fetches is its own vendored compiler, and only when you first press Run. It deploys from
|
||||||
|
`docs/` on `main` through Actions once CI is green, 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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Spring-like Decorators:** Familiar `@JsonSerialize`, `@JsonDeserialize`, `@JsonType`, and `@JsonPolymorphic`.
|
- **Strongly typed decorators:** a rule that does not fit its field is a compile error.
|
||||||
- **Custom Serializers/Deserializers:** Easily handle complex types like Dates, BigInts, or custom objects.
|
- **Real instances:** nested objects, polymorphic subtypes and arrays all come back as classes.
|
||||||
- **Polymorphism Support:** Native handling of polymorphic types via discriminators.
|
- **Field-name mapping:** `@JsonProperty`, `@JsonAlias` and naming strategies, both directions.
|
||||||
- **Integrated Validation:** Automatically validates objects during serialization and deserialization.
|
- **Access control:** keep passwords out of responses and server-owned ids out of requests.
|
||||||
- **Type Safety:** Fully written in TypeScript for excellent developer experience.
|
- **Nothing fails quietly:** a misconfigured compiler, a cycle, or a value JSON cannot carry
|
||||||
- **Zero Dependencies:** Extremely lightweight and fast.
|
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+.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -17,110 +84,146 @@ Cereale is a lightweight TypeScript library that provides Spring-like decorators
|
|||||||
npm install cereale
|
npm install cereale
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure to enable `experimentalDecorators` and `emitDecoratorMetadata` in your `tsconfig.json`:
|
Published with [provenance](https://www.npmjs.com/package/cereale), so the registry carries a
|
||||||
|
verified attestation linking the tarball to the commit it was built from.
|
||||||
|
|
||||||
|
Cereale uses **TC39 standard decorators** (since 0.2.0), so no `experimentalDecorators` flag:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"target": "ES2022",
|
||||||
"emitDecoratorMetadata": true,
|
"lib": ["ESNext", "ESNext.Decorators"]
|
||||||
"target": "ES2025"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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` 5.2+ | ✅ | 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 |
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
**[FRAMEWORKS.md](FRAMEWORKS.md)** has a setup recipe for each, every one of them run before it
|
||||||
|
was written. The short version:
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Angular** 21 | ✅ | Flip the scaffolded `experimentalDecorators` to `false`. Angular does not need it — `ngtsc` erases its own decorators itself |
|
||||||
|
| **React, Vue, Svelte, Solid, Astro, Nuxt** | ✅ | Any Vite 8 app: add the `cereale/vite` plugin below |
|
||||||
|
| **Bun** 1.3 | ✅ | No configuration |
|
||||||
|
| **Node** + `tsc` | ✅ | Just the flag |
|
||||||
|
| **Next.js** 16 | ⚠️ | Not inline: it derives both the SWC parser *and* the transform from one flag, so decorators either compile legacy or fail to parse. Keep your models in a package compiled by `tsc` |
|
||||||
|
| **NestJS** 11.1 | ⚠️ | Not inline: its DI needs `emitDecoratorMetadata`. Same precompiled-package route — verified working alongside `@Injectable()` in a program with both legacy flags on |
|
||||||
|
|
||||||
|
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
||||||
|
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
||||||
|
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
||||||
|
the plugin that fixes it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts / vitest.config.ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [standardDecorators()],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
It transforms `.ts`, `.mts` and `.cts` outside `node_modules` with esbuild, falling back to
|
||||||
|
the TypeScript compiler if esbuild is not installed — Cereale depends on neither. Pass
|
||||||
|
`include` to widen or narrow the set (decorated classes in `.tsx` files need this),
|
||||||
|
`transformer: 'esbuild' | 'typescript'` to pin one, or `target` to change the output level
|
||||||
|
from the default `es2022`. Nothing in the plugin is specific to Cereale; delete it once oxc
|
||||||
|
implements the transform.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Define your Models
|
### 1. Define your model
|
||||||
|
|
||||||
Use decorators to define how your data should be transformed and validated.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString, IsDate, IsInt, Min, ValidateNested, JsonType,
|
||||||
IsInt,
|
JsonProperty, JsonWriteOnly, JsonPolymorphic,
|
||||||
Min,
|
|
||||||
IsDate,
|
|
||||||
ValidateNested,
|
|
||||||
JsonSerialize,
|
|
||||||
JsonDeserialize,
|
|
||||||
JsonPolymorphic,
|
|
||||||
JsonSerializer,
|
|
||||||
JsonDeserializer
|
|
||||||
} from 'cereale';
|
} from 'cereale';
|
||||||
|
|
||||||
// Custom Date Serializer
|
class Address {
|
||||||
class DateSerializer implements JsonSerializer<Date, string> {
|
@IsString() street!: string;
|
||||||
serialize(value: Date): string {
|
@IsString() city!: string;
|
||||||
return value.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DateDeserializer implements JsonDeserializer<string, Date> {
|
format() { return `${this.street}, ${this.city}`; }
|
||||||
deserialize(value: string): Date {
|
|
||||||
return new Date(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
@IsString()
|
// Standard decorators cannot decorate an `abstract` member, so declare it concretely.
|
||||||
abstract type: string;
|
@IsString() type: string = '';
|
||||||
|
@IsString() title: string = '';
|
||||||
@IsString()
|
|
||||||
title: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
type = 'book';
|
@IsString() override type = 'book';
|
||||||
|
@IsString() author!: string;
|
||||||
@IsString()
|
|
||||||
author: string;
|
|
||||||
|
|
||||||
@JsonSerialize(DateSerializer)
|
@JsonProperty('published_at')
|
||||||
@JsonDeserialize(DateDeserializer)
|
|
||||||
@IsDate()
|
@IsDate()
|
||||||
publishedAt: Date;
|
publishedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
@IsString()
|
@IsString() name!: string;
|
||||||
name: string;
|
|
||||||
|
@ValidateNested() @JsonType(() => Address)
|
||||||
|
address!: Address; // the class must match the field
|
||||||
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
@JsonPolymorphic<Media>('type', [{ value: Book, name: 'book' }])
|
||||||
{ value: Book, name: 'book' }
|
items!: Media[];
|
||||||
])
|
|
||||||
items: Media[];
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Map JSON with Validation
|
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.
|
||||||
|
|
||||||
Use standalone utility functions to handle the conversion process directly.
|
### 2. Map JSON, synchronously or not
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { fromJson, toJson, JsonValidationError } from 'cereale';
|
import { fromJsonSync, toJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
|
||||||
async function main() {
|
try {
|
||||||
const json = '{"name": "Central Library", "items": [{"type": "book", "title": "The Great Gatsby", "author": "F. Scott Fitzgerald", "publishedAt": "1925-04-10"}]}';
|
const library = fromJsonSync(Library, json);
|
||||||
|
library.address.format(); // your method, on a real Address
|
||||||
try {
|
library.items[0] instanceof Book; // true
|
||||||
// Deserialize JSON to Class Instance
|
console.log(toJsonSync(library));
|
||||||
const library = await fromJson(Library, json);
|
} catch (error) {
|
||||||
console.log(library.name); // "Central Library"
|
if (error instanceof JsonValidationError) {
|
||||||
console.log(library.items[0] instanceof Book); // true
|
console.error(flattenErrors(error.errors));
|
||||||
|
// { "items[0].title": ["title must be a string"] }
|
||||||
// Serialize Class Instance back to JSON
|
|
||||||
const outputJson = await toJson(library);
|
|
||||||
console.log(outputJson);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof JsonValidationError) {
|
|
||||||
console.error("Validation failed:", error.errors);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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)
|
### 3. Modern Web Frameworks (Request Integration)
|
||||||
|
|
||||||
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
Cereale is compatible with Fetch-based frameworks like Hono, Next.js, and Remix. Use the `fromRequest` async helper.
|
||||||
@@ -135,20 +238,123 @@ app.post('/books', async (c) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Field-name Mapping
|
||||||
|
|
||||||
|
JSON rarely uses the same names as your classes.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { JsonProperty, JsonAlias } from 'cereale';
|
||||||
|
|
||||||
|
class User {
|
||||||
|
@JsonProperty('first_name')
|
||||||
|
firstName: string; // <-> {"first_name": "Ada"}
|
||||||
|
|
||||||
|
@JsonProperty('surname')
|
||||||
|
@JsonAlias('last_name') // also accepted on input, never emitted
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or convert every property at once with a naming strategy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { configure, toPlain } from 'cereale';
|
||||||
|
|
||||||
|
// once, for the whole application
|
||||||
|
configure({ namingStrategy: 'snake_case' });
|
||||||
|
|
||||||
|
// or per call
|
||||||
|
await toPlain(user, { namingStrategy: 'snake_case' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in strategies: `identity` (default), `camelCase`, `PascalCase`, `snake_case`,
|
||||||
|
`SCREAMING_SNAKE_CASE`, `kebab-case`. You can also pass your own
|
||||||
|
`(propertyKey: string) => string`. An explicit `@JsonProperty` always wins.
|
||||||
|
|
||||||
|
Acronyms split where a reader expects them to: `parseHTTPResponse` becomes
|
||||||
|
`parse_http_response`, not `parse_h_t_t_p_response`.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { JsonIgnore, JsonReadOnly, JsonWriteOnly } from 'cereale';
|
||||||
|
|
||||||
|
class Account {
|
||||||
|
@JsonReadOnly() // sent to clients, never settable by them
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly() // accepted from clients, never echoed back
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@JsonIgnore() // never crosses the boundary in either direction
|
||||||
|
internalNotes: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
Every mapping function takes an optional trailing options argument, and `configure()` sets
|
||||||
|
defaults for the whole application. Per-call options win.
|
||||||
|
|
||||||
|
| Option | Values | Default | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `validate` | `boolean` | `true` | Validate the result; throw `JsonValidationError` on failure. |
|
||||||
|
| `namingStrategy` | strategy name or function | `identity` | JSON naming convention for properties without `@JsonProperty`. |
|
||||||
|
| `unknownKeys` | `allow` \| `strip` \| `error` | `allow` | What to do with incoming keys matching no declared property. |
|
||||||
|
| `maxDepth` | `number` | `64` | Nesting depth before a `JsonMappingError` is raised, bounding hostile payloads. |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lenient parse: build the instance, inspect the damage yourself
|
||||||
|
const draft = await fromJson(Order, body, { validate: false });
|
||||||
|
const problems = flattenErrors(await validate(draft));
|
||||||
|
|
||||||
|
// strict intake: reject anything you did not declare
|
||||||
|
const order = await fromJson(Order, body, { unknownKeys: 'error' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Synchronous API
|
||||||
|
|
||||||
|
Nothing on the default path is genuinely asynchronous — only a serializer, deserializer or
|
||||||
|
validator you supply can be — so every mapping function has a synchronous twin.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { fromJsonSync, toJsonSync, validateSync } from 'cereale';
|
||||||
|
|
||||||
|
const user = fromJsonSync(User, body); // no await
|
||||||
|
const errors = validateSync(user);
|
||||||
|
const payload = toJsonSync(user);
|
||||||
|
```
|
||||||
|
|
||||||
|
`validateSync`, `validateOrRejectSync`, `toPlainSync`, `toJsonSync`, `toInstanceSync`,
|
||||||
|
`toInstanceArraySync`, `fromJsonSync`, `fromJsonArraySync`.
|
||||||
|
|
||||||
|
If one of your hooks does return a Promise, the synchronous call raises a `JsonMappingError`
|
||||||
|
naming the async function to use instead, rather than handing back a half-built object.
|
||||||
|
`fromRequest` has no synchronous form, since reading a request body is inherently async.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### Decorators
|
### Mapping Decorators
|
||||||
|
|
||||||
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Specifies a custom serializer for a property.
|
- `@JsonProperty(name: string)`: Renames the property in JSON, both directions.
|
||||||
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Specifies a custom deserializer for a property.
|
- `@JsonAlias(...names: string[])`: Extra names accepted on input only.
|
||||||
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations.
|
- `@JsonIgnore()`: Excludes the property from mapping entirely.
|
||||||
- `@JsonPolymorphic(discriminator: string, subTypes: { value: ClassConstructor<any>, name: string }[])`: Configures polymorphic transformation based on a discriminator field.
|
- `@JsonReadOnly()`: Serialized, but never populated from incoming JSON.
|
||||||
|
- `@JsonWriteOnly()`: Populated from incoming JSON, but never serialized.
|
||||||
|
- `@JsonSerialize(serializer: ClassConstructor<JsonSerializer>)`: Custom serializer for a property. Skipped when the value is `null`/`undefined`.
|
||||||
|
- `@JsonDeserialize(deserializer: ClassConstructor<JsonDeserializer>)`: Custom deserializer for a property.
|
||||||
|
- `@JsonType(typeFunction: () => ClassConstructor<any>)`: Explicitly sets the type for nested transformations. Applies element-wise to arrays.
|
||||||
|
- `@JsonPolymorphic(discriminator, subTypes, options?)`: Polymorphic transformation based on a discriminator field. `options` accepts `{ onUnknown: 'keep' | 'error' }` (default `keep`, which preserves the raw value) and `{ fallback: ClassConstructor }`.
|
||||||
|
|
||||||
#### Validation Decorators
|
### Validation Decorators
|
||||||
|
|
||||||
Most validation decorators accept an optional `ValidationOptions` object:
|
Most validation decorators accept an optional `ValidationOptions` object:
|
||||||
- `each: boolean`: Apply validation to each element of an array.
|
- `each: boolean`: Apply validation to each element of an array.
|
||||||
- `message: string | ((args: ValidationArguments) => string)`: Custom error message.
|
- `message: string | ((args: ValidationArguments) => string)`: Custom error message, reported verbatim.
|
||||||
|
|
||||||
| Decorator | Description |
|
| Decorator | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -156,46 +362,95 @@ Most validation decorators accept an optional `ValidationOptions` object:
|
|||||||
| `@IsNumber()` | Checks if value is a number (and not NaN). |
|
| `@IsNumber()` | Checks if value is a number (and not NaN). |
|
||||||
| `@IsInt()` | Checks if value is an integer. |
|
| `@IsInt()` | Checks if value is an integer. |
|
||||||
| `@IsBoolean()` | Checks if value is a boolean. |
|
| `@IsBoolean()` | Checks if value is a boolean. |
|
||||||
|
| `@IsBigInt()` | Checks if value is a bigint. |
|
||||||
| `@IsObject()` | Checks if value is an object (not null/array). |
|
| `@IsObject()` | Checks if value is an object (not null/array). |
|
||||||
| `@IsDate()` | Checks if value is a valid Date object. |
|
| `@IsDate()` | Checks if value is a valid Date object. |
|
||||||
| `@IsDefined()` | Checks if value is not null or undefined. |
|
| `@IsDefined()` | Checks if value is not null or undefined. |
|
||||||
| `@IsOptional()` | Skips other validations if value is null/undefined. |
|
| `@IsOptional()` | Skips other validations if value is null/undefined. |
|
||||||
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
|
| `@IsNotEmpty()` | Checks if value is not null/undefined/empty string. |
|
||||||
| `@Min(value)` | Checks if number is >= value. |
|
| `@IsEmpty()` | Checks if value is null/undefined/`''`/`[]`/`{}`. |
|
||||||
| `@Max(value)` | Checks if number is <= value. |
|
| `@Equals(value)` / `@NotEquals(value)` | Strict equality against a fixed value. |
|
||||||
| `@Positive()` | Checks if number is > 0. |
|
| `@IsEnum(enumObject)` | Checks membership of a TypeScript enum. |
|
||||||
| `@Negative()` | Checks if number is < 0. |
|
| `@IsInstance(Class)` | Checks `value instanceof Class`. |
|
||||||
| `@MinLength(len)` | Checks if string length is >= len. |
|
| `@Min(value)` / `@Max(value)` | Numeric bounds. |
|
||||||
| `@MaxLength(len)` | Checks if string length is <= len. |
|
| `@Positive()` / `@Negative()` | Checks sign. |
|
||||||
|
| `@IsDivisibleBy(n)` | Checks `value % n === 0`. |
|
||||||
|
| `@IsPort()` | Integer in 0–65535, as number or numeric string. |
|
||||||
|
| `@IsLatitude()` / `@IsLongitude()` | Geographic bounds. |
|
||||||
|
| `@MinLength(len)` / `@MaxLength(len)` | String length bounds. |
|
||||||
|
| `@Length(min, max?)` | Both bounds in one rule. |
|
||||||
|
| `@IsAlpha()` / `@IsAlphanumeric()` | Character-class checks. |
|
||||||
|
| `@IsLowercase()` / `@IsUppercase()` | Case checks. |
|
||||||
|
| `@IsNumberString()` | String that parses as a finite number. |
|
||||||
|
| `@Contains(s)` / `@NotContains(s)` | Substring checks. |
|
||||||
|
| `@StartsWith(s)` / `@EndsWith(s)` | Affix checks. |
|
||||||
| `@Email()` | Checks if string is a valid email. |
|
| `@Email()` | Checks if string is a valid email. |
|
||||||
| `@IsUrl()` | Checks if string is a valid URL. |
|
| `@IsUrl()` | Checks if string is a valid URL. |
|
||||||
| `@Matches(regex)`| Checks if string matches a regular expression. |
|
| `@IsUUID(version?)` | Checks if string is a valid UUID. |
|
||||||
|
| `@IsIP(version?)` | Checks if string is a valid IPv4/IPv6 address. |
|
||||||
|
| `@IsJSON()` | Checks if string parses as JSON. |
|
||||||
|
| `@IsDateString()` | Checks if string is a parseable date. |
|
||||||
|
| `@IsSemVer()` | Checks if string is a semantic version. |
|
||||||
|
| `@IsHexColor()` | Checks `#rgb`, `#rrggbb`, `#rrggbbaa`. |
|
||||||
|
| `@Matches(regex)` | Checks if string matches a regular expression. |
|
||||||
|
| `@MinDate(d)` / `@MaxDate(d)` | Date bounds. Accepts `() => Date` for a moving bound. |
|
||||||
| `@IsArray()` | Checks if value is an array. |
|
| `@IsArray()` | Checks if value is an array. |
|
||||||
| `@ArrayNotEmpty()`| Checks if array is not empty. |
|
| `@ArrayNotEmpty()` | Checks if array is not empty. |
|
||||||
| `@ArrayMinSize(n)`| Checks if array has at least n elements. |
|
| `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | Array size bounds. |
|
||||||
| `@ArrayMaxSize(n)`| Checks if array has at most n elements. |
|
| `@ArrayUnique(keyFn?)` | Checks for duplicate elements. |
|
||||||
| `@IsIn(values)` | Checks if value is in the allowed list. |
|
| `@ArrayContains(vals)` / `@ArrayNotContains(vals)` | Membership checks. |
|
||||||
| `@IsNotIn(vals)` | Checks if value is NOT in the list. |
|
| `@IsIn(values)` / `@IsNotIn(values)` | Allow/deny lists. |
|
||||||
| `@ValidateNested()`| Recursively validates nested objects/arrays. |
|
| `@ValidateNested(options?)` | Recursively validates nested objects/arrays. |
|
||||||
|
| `@ValidateIf(o => boolean)` | Skips this property's rules when the condition is false. |
|
||||||
|
| `@Allow()` | Declares a property with no rules of its own. |
|
||||||
|
| `@Validate(validator, constraints?, options?)` | Applies a custom validator class or function. |
|
||||||
|
|
||||||
|
Write your own with `registerDecorator({ name, target, propertyName, validator })`.
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
|
|
||||||
- `toJson(obj: any)`: Validates and serializes an instance to a JSON string (Returns `Promise<string>`).
|
- `toJson(obj, options?)`: Validates and serializes an instance to a JSON string (`Promise<string>`).
|
||||||
- `toPlain(obj: any)`: Validates and transforms an instance to a plain object (Returns `Promise<any>`).
|
- `toPlain(obj, options?)`: Validates and transforms an instance to a plain object (`Promise<any>`).
|
||||||
- `fromJson(clazz: ClassConstructor, json: string)`: Parses JSON and transforms it to a validated class instance (Returns `Promise<T>`).
|
- `fromJson(clazz, json, options?)`: Parses JSON to a validated class instance (`Promise<T>`).
|
||||||
- `toInstance(clazz: ClassConstructor, plain: any)`: Transforms a plain object to a validated class instance (Returns `Promise<T>`).
|
- `fromJsonArray(clazz, json, options?)`: Same, for a JSON array (`Promise<T[]>`).
|
||||||
- `fromRequest(clazz: ClassConstructor, request: Request)`: Extracts JSON from a Fetch `Request` and transforms it to a validated instance (Returns `Promise<T>`).
|
- `toInstance(clazz, plain, options?)`: Transforms a plain object to a validated class instance (`Promise<T>`).
|
||||||
- `validate(obj: any)`: Performs full validation on an object/instance (Returns `Promise<ValidationError[]>`).
|
- `toInstanceArray(clazz, plain, options?)`: Same, for an array (`Promise<T[]>`).
|
||||||
|
- `fromRequest(clazz, request, options?)`: Extracts JSON from a Fetch `Request` (`Promise<T>`).
|
||||||
|
- `validate(obj, options?)`: Full validation, returning `Promise<ValidationError[]>`.
|
||||||
|
- `validateOrReject(obj, options?)`: As above, but throws `JsonValidationError`.
|
||||||
|
- Synchronous twins of all of the above except `fromRequest`: `toPlainSync`, `toJsonSync`,
|
||||||
|
`fromJsonSync`, `fromJsonArraySync`, `toInstanceSync`, `toInstanceArraySync`,
|
||||||
|
`validateSync`, `validateOrRejectSync`.
|
||||||
|
- `configure(options)` / `getConfig()` / `resetConfig()`: Library-wide defaults.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
`JsonValidationError` carries a nested `ValidationError[]`. Three helpers turn it into
|
||||||
|
something you can return to a client:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { flattenErrors, formatErrors, collectErrorMessages } from 'cereale';
|
||||||
|
|
||||||
|
flattenErrors(errors); // { "items[0].qty": ["qty must be at least 1"] }
|
||||||
|
formatErrors(errors); // "items[0].qty: qty must be at least 1"
|
||||||
|
collectErrorMessages(errors); // ["qty must be at least 1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Values of properties that never leave the process — `@JsonWriteOnly` and `@JsonIgnore` — are
|
||||||
|
replaced with `REDACTED` in `ValidationError.value`, so a rejected password does not travel
|
||||||
|
into your logs inside an error object. The property name and message are unaffected.
|
||||||
|
|
||||||
|
`JsonMappingError` is raised when a value cannot be mapped at all — a body that is not
|
||||||
|
JSON, a circular reference, an unknown discriminator under `{ onUnknown: 'error' }` — as
|
||||||
|
distinct from mapping fine and failing validation.
|
||||||
|
|
||||||
## Framework Integrations
|
## Framework Integrations
|
||||||
|
|
||||||
Cereale is designed to be compatible with all trending web frameworks.
|
|
||||||
|
|
||||||
### Hono / Next.js / Cloudflare Workers
|
### Hono / Next.js / Cloudflare Workers
|
||||||
Use `fromRequest` for seamless integration with the Fetch `Request` API.
|
Use `fromRequest` for seamless integration with the Fetch `Request` API.
|
||||||
|
|
||||||
### NestJS
|
### NestJS
|
||||||
You can use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
|
Use Cereale inside your controllers for explicit mapping and validation without needing `reflect-metadata`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { toInstance } from 'cereale';
|
import { toInstance } from 'cereale';
|
||||||
@@ -208,25 +463,135 @@ async create(@Body() body: any) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Express / Fastify
|
### Express / Fastify
|
||||||
Easily integrate with traditional Node.js frameworks.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { toInstance, toPlain } from 'cereale';
|
import { toInstance, toPlain, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
|
||||||
app.post('/user', async (req, res) => {
|
app.post('/user', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await toInstance(User, req.body);
|
const user = await toInstance(User, req.body);
|
||||||
res.json(await toPlain(user));
|
res.json(await toPlain(user));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json(err);
|
if (err instanceof JsonValidationError) {
|
||||||
|
return res.status(400).json({ errors: flattenErrors(err.errors) });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Decorator metadata is fixed once your classes are declared, so cereale resolves each class's
|
||||||
|
validation, serialization and deserialization plans once and memoizes them per prototype.
|
||||||
|
A version counter invalidates the caches if metadata is registered late, so `registerDecorator`
|
||||||
|
after first use still behaves correctly. The engines are synchronous internally, so the async
|
||||||
|
entry points do not pay for a microtask per property.
|
||||||
|
|
||||||
|
Indicative throughput for a customer record with a nested address and 10 orders, measured
|
||||||
|
against `JSON.parse` + `JSON.stringify` (5.8 us) on the same machine:
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
| --- | --- |
|
||||||
|
| `toInstance` (deserialize + validate) | ~8 us |
|
||||||
|
| `toInstance` with `{ validate: false }` | ~3 us |
|
||||||
|
| `validate` on an existing instance | ~4.5 us |
|
||||||
|
| `toPlain` (validate + serialize) | ~12 us |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Bundle size
|
||||||
|
|
||||||
|
Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not
|
||||||
|
import, which matters for a library with 68 decorators — you pay for what you name and nothing
|
||||||
|
else. Minified bytes, measured through esbuild, rollup and webpack. The table was measured by hand;
|
||||||
|
what is [pinned by a test](src/treeshake.test.ts) is the property behind it — that a given
|
||||||
|
import drops the parts of the library it does not reach — checked through esbuild on both the
|
||||||
|
source and the published bundle:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,360 |
|
||||||
|
| the whole library | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
The serializer and the deserializer drop independently: read JSON and you do not pay for
|
||||||
|
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
|
||||||
|
points reference it whatever a given call site passes.
|
||||||
|
|
||||||
|
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
|
||||||
|
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
|
||||||
|
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
|
||||||
|
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
|
||||||
|
its own marking is ever read. That cost lands only on the first row; every import that touches a
|
||||||
|
model was already carrying it.
|
||||||
|
|
||||||
|
This did not come for free. Thirty of the rules are declared as top-level calls —
|
||||||
|
`export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
|
||||||
|
the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
|
||||||
|
them, importing one decorator pulled in the message and validator of all 68: **4,909 bytes
|
||||||
|
instead of 1,837**. Nothing failed; the library was simply three times heavier in every
|
||||||
|
consumer's bundle, and the only way to find out was to measure. Note what that means for
|
||||||
|
measuring: rollup alone would have shown nothing wrong.
|
||||||
|
|
||||||
|
`cereale/min` tree-shakes too, which took a second fix — esbuild's `minify` strips comments,
|
||||||
|
annotations included, so the flat bundle was silently reproducing the same bug (5,066 bytes for
|
||||||
|
one decorator). It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw,
|
||||||
|
9.6 KB gzipped, about a kilobyte over the wire more than full minification would give. Still,
|
||||||
|
if you are using a bundler, import from `cereale` rather than `cereale/min`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **`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.
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Cereale is licensed under the [MIT License](LICENSE).
|
Cereale is licensed under the [MIT License](LICENSE).
|
||||||
|
|||||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
// 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": []
|
||||||
|
};
|
||||||
+997
-263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
// Generated by scripts/build-docs.mjs — do not edit.
|
||||||
|
window.CEREALE_META = {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
};
|
||||||
+566
@@ -0,0 +1,566 @@
|
|||||||
|
/* 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', '');
|
||||||
|
if (meta.version) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.js-version'), function (el) {
|
||||||
|
el.textContent = meta.version;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (decoratorCount) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.js-dec-count'), function (el) {
|
||||||
|
el.textContent = String(decoratorCount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- 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
@@ -0,0 +1,7 @@
|
|||||||
|
# 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
+3532
File diff suppressed because it is too large
Load Diff
+51
-12
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.0.1",
|
"version": "0.4.1",
|
||||||
"description": "Spring-like decorators for JSON mapping and validation in TypeScript",
|
"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.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
"module": "./dist/esm/index.js",
|
"module": "./dist/esm/index.js",
|
||||||
@@ -11,49 +11,88 @@
|
|||||||
"types": "./dist/esm/index.d.ts",
|
"types": "./dist/esm/index.d.ts",
|
||||||
"import": "./dist/esm/index.js",
|
"import": "./dist/esm/index.js",
|
||||||
"require": "./dist/cjs/index.js"
|
"require": "./dist/cjs/index.js"
|
||||||
|
},
|
||||||
|
"./vite": {
|
||||||
|
"types": "./dist/esm/vite.d.ts",
|
||||||
|
"import": "./dist/esm/vite.js",
|
||||||
|
"require": "./dist/cjs/vite.js"
|
||||||
|
},
|
||||||
|
"./min": {
|
||||||
|
"types": "./dist/esm/index.d.ts",
|
||||||
|
"default": "./dist/cereale.min.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": [
|
||||||
|
"./dist/esm/index.js",
|
||||||
|
"./dist/esm/metadata.js",
|
||||||
|
"./dist/cereale.min.js",
|
||||||
|
"./src/index.ts",
|
||||||
|
"./src/metadata.ts"
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"src",
|
||||||
|
"FRAMEWORKS.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"!src/**/*.test.ts",
|
||||||
|
"!src/example.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node scripts/build-bundle.mjs",
|
||||||
|
"build:docs": "node scripts/build-docs.mjs",
|
||||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"prepublishOnly": "npm run build"
|
"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",
|
||||||
|
"check:docs-sync": "npm run build:docs && node scripts/check-docs-sync.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/Avalon-Vanguard/cereale.git"
|
"url": "git+https://github.com/avalon-vanguard/cereale.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"json",
|
"json",
|
||||||
"mapping",
|
|
||||||
"validation",
|
"validation",
|
||||||
"decorators",
|
"decorators",
|
||||||
"spring",
|
"standard-decorators",
|
||||||
"typescript"
|
"typescript",
|
||||||
|
"dto",
|
||||||
|
"serialization",
|
||||||
|
"class-validator",
|
||||||
|
"class-transformer",
|
||||||
|
"class-validator-alternative"
|
||||||
],
|
],
|
||||||
"author": "Avalon Vanguard",
|
"author": "Avalon Vanguard",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Avalon-Vanguard/cereale/issues"
|
"url": "https://github.com/avalon-vanguard/cereale/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/Avalon-Vanguard/cereale#readme",
|
"homepage": "https://avalon-vanguard.github.io/cereale/",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/standalone": "^8.0.4",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@swc/core": "^1.15.47",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Flattens the ESM build into a single minified module.
|
||||||
|
*
|
||||||
|
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`: it keeps
|
||||||
|
* readable stack traces for anyone who does not load source maps, and it is what a bundler
|
||||||
|
* should be handed.
|
||||||
|
*
|
||||||
|
* Where the single file does earn its place is everywhere a bundler is not involved: a
|
||||||
|
* `<script type="module">` tag, a CDN, an import map, Deno, or a Worker. That is what
|
||||||
|
* `cereale/min` is for.
|
||||||
|
*
|
||||||
|
* It is built from `dist/esm/index.js` rather than from `src/`, so the decorator lowering and
|
||||||
|
* the ES2025 target are whatever `tsc` produced — esbuild is only flattening and minifying.
|
||||||
|
* (esbuild has no `es2025` target name; `esnext` means "downlevel nothing", which is what we
|
||||||
|
* want when the input is already at the target.)
|
||||||
|
*/
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { readFile, writeFile, stat } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
|
||||||
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const dist = path.join(root, 'dist');
|
||||||
|
const outfile = path.join(dist, 'cereale.min.js');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [path.join(dist, 'esm/index.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext',
|
||||||
|
// NOT `minify: true`: `minifyWhitespace` strips comments, /*#__PURE__*/ included, and a
|
||||||
|
// bundler fed the result keeps every unused rule — 5,066 bytes for one decorator against
|
||||||
|
// 1,837. Asserted below. Costs about a kilobyte gzipped.
|
||||||
|
minifySyntax: true,
|
||||||
|
minifyIdentifiers: true,
|
||||||
|
sourcemap: true,
|
||||||
|
legalComments: 'none',
|
||||||
|
banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` },
|
||||||
|
// The input is compiled JavaScript, so the project's tsconfig has no bearing here — and
|
||||||
|
// reading it only earns a warning, because esbuild does not know the ES2025 target name
|
||||||
|
// that tsc is perfectly happy with.
|
||||||
|
tsconfigRaw: {},
|
||||||
|
outfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole reason this file is not fully minified. Asserting it here means a future change to
|
||||||
|
// the minify options fails the build rather than silently tripling what a bundler keeps.
|
||||||
|
//
|
||||||
|
// The floor on `expected` is not decoration: comparing the two counts alone passes vacuously if
|
||||||
|
// tsc ever stops emitting the annotations, since 0 >= 0. It has to be wrong in both directions.
|
||||||
|
const count = (s) => (s.match(/__PURE__/g) ?? []).length;
|
||||||
|
const emitted = await readFile(outfile, 'utf8');
|
||||||
|
const annotations = count(emitted);
|
||||||
|
const expected = count(await readFile(path.join(dist, 'esm/decorators.js'), 'utf8'));
|
||||||
|
if (expected < 30) {
|
||||||
|
throw new Error(
|
||||||
|
`dist/esm/decorators.js carries only ${expected} /*#__PURE__*/ annotations; src/decorators.ts ` +
|
||||||
|
'writes 30. The compiler is dropping them, so every consumer keeps all 68 rules.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (annotations < expected) {
|
||||||
|
throw new Error(
|
||||||
|
`the flat bundle kept ${annotations} /*#__PURE__*/ annotations but dist/esm/decorators.js has ` +
|
||||||
|
`${expected}. Minification stripped them, so anything bundling cereale/min would keep every ` +
|
||||||
|
'unused rule. Do not turn on minifyWhitespace here.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dist/cjs/package.json is the nearest descriptor for every module beneath it, so bundlers
|
||||||
|
// read `sideEffects` from there rather than from the root manifest. Declaring it only at the
|
||||||
|
// root leaves the whole CommonJS build undeclared.
|
||||||
|
await writeFile(
|
||||||
|
path.join(dist, 'cjs/package.json'),
|
||||||
|
JSON.stringify({ type: 'commonjs', sideEffects: ['./metadata.js'] }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const size = (await stat(outfile)).size;
|
||||||
|
const gzip = gzipSync(emitted).length;
|
||||||
|
console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2b. The same version, stamped into the two spots in index.html that page.js later
|
||||||
|
// overwrites from meta.js. Those are the no-JavaScript fallbacks: correct in a browser,
|
||||||
|
// stale in a text reader or a scraper, and hand-bumped until now — they went stale on
|
||||||
|
// 0.4.0 and again on 0.4.1. Stamping them here means the docs-sync gate catches the
|
||||||
|
// drift instead of a reviewer. The regexes are asserted, so if the markup is renamed
|
||||||
|
// the build fails loudly rather than silently stamping nothing.
|
||||||
|
const indexPath = path.join(docs, 'index.html');
|
||||||
|
let index = await readFile(indexPath, 'utf8');
|
||||||
|
const stamps = [
|
||||||
|
[/(<span class="badge" id="version-badge">)v[\d.]+(<\/span>)/, `$1v${pkg.version}$2`],
|
||||||
|
[/(<span class="js-version">)[\d.]+(<\/span>)/g, `$1${pkg.version}$2`],
|
||||||
|
];
|
||||||
|
for (const [re, replacement] of stamps) {
|
||||||
|
if (!re.test(index)) {
|
||||||
|
console.error(`build-docs: nothing in docs/index.html matched ${re} — the version fallback markup moved.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
index = index.replace(re, replacement);
|
||||||
|
}
|
||||||
|
await writeFile(indexPath, index);
|
||||||
|
|
||||||
|
// 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'))}`);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Fails if docs/ differs from what `npm run build:docs` just produced — including NEW
|
||||||
|
* files. That last part is the reason this exists: `git diff --exit-code -- docs/` only
|
||||||
|
* reports modifications to tracked files, so a build-script change whose only effect is
|
||||||
|
* an additional output (a sourcemap, a second vendor asset) passed the old gate silently
|
||||||
|
* and would have been deployed without ever being committed or reviewed.
|
||||||
|
*
|
||||||
|
* Run after build:docs (the check:docs-sync npm script chains them). Shared by ci.yml
|
||||||
|
* and pages.yml so the two workflows cannot drift into enforcing different notions of
|
||||||
|
* "in sync" — they already had, before this was extracted.
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const out = execFileSync('git', ['status', '--porcelain', '--', 'docs/'], { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
|
if (out) {
|
||||||
|
console.error(out);
|
||||||
|
console.error("docs/ is stale — run 'npm run build:docs' and commit the result");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('docs/ matches src/ — nothing modified, nothing untracked.');
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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.`);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NamingStrategy } from './naming.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an incoming key that maps to no known property should be treated.
|
||||||
|
*
|
||||||
|
* - `allow` (default): copy it onto the instance untouched, preserving the previous behaviour.
|
||||||
|
* - `strip`: drop it, so instances only ever carry declared properties.
|
||||||
|
* - `error`: reject the payload with a {@link JsonMappingError}.
|
||||||
|
*/
|
||||||
|
export type UnknownKeyPolicy = 'allow' | 'strip' | 'error';
|
||||||
|
|
||||||
|
export interface TransformOptions {
|
||||||
|
/**
|
||||||
|
* Validate the result and throw {@link JsonValidationError} on failure.
|
||||||
|
*
|
||||||
|
* Defaults to `true`, matching the behaviour of every previous release. Set it to `false`
|
||||||
|
* to map without validating — useful when you want to inspect a partially-valid payload,
|
||||||
|
* or when validation happens elsewhere in your stack.
|
||||||
|
*/
|
||||||
|
validate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naming convention used on the JSON side for properties without an explicit
|
||||||
|
* `@JsonProperty`. Defaults to `identity` (property names are used as-is).
|
||||||
|
*/
|
||||||
|
namingStrategy?: NamingStrategy;
|
||||||
|
|
||||||
|
/** What to do with incoming keys that match no declared property. Deserialization only. */
|
||||||
|
unknownKeys?: UnknownKeyPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum nesting depth before a {@link JsonMappingError} is raised. Defaults to 64.
|
||||||
|
*
|
||||||
|
* All three engines recurse, so a hostile payload nested thousands of levels deep would
|
||||||
|
* otherwise exhaust the call stack. Raise it if you legitimately model deep trees.
|
||||||
|
*/
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options that can be set once for the whole application via {@link configure}. */
|
||||||
|
export type GlobalOptions = Pick<TransformOptions, 'namingStrategy' | 'unknownKeys' | 'validate' | 'maxDepth'>;
|
||||||
|
|
||||||
|
const DEFAULTS: Required<GlobalOptions> = {
|
||||||
|
namingStrategy: 'identity',
|
||||||
|
unknownKeys: 'allow',
|
||||||
|
validate: true,
|
||||||
|
maxDepth: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
let globalOptions: Required<GlobalOptions> = { ...DEFAULTS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets library-wide defaults, so an application that consistently speaks `snake_case` does
|
||||||
|
* not have to repeat itself at every call site.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* configure({ namingStrategy: 'snake_case', unknownKeys: 'strip' });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Per-call options always take precedence over these.
|
||||||
|
*/
|
||||||
|
export function configure(options: GlobalOptions): void {
|
||||||
|
globalOptions = { ...globalOptions, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the current library-wide defaults. */
|
||||||
|
export function getConfig(): Required<GlobalOptions> {
|
||||||
|
return { ...globalOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the library-wide defaults to their original values. */
|
||||||
|
export function resetConfig(): void {
|
||||||
|
globalOptions = { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges per-call options over the library-wide defaults. */
|
||||||
|
export function resolveOptions(options?: TransformOptions): Required<GlobalOptions> {
|
||||||
|
if (!options) return globalOptions;
|
||||||
|
return {
|
||||||
|
namingStrategy: options.namingStrategy ?? globalOptions.namingStrategy,
|
||||||
|
unknownKeys: options.unknownKeys ?? globalOptions.unknownKeys,
|
||||||
|
validate: options.validate ?? globalOptions.validate,
|
||||||
|
maxDepth: options.maxDepth ?? globalOptions.maxDepth,
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
-32
@@ -13,7 +13,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsNotIn,
|
IsNotIn,
|
||||||
Validate,
|
Validate,
|
||||||
registerDecorator,
|
defineRule,
|
||||||
JsonType,
|
JsonType,
|
||||||
JsonPolymorphic,
|
JsonPolymorphic,
|
||||||
JsonMapper,
|
JsonMapper,
|
||||||
@@ -238,27 +238,20 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 'wrong';
|
t.val = 'wrong';
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['CustomValidator']).toBe('val must be correct');
|
expect(errors[0]!.constraints['CustomValidator']).toBe('val must be correct');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registerDecorator', () => {
|
describe('registerDecorator', () => {
|
||||||
it('should register a custom decorator with functional validator', async () => {
|
it('should register a custom decorator with functional validator', async () => {
|
||||||
function IsEven() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isEven',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: (value: any) => typeof value === 'number' && value % 2 === 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsEven()
|
val: number = 0;
|
||||||
val: number;
|
|
||||||
}
|
}
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isEven',
|
||||||
|
validate: (value: any) => typeof value === 'number' && value % 2 === 0,
|
||||||
|
message: 'val must be even',
|
||||||
|
});
|
||||||
|
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 2;
|
t.val = 2;
|
||||||
@@ -271,19 +264,15 @@ describe('Additional Decorators', () => {
|
|||||||
class MyValidator implements ValidatorConstraintInterface {
|
class MyValidator implements ValidatorConstraintInterface {
|
||||||
validate(v: any) { return v === 'ok'; }
|
validate(v: any) { return v === 'ok'; }
|
||||||
}
|
}
|
||||||
function IsOk() {
|
|
||||||
return function (object: any, propertyName: string) {
|
|
||||||
registerDecorator({
|
|
||||||
name: 'isOk',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
validator: MyValidator,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
class Test {
|
class Test {
|
||||||
@IsOk() val: string;
|
val: string = '';
|
||||||
}
|
}
|
||||||
|
const validator = new MyValidator();
|
||||||
|
defineRule(Test, 'val', {
|
||||||
|
name: 'isOk',
|
||||||
|
validate: (v: any) => validator.validate(v),
|
||||||
|
message: 'val must be ok',
|
||||||
|
});
|
||||||
const t = new Test();
|
const t = new Test();
|
||||||
t.val = 'ok';
|
t.val = 'ok';
|
||||||
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
expect(await JsonMapper.validate(t)).toHaveLength(0);
|
||||||
@@ -304,7 +293,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 5;
|
t.val = 5;
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['custom']).toBe('must be ten');
|
expect(errors[0]!.constraints['custom']).toBe('must be ten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle options as second argument', async () => {
|
it('should handle options as second argument', async () => {
|
||||||
@@ -318,7 +307,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.val = 2;
|
t.val = 2;
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['custom']).toBe('must be one');
|
expect(errors[0]!.constraints['custom']).toBe('must be one');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,10 +328,10 @@ describe('Additional Decorators', () => {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
const json = '[{"name": "a"}, {"name": "b"}]';
|
const json = '[{"name": "a"}, {"name": "b"}]';
|
||||||
const items = await JsonMapper.fromJson(Item, json);
|
const items = (await JsonMapper.fromJson(Item, json)) as unknown as Item[];
|
||||||
expect(Array.isArray(items)).toBe(true);
|
expect(Array.isArray(items)).toBe(true);
|
||||||
expect(items[0]).toBeInstanceOf(Item);
|
expect(items[0]).toBeInstanceOf(Item);
|
||||||
expect(items[0].name).toBe('a');
|
expect(items[0]!.name).toBe('a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle single polymorphic object', async () => {
|
it('should handle single polymorphic object', async () => {
|
||||||
@@ -350,7 +339,7 @@ describe('Additional Decorators', () => {
|
|||||||
@IsString() type: string;
|
@IsString() type: string;
|
||||||
}
|
}
|
||||||
class Dog extends Animal {
|
class Dog extends Animal {
|
||||||
type = 'dog';
|
override type = 'dog';
|
||||||
@IsString() breed: string;
|
@IsString() breed: string;
|
||||||
}
|
}
|
||||||
class Test {
|
class Test {
|
||||||
@@ -376,7 +365,7 @@ describe('Additional Decorators', () => {
|
|||||||
t.tags = ['a', 1 as any];
|
t.tags = ['a', 1 as any];
|
||||||
const errors = await JsonMapper.validate(t);
|
const errors = await JsonMapper.validate(t);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].constraints['isString']).toContain('each element');
|
expect(errors[0]!.constraints['isString']).toContain('each element');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+637
-511
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ValidationError } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens the nested {@link ValidationError} tree into a flat map of dotted paths to
|
||||||
|
* messages — the shape you actually want when turning a failure into an HTTP 400 body.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* flattenErrors(errors);
|
||||||
|
* // {
|
||||||
|
* // "name": ["name must be a string"],
|
||||||
|
* // "items[0].qty": ["qty must be at least 1"]
|
||||||
|
* // }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function flattenErrors(errors: ValidationError[]): Record<string, string[]> {
|
||||||
|
const flat: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
const walk = (nodes: ValidationError[], prefix: string) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Array indices read as `items[0]`, named properties as `order.total`.
|
||||||
|
const path = node.property.startsWith('[')
|
||||||
|
? `${prefix}${node.property}`
|
||||||
|
: prefix ? `${prefix}.${node.property}` : node.property;
|
||||||
|
|
||||||
|
const messages = Object.values(node.constraints);
|
||||||
|
if (messages.length > 0) {
|
||||||
|
(flat[path] ??= []).push(...messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
walk(node.children, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(errors, '');
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the error tree as human-readable lines, one per failed rule.
|
||||||
|
*
|
||||||
|
* Intended for logs and CLI output; use {@link flattenErrors} when the destination is JSON.
|
||||||
|
*/
|
||||||
|
export function formatErrors(errors: ValidationError[]): string {
|
||||||
|
const flat = flattenErrors(errors);
|
||||||
|
return Object.entries(flat)
|
||||||
|
.flatMap(([path, messages]) => messages.map(message => `${path}: ${message}`))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects every message in the tree, discarding paths.
|
||||||
|
*/
|
||||||
|
export function collectErrorMessages(errors: ValidationError[]): string[] {
|
||||||
|
return Object.values(flattenErrors(errors)).flat();
|
||||||
|
}
|
||||||
+132
-87
@@ -1,22 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsInt,
|
IsInt,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsDate,
|
IsDate,
|
||||||
JsonSerialize,
|
IsEnum,
|
||||||
JsonDeserialize,
|
IsUUID,
|
||||||
JsonPolymorphic,
|
ValidateIf,
|
||||||
|
JsonProperty,
|
||||||
|
JsonAlias,
|
||||||
|
JsonReadOnly,
|
||||||
|
JsonWriteOnly,
|
||||||
|
JsonSerialize,
|
||||||
|
JsonDeserialize,
|
||||||
|
JsonPolymorphic,
|
||||||
toJson,
|
toJson,
|
||||||
fromJson,
|
fromJson,
|
||||||
JsonSerializer,
|
toPlain,
|
||||||
|
validate,
|
||||||
|
flattenErrors,
|
||||||
|
JsonSerializer,
|
||||||
JsonDeserializer,
|
JsonDeserializer,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
ValidationArguments,
|
ValidationArguments,
|
||||||
registerDecorator,
|
Matches,
|
||||||
ValidationOptions
|
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
// --- Custom Validators ---
|
// --- Custom Validators ---
|
||||||
@@ -32,26 +41,14 @@ class IsLongerThan implements ValidatorConstraintInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function IsUsername(options?: ValidationOptions) {
|
/** A custom rule is just a decorator that composes an existing one. */
|
||||||
return function (object: any, propertyName: string) {
|
const IsSlug = () => Matches(/^[a-z0-9-]+$/, { message: 'name must be a lowercase slug' });
|
||||||
registerDecorator({
|
|
||||||
name: 'isUsername',
|
|
||||||
target: object.constructor,
|
|
||||||
propertyName: propertyName,
|
|
||||||
...(options ? { options } : {}),
|
|
||||||
validator: (value: any) => typeof value === 'string' && /^[a-zA-Z0-9_]+$/.test(value)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Custom Serializers ---
|
// --- Custom Serializers ---
|
||||||
|
|
||||||
class DateSerializer implements JsonSerializer<Date, string> {
|
class DateSerializer implements JsonSerializer<Date, string> {
|
||||||
serialize(value: Date): string {
|
serialize(value: Date): string {
|
||||||
if (value instanceof Date) {
|
return value.toISOString().split('T')[0] || '';
|
||||||
return value.toISOString().split('T')[0] || '';
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,26 +60,34 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
|||||||
|
|
||||||
// --- Domain Models ---
|
// --- Domain Models ---
|
||||||
|
|
||||||
abstract class Media {
|
enum Format {
|
||||||
@IsString()
|
Hardback = 'hardback',
|
||||||
abstract type: string;
|
Paperback = 'paperback',
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the discriminator is a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
type: string = '';
|
||||||
|
|
||||||
|
// Declared once here. Subclasses inherit the rule without restating it.
|
||||||
|
@IsString()
|
||||||
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
override type: string = 'book';
|
override type: string = 'book';
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsUsername({ message: 'Title must be a valid alphanumeric username' })
|
|
||||||
declare title: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Validate(IsLongerThan, [5])
|
@Validate(IsLongerThan, [5])
|
||||||
author: string;
|
author: string;
|
||||||
|
|
||||||
|
@IsEnum(Format)
|
||||||
|
format: Format = Format.Paperback;
|
||||||
|
|
||||||
|
@JsonProperty('published_at')
|
||||||
@JsonSerialize(DateSerializer)
|
@JsonSerialize(DateSerializer)
|
||||||
@JsonDeserialize(DateDeserializer)
|
@JsonDeserialize(DateDeserializer)
|
||||||
@IsDate()
|
@IsDate()
|
||||||
@@ -91,87 +96,127 @@ class Book extends Media {
|
|||||||
|
|
||||||
class Movie extends Media {
|
class Movie extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'movie';
|
override type: string = 'movie';
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
|
// Only checked for films that claim to be part of a series.
|
||||||
|
@ValidateIf<Movie>(movie => movie.duration > 200)
|
||||||
|
@IsString()
|
||||||
|
intermissionNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
|
@JsonReadOnly()
|
||||||
|
@IsUUID(4)
|
||||||
|
id: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsSlug()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@JsonProperty('curator_email')
|
||||||
|
@JsonAlias('curatorEmail')
|
||||||
|
@IsString()
|
||||||
|
curatorEmail: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
adminToken: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested()
|
@ValidateNested({ each: true })
|
||||||
@JsonPolymorphic('type', [
|
// Naming the base type has the subtype list checked against it.
|
||||||
|
@JsonPolymorphic<Media>('type', [
|
||||||
{ value: Book, name: 'book' },
|
{ value: Book, name: 'book' },
|
||||||
{ value: Movie, name: 'movie' }
|
{ value: Movie, name: 'movie' }
|
||||||
])
|
])
|
||||||
items: Media[];
|
items: Media[] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Execution ---
|
// --- Execution ---
|
||||||
|
|
||||||
async function runExample() {
|
async function runExample() {
|
||||||
console.log("--- Starting Example ---");
|
console.log('--- Starting Example ---');
|
||||||
|
|
||||||
// 1. Create a Library instance
|
|
||||||
const library = new Library();
|
const library = new Library();
|
||||||
library.name = "Central Library";
|
library.id = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
|
||||||
|
library.name = 'central-library';
|
||||||
|
library.curatorEmail = 'ada@example.com';
|
||||||
|
library.adminToken = 'super-secret';
|
||||||
|
|
||||||
const book = new Book();
|
const book = new Book();
|
||||||
book.title = "Gatsby";
|
book.title = 'Gatsby';
|
||||||
book.author = "Fitzgerald";
|
book.author = 'Fitzgerald';
|
||||||
book.publishedAt = new Date("1925-04-10");
|
book.format = Format.Hardback;
|
||||||
|
book.publishedAt = new Date('1925-04-10');
|
||||||
|
|
||||||
const movie = new Movie();
|
const movie = new Movie();
|
||||||
movie.title = "Inception";
|
movie.title = 'Inception';
|
||||||
movie.duration = 148;
|
movie.duration = 148;
|
||||||
|
|
||||||
library.items = [book, movie];
|
library.items = [book, movie];
|
||||||
|
|
||||||
try {
|
// 1. Serialize, honouring @JsonProperty and the write-only token
|
||||||
// 2. Serialize to JSON
|
console.log('\n[1] Serializing Library to JSON...');
|
||||||
console.log("\n[1] Serializing Library to JSON...");
|
const json = await toJson(library);
|
||||||
const json = await toJson(library);
|
console.log('JSON Output:', json);
|
||||||
console.log("JSON Output:", json);
|
console.log('Secret withheld from output:', !json.includes('super-secret'));
|
||||||
|
|
||||||
// 3. Deserialize back to Instance
|
// 2. Deserialize back, resolving the polymorphic items
|
||||||
console.log("\n[2] Deserializing JSON back to Library instance...");
|
console.log('\n[2] Deserializing JSON back to Library instance...');
|
||||||
const deserializedLibrary = await fromJson(Library, json);
|
const restored = await fromJson(Library, json, { validate: false });
|
||||||
console.log("Deserialized Library Name:", deserializedLibrary.name);
|
console.log('Curator (read via curator_email):', restored.curatorEmail);
|
||||||
console.log("Items count:", deserializedLibrary.items.length);
|
console.log('Items count:', restored.items.length);
|
||||||
|
restored.items.forEach((item, index) => {
|
||||||
// Check Polymorphism
|
console.log(`Item ${index} is a ${item.constructor.name}: ${item.title}`);
|
||||||
deserializedLibrary.items.forEach((item, index) => {
|
if (item instanceof Book) {
|
||||||
console.log(`Item ${index} is instance of ${item.constructor.name}: ${item.title}`);
|
console.log(` > Author: ${item.author}, format: ${item.format}`);
|
||||||
if (item instanceof Book) {
|
console.log(` > Published: ${item.publishedAt.toISOString()} (Date: ${item.publishedAt instanceof Date})`);
|
||||||
console.log(` > Book Author: ${item.author}`);
|
} else if (item instanceof Movie) {
|
||||||
console.log(` > Published At: ${item.publishedAt.toISOString()} (instanceof Date: ${item.publishedAt instanceof Date})`);
|
console.log(` > Duration: ${item.duration} mins`);
|
||||||
} else if (item instanceof Movie) {
|
|
||||||
console.log(` > Movie Duration: ${item.duration} mins`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Test Validation Failure
|
|
||||||
console.log("\n[3] Testing Validation Failure (Invalid Movie Duration)...");
|
|
||||||
const invalidJson = JSON.stringify({
|
|
||||||
name: "Invalid Library",
|
|
||||||
items: [
|
|
||||||
{ type: "movie", title: "Short Film", duration: -5 } // Invalid: duration < 1
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
await fromJson(Library, invalidJson);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.log("Caught expected error:", error.message);
|
|
||||||
if ((error as any).errors) {
|
|
||||||
console.log("Validation details:", JSON.stringify((error as any).errors, null, 2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 3. A client cannot set a @JsonReadOnly field
|
||||||
|
console.log('\n[3] A client trying to set the read-only id...');
|
||||||
|
const hijacked = await fromJson(
|
||||||
|
Library,
|
||||||
|
JSON.stringify({ id: 'attacker-supplied', name: 'x', curator_email: 'a@b.c', adminToken: 't', items: [] }),
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
console.log('id after mapping (expected undefined):', hijacked.id);
|
||||||
|
|
||||||
|
// 4. Validation failures, flattened for an HTTP response
|
||||||
|
console.log('\n[4] Reporting validation failures...');
|
||||||
|
const invalid = await fromJson(
|
||||||
|
Library,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'Not A Slug',
|
||||||
|
curator_email: 'a@b.c',
|
||||||
|
adminToken: 't',
|
||||||
|
items: [{ type: 'movie', title: 'Short Film', duration: -5 }]
|
||||||
|
}),
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
console.log(flattenErrors(await validate(invalid)));
|
||||||
|
|
||||||
|
// 5. A base-class rule applies to a subclass that never restates it
|
||||||
|
console.log('\n[5] Base-class constraints reach subclasses...');
|
||||||
|
const untitled = new Book();
|
||||||
|
untitled.title = undefined as any;
|
||||||
|
untitled.author = 'Fitzgerald';
|
||||||
|
untitled.publishedAt = new Date('1925-04-10');
|
||||||
|
console.log(flattenErrors(await validate(untitled)));
|
||||||
|
|
||||||
|
// 6. Naming strategies convert every property at once
|
||||||
|
console.log('\n[6] The same movie under snake_case...');
|
||||||
|
console.log(await toPlain(movie, { namingStrategy: 'snake_case' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
runExample();
|
runExample().catch((error) => {
|
||||||
|
console.error('Example failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
afterEach(() => resetConfig());
|
||||||
|
|
||||||
|
describe('plan caching', () => {
|
||||||
|
// The validation plan for a class is memoized. It must not go stale when metadata is
|
||||||
|
// registered after the class has already been validated once.
|
||||||
|
it('picks up a decorator registered after the first validation', async () => {
|
||||||
|
class Late {
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = new Late();
|
||||||
|
before.value = 'anything';
|
||||||
|
expect(await validate(before)).toEqual([]);
|
||||||
|
|
||||||
|
// Register a rule after the plan has already been built and cached.
|
||||||
|
defineRule(Late, 'value', {
|
||||||
|
name: 'isEven',
|
||||||
|
validate: (v: any) => typeof v === 'number' && v % 2 === 0,
|
||||||
|
message: 'value must be even',
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = new Late();
|
||||||
|
after.value = 'anything';
|
||||||
|
expect(await validate(after)).toHaveLength(1);
|
||||||
|
|
||||||
|
after.value = 4;
|
||||||
|
expect(await validate(after)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps per-class plans separate', async () => {
|
||||||
|
class A {
|
||||||
|
@IsString()
|
||||||
|
v: any;
|
||||||
|
}
|
||||||
|
class B {
|
||||||
|
@IsInt()
|
||||||
|
v: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = new A();
|
||||||
|
a.v = 'text';
|
||||||
|
const b = new B();
|
||||||
|
b.v = 'text';
|
||||||
|
|
||||||
|
expect(await validate(a)).toEqual([]);
|
||||||
|
expect(await validate(b)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one serializer instance rather than constructing per property', async () => {
|
||||||
|
let constructed = 0;
|
||||||
|
class Counting implements JsonSerializer<string, string> {
|
||||||
|
constructor() { constructed++; }
|
||||||
|
serialize(value: string): string { return value.toUpperCase(); }
|
||||||
|
}
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(Counting)
|
||||||
|
a: string;
|
||||||
|
|
||||||
|
@JsonSerialize(Counting)
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.a = 'x';
|
||||||
|
doc.b = 'y';
|
||||||
|
|
||||||
|
await toPlain(doc);
|
||||||
|
await toPlain(doc);
|
||||||
|
await toPlain(doc);
|
||||||
|
|
||||||
|
expect(await toPlain(doc)).toEqual({ a: 'X', b: 'Y' });
|
||||||
|
expect(constructed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours a deserializer after caching', async () => {
|
||||||
|
class ToDate implements JsonDeserializer<string, Date> {
|
||||||
|
deserialize(value: string): Date { return new Date(value); }
|
||||||
|
}
|
||||||
|
class Event {
|
||||||
|
@JsonDeserialize(ToDate)
|
||||||
|
at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const e = await toInstance(Event, { at: '2026-01-01T00:00:00Z' });
|
||||||
|
expect(e.at).toBeInstanceOf(Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maxDepth guard', () => {
|
||||||
|
const nest = (depth: number): any => {
|
||||||
|
let node: any = { value: 'leaf' };
|
||||||
|
for (let i = 0; i < depth; i++) node = { child: node };
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Node)
|
||||||
|
child?: Node;
|
||||||
|
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a payload nested past the limit instead of exhausting the stack', async () => {
|
||||||
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||||
|
.rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toInstance(Node, nest(500), { validate: false }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts nesting within the limit', async () => {
|
||||||
|
const parsed = await toInstance(Node, nest(10), { validate: false });
|
||||||
|
expect(parsed).toBeInstanceOf(Node);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is configurable per call and globally', async () => {
|
||||||
|
await expect(toInstance(Node, nest(10), { validate: false, maxDepth: 3 }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth of 3/);
|
||||||
|
|
||||||
|
configure({ maxDepth: 2 });
|
||||||
|
await expect(toInstance(Node, nest(10), { validate: false }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth of 2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards serialization too', async () => {
|
||||||
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||||
|
await expect(toPlain(deep, { validate: false, maxDepth: 5 }))
|
||||||
|
.rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards validation too', async () => {
|
||||||
|
const deep = await toInstance(Node, nest(30), { validate: false, maxDepth: 200 });
|
||||||
|
await expect(validate(deep, { maxDepth: 5 })).rejects.toThrow(/Maximum nesting depth/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'b', 'a', 'nope' as 'a', 'b'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toContain('failed at index 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a caller-supplied message untouched', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a'], { each: true, message: 'bad tag' })
|
||||||
|
tags!: 'a'[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toBe('bad tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
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'[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'zzz' as 'a'];
|
||||||
|
|
||||||
|
const errors = await validate(basket);
|
||||||
|
expect(errors[0]!.constraints['isIn']).toBe('rejected "zzz"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing when every element passes', async () => {
|
||||||
|
class Basket {
|
||||||
|
@IsIn(['a', 'b'], { each: true })
|
||||||
|
tags!: ('a' | 'b')[];
|
||||||
|
}
|
||||||
|
const basket = new Basket();
|
||||||
|
basket.tags = ['a', 'b'];
|
||||||
|
expect(await validate(basket)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate() accepts options', () => {
|
||||||
|
it('threads maxDepth through nested validation', async () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@IsString()
|
||||||
|
ref: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Item)
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = new Item();
|
||||||
|
bad.qty = -1;
|
||||||
|
const order = new Order();
|
||||||
|
order.ref = 'r';
|
||||||
|
order.items = [bad];
|
||||||
|
|
||||||
|
// Deep enough to be fine at the default, so behaviour is unchanged.
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-15
@@ -41,16 +41,18 @@ class DateDeserializer implements JsonDeserializer<string, Date> {
|
|||||||
|
|
||||||
// --- Domain Models ---
|
// --- Domain Models ---
|
||||||
abstract class Media {
|
abstract class Media {
|
||||||
|
// Standard decorators cannot be applied to an `abstract` member, so the base declares a
|
||||||
|
// concrete field the subclasses override.
|
||||||
@IsString()
|
@IsString()
|
||||||
abstract type: string;
|
type: string = '';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
title: string;
|
title: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
class Book extends Media {
|
class Book extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'book';
|
override type: string = 'book';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
author: string;
|
author: string;
|
||||||
@@ -63,7 +65,7 @@ class Book extends Media {
|
|||||||
|
|
||||||
class Movie extends Media {
|
class Movie extends Media {
|
||||||
@IsString()
|
@IsString()
|
||||||
type: string = 'movie';
|
override type: string = 'movie';
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -162,7 +164,7 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
@ArrayNotEmpty()
|
@ArrayNotEmpty()
|
||||||
@IsIn(['admin', 'user', 'guest'], { each: true })
|
@IsIn(['admin', 'user', 'guest'], { each: true })
|
||||||
roles: string[];
|
roles!: ('admin' | 'user' | 'guest')[];
|
||||||
|
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -202,8 +204,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('username');
|
expect(errors[0]!.property).toBe('username');
|
||||||
expect(errors[0].constraints).toHaveProperty('minLength');
|
expect(errors[0]!.constraints).toHaveProperty('minLength');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail on invalid email', async () => {
|
it('should fail on invalid email', async () => {
|
||||||
@@ -215,8 +217,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('email');
|
expect(errors[0]!.property).toBe('email');
|
||||||
expect(errors[0].constraints).toHaveProperty('isEmail');
|
expect(errors[0]!.constraints).toHaveProperty('isEmail');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail on invalid role (IsIn)', async () => {
|
it('should fail on invalid role (IsIn)', async () => {
|
||||||
@@ -224,12 +226,12 @@ describe('JsonMapper', () => {
|
|||||||
user.username = 'johndoe';
|
user.username = 'johndoe';
|
||||||
user.email = 'john@example.com';
|
user.email = 'john@example.com';
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = ['superadmin'];
|
user.roles = ['superadmin' as 'admin'];
|
||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('roles');
|
expect(errors[0]!.property).toBe('roles');
|
||||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
expect(errors[0]!.constraints).toHaveProperty('isIn');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip validation for null optional field', async () => {
|
it('should skip validation for null optional field', async () => {
|
||||||
@@ -238,7 +240,7 @@ describe('JsonMapper', () => {
|
|||||||
user.email = 'john@example.com';
|
user.email = 'john@example.com';
|
||||||
user.active = true;
|
user.active = true;
|
||||||
user.roles = ['user'];
|
user.roles = ['user'];
|
||||||
user.age = undefined; // optional
|
delete user.age; // optional
|
||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(0);
|
expect(errors).toHaveLength(0);
|
||||||
@@ -254,8 +256,8 @@ describe('JsonMapper', () => {
|
|||||||
|
|
||||||
const errors = await JsonMapper.validate(user);
|
const errors = await JsonMapper.validate(user);
|
||||||
expect(errors).toHaveLength(1);
|
expect(errors).toHaveLength(1);
|
||||||
expect(errors[0].property).toBe('age');
|
expect(errors[0]!.property).toBe('age');
|
||||||
expect(errors[0].constraints).toHaveProperty('min');
|
expect(errors[0]!.constraints).toHaveProperty('min');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export * from './interfaces.js';
|
export * from './interfaces.js';
|
||||||
|
export * from './metadata.js';
|
||||||
|
export * from './naming.js';
|
||||||
|
export * from './config.js';
|
||||||
export * from './decorators.js';
|
export * from './decorators.js';
|
||||||
|
export * from './errors.js';
|
||||||
export * from './utils.js';
|
export * from './utils.js';
|
||||||
|
|||||||
+44
-5
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Interface for custom JSON serializers.
|
* Interface for custom JSON serializers.
|
||||||
*
|
*
|
||||||
* @template T - The type of the value to serialize (usually a class instance or a specific field).
|
* @template T - The type of the value to serialize (usually a class instance or a specific field).
|
||||||
* @template R - The type of the serialized value (usually a string, number, or plain object).
|
* @template R - The type of the serialized value (usually a string, number, or plain object).
|
||||||
*/
|
*/
|
||||||
export interface JsonSerializer<T = any, R = any> {
|
export interface JsonSerializer<T = any, R = any> {
|
||||||
/**
|
/**
|
||||||
* Serializes the value into a representation suitable for JSON output.
|
* Serializes the value into a representation suitable for JSON output.
|
||||||
*
|
*
|
||||||
* @param value - The value to be serialized.
|
* @param value - The value to be serialized.
|
||||||
* @returns The serialized value or a promise resolving to it.
|
* @returns The serialized value or a promise resolving to it.
|
||||||
*/
|
*/
|
||||||
@@ -16,14 +16,14 @@ export interface JsonSerializer<T = any, R = any> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for custom JSON deserializers.
|
* Interface for custom JSON deserializers.
|
||||||
*
|
*
|
||||||
* @template T - The type of the value to deserialize (usually a string or plain object from JSON).
|
* @template T - The type of the value to deserialize (usually a string or plain object from JSON).
|
||||||
* @template R - The type of the deserialized value (usually a class instance or a specific field).
|
* @template R - The type of the deserialized value (usually a class instance or a specific field).
|
||||||
*/
|
*/
|
||||||
export interface JsonDeserializer<T = any, R = any> {
|
export interface JsonDeserializer<T = any, R = any> {
|
||||||
/**
|
/**
|
||||||
* Deserializes the value from a JSON-like representation back to its original type.
|
* Deserializes the value from a JSON-like representation back to its original type.
|
||||||
*
|
*
|
||||||
* @param value - The value to be deserialized.
|
* @param value - The value to be deserialized.
|
||||||
* @returns The deserialized value or a promise resolving to it.
|
* @returns The deserialized value or a promise resolving to it.
|
||||||
*/
|
*/
|
||||||
@@ -32,9 +32,48 @@ export interface JsonDeserializer<T = any, R = any> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a class constructor function.
|
* Represents a class constructor function.
|
||||||
*
|
*
|
||||||
* @template T - The type of the instance created by this constructor.
|
* @template T - The type of the instance created by this constructor.
|
||||||
*/
|
*/
|
||||||
export type ClassConstructor<T> = {
|
export type ClassConstructor<T> = {
|
||||||
new (...args: any[]): 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;
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, IsOptional, ValidateNested, Min,
|
||||||
|
JsonProperty, JsonAlias, JsonIgnore, JsonReadOnly, JsonWriteOnly, JsonType,
|
||||||
|
JsonMappingError, JsonValidationError,
|
||||||
|
toPlain, toJson, toInstance, fromJson, validate, validateOrReject,
|
||||||
|
configure, resetConfig, getConfig,
|
||||||
|
flattenErrors, formatErrors, collectErrorMessages,
|
||||||
|
resolveNamingStrategy,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
afterEach(() => resetConfig());
|
||||||
|
|
||||||
|
describe('@JsonProperty', () => {
|
||||||
|
class User {
|
||||||
|
@JsonProperty('first_name')
|
||||||
|
@IsString()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@JsonProperty('last_name')
|
||||||
|
@IsString()
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renames on the way out', async () => {
|
||||||
|
const u = new User();
|
||||||
|
u.firstName = 'Ada';
|
||||||
|
u.lastName = 'Lovelace';
|
||||||
|
|
||||||
|
await expect(toPlain(u)).resolves.toEqual({ first_name: 'Ada', last_name: 'Lovelace' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames on the way in', async () => {
|
||||||
|
const u = await fromJson(User, '{"first_name":"Ada","last_name":"Lovelace"}');
|
||||||
|
expect(u.firstName).toBe('Ada');
|
||||||
|
expect(u.lastName).toBe('Lovelace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips', async () => {
|
||||||
|
const json = '{"first_name":"Ada","last_name":"Lovelace"}';
|
||||||
|
expect(await toJson(await fromJson(User, json))).toBe(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no longer accepts the raw property name once renamed', async () => {
|
||||||
|
const u = await toInstance(
|
||||||
|
User,
|
||||||
|
{ firstName: 'Ada', last_name: 'L' },
|
||||||
|
{ unknownKeys: 'strip', validate: false }
|
||||||
|
);
|
||||||
|
expect(u.firstName).toBeUndefined();
|
||||||
|
expect(u.lastName).toBe('L');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects two properties claiming the same JSON name', async () => {
|
||||||
|
class Clash {
|
||||||
|
@JsonProperty('name')
|
||||||
|
a: string;
|
||||||
|
|
||||||
|
@JsonProperty('name')
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toInstance(Clash, { name: 'x' })).rejects.toThrow(/both map to the JSON name/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@JsonAlias', () => {
|
||||||
|
class Person {
|
||||||
|
@JsonProperty('surname')
|
||||||
|
@JsonAlias('last_name', 'lastName')
|
||||||
|
@IsString()
|
||||||
|
surname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts every alias on input', async () => {
|
||||||
|
for (const key of ['surname', 'last_name', 'lastName']) {
|
||||||
|
const p = await toInstance(Person, { [key]: 'Hopper' });
|
||||||
|
expect(p.surname).toBe('Hopper');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never emits an alias on output', async () => {
|
||||||
|
const p = new Person();
|
||||||
|
p.surname = 'Hopper';
|
||||||
|
await expect(toPlain(p)).resolves.toEqual({ surname: 'Hopper' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('access control decorators', () => {
|
||||||
|
it('@JsonIgnore drops the property in both directions', async () => {
|
||||||
|
class Secretive {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@JsonIgnore()
|
||||||
|
internalNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = new Secretive();
|
||||||
|
s.name = 'x';
|
||||||
|
s.internalNote = 'do not leak';
|
||||||
|
await expect(toPlain(s)).resolves.toEqual({ name: 'x' });
|
||||||
|
|
||||||
|
const parsed = await toInstance(Secretive, { name: 'x', internalNote: 'injected' });
|
||||||
|
expect(parsed.internalNote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonWriteOnly accepts input but never echoes it back', async () => {
|
||||||
|
class Credentials {
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = await toInstance(Credentials, { email: 'a@b.com', password: 'hunter2' });
|
||||||
|
expect(c.password).toBe('hunter2');
|
||||||
|
await expect(toPlain(c)).resolves.toEqual({ email: 'a@b.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonReadOnly is emitted but cannot be set by a client', async () => {
|
||||||
|
class Record {
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await toInstance(Record, { id: 999, title: 'hello' });
|
||||||
|
expect(r.id).toBeUndefined();
|
||||||
|
|
||||||
|
r.id = 1;
|
||||||
|
await expect(toPlain(r)).resolves.toEqual({ id: 1, title: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonReadOnly is not resurrected by the default unknownKeys policy', async () => {
|
||||||
|
class Record {
|
||||||
|
@JsonProperty('identifier')
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await toInstance(Record, { identifier: 999, title: 't' }, { unknownKeys: 'allow' });
|
||||||
|
expect(r.id).toBeUndefined();
|
||||||
|
expect((r as any).identifier).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('naming strategies', () => {
|
||||||
|
class Account {
|
||||||
|
@IsString()
|
||||||
|
accountHolderName: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
balanceInCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('snake_case both ways', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 100;
|
||||||
|
|
||||||
|
const plain = await toPlain(a, { namingStrategy: 'snake_case' });
|
||||||
|
expect(plain).toEqual({ account_holder_name: 'Ada', balance_in_cents: 100 });
|
||||||
|
|
||||||
|
const back = await toInstance(Account, plain, { namingStrategy: 'snake_case' });
|
||||||
|
expect(back.accountHolderName).toBe('Ada');
|
||||||
|
expect(back.balanceInCents).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kebab-case, SCREAMING_SNAKE_CASE and PascalCase', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 1;
|
||||||
|
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||||
|
.resolves.toEqual({ 'account-holder-name': 'Ada', 'balance-in-cents': 1 });
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'SCREAMING_SNAKE_CASE' }))
|
||||||
|
.resolves.toEqual({ ACCOUNT_HOLDER_NAME: 'Ada', BALANCE_IN_CENTS: 1 });
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'PascalCase' }))
|
||||||
|
.resolves.toEqual({ AccountHolderName: 'Ada', BalanceInCents: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom function', async () => {
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
a.balanceInCents = 1;
|
||||||
|
|
||||||
|
await expect(toPlain(a, { namingStrategy: (k) => `x_${k}` }))
|
||||||
|
.resolves.toEqual({ x_accountHolderName: 'Ada', x_balanceInCents: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@JsonProperty wins over the naming strategy', async () => {
|
||||||
|
class Mixed {
|
||||||
|
@JsonProperty('EXPLICIT')
|
||||||
|
someField: string;
|
||||||
|
|
||||||
|
otherField: string;
|
||||||
|
}
|
||||||
|
const m = new Mixed();
|
||||||
|
m.someField = 'a';
|
||||||
|
m.otherField = 'b';
|
||||||
|
|
||||||
|
await expect(toPlain(m, { namingStrategy: 'snake_case' }))
|
||||||
|
.resolves.toEqual({ EXPLICIT: 'a', other_field: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits acronyms the way a reader expects', () => {
|
||||||
|
const snake = resolveNamingStrategy('snake_case');
|
||||||
|
expect(snake('parseHTTPResponse')).toBe('parse_http_response');
|
||||||
|
expect(snake('firstName')).toBe('first_name');
|
||||||
|
expect(snake('id')).toBe('id');
|
||||||
|
expect(snake('already_snake')).toBe('already_snake');
|
||||||
|
|
||||||
|
const camel = resolveNamingStrategy('camelCase');
|
||||||
|
expect(camel('first_name')).toBe('firstName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown strategy name', () => {
|
||||||
|
expect(() => resolveNamingStrategy('shouty' as any)).toThrow(/Unknown naming strategy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to nested objects too', async () => {
|
||||||
|
class Inner {
|
||||||
|
@IsString()
|
||||||
|
innerValue: string;
|
||||||
|
}
|
||||||
|
class Outer {
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Inner)
|
||||||
|
outerChild: Inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = await toInstance(
|
||||||
|
Outer,
|
||||||
|
{ outer_child: { inner_value: 'v' } },
|
||||||
|
{ namingStrategy: 'snake_case' }
|
||||||
|
);
|
||||||
|
expect(parsed.outerChild).toBeInstanceOf(Inner);
|
||||||
|
expect(parsed.outerChild.innerValue).toBe('v');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configure()', () => {
|
||||||
|
class Account {
|
||||||
|
@IsString()
|
||||||
|
accountHolderName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sets a library-wide default', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case' });
|
||||||
|
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
await expect(toPlain(a)).resolves.toEqual({ account_holder_name: 'Ada' });
|
||||||
|
expect(getConfig().namingStrategy).toBe('snake_case');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is overridden by per-call options', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case' });
|
||||||
|
|
||||||
|
const a = new Account();
|
||||||
|
a.accountHolderName = 'Ada';
|
||||||
|
await expect(toPlain(a, { namingStrategy: 'kebab-case' }))
|
||||||
|
.resolves.toEqual({ 'account-holder-name': 'Ada' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetConfig() restores the defaults', async () => {
|
||||||
|
configure({ namingStrategy: 'snake_case', unknownKeys: 'error', validate: false });
|
||||||
|
resetConfig();
|
||||||
|
expect(getConfig()).toEqual({
|
||||||
|
namingStrategy: 'identity',
|
||||||
|
unknownKeys: 'allow',
|
||||||
|
validate: true,
|
||||||
|
maxDepth: 64,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknownKeys policy', () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
known: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('allow (default) copies unknown keys through', async () => {
|
||||||
|
const d = await toInstance(Dto, { known: 'a', extra: 'b' });
|
||||||
|
expect((d as any).extra).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strip drops them', async () => {
|
||||||
|
const d = await toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'strip' });
|
||||||
|
expect((d as any).extra).toBeUndefined();
|
||||||
|
expect(d.known).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error rejects the payload and names the offending key', async () => {
|
||||||
|
await expect(toInstance(Dto, { known: 'a', extra: 'b' }, { unknownKeys: 'error' }))
|
||||||
|
.rejects.toThrow(/Unknown property "extra"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets __proto__ through, whatever the policy', async () => {
|
||||||
|
for (const unknownKeys of ['allow', 'strip', 'error'] as const) {
|
||||||
|
const d = await toInstance(Dto, JSON.parse('{"known":"a","__proto__":{"x":1}}'), { unknownKeys });
|
||||||
|
expect(Object.getPrototypeOf(d)).toBe(Dto.prototype);
|
||||||
|
expect(({} as any).x).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate option', () => {
|
||||||
|
class Strict {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
age?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws by default', async () => {
|
||||||
|
await expect(toInstance(Strict, { name: 123 })).rejects.toThrow(JsonValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps without validating when told to', async () => {
|
||||||
|
const s = await toInstance(Strict, { name: 123 }, { validate: false });
|
||||||
|
expect(s).toBeInstanceOf(Strict);
|
||||||
|
expect(s.name).toBe(123 as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips validation on the way out too', async () => {
|
||||||
|
const s = new Strict();
|
||||||
|
s.name = 123 as any;
|
||||||
|
await expect(toPlain(s, { validate: false })).resolves.toEqual({ name: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateOrReject throws, validate returns', async () => {
|
||||||
|
const s = new Strict();
|
||||||
|
s.name = 123 as any;
|
||||||
|
|
||||||
|
await expect(validateOrReject(s)).rejects.toThrow(JsonValidationError);
|
||||||
|
await expect(validate(s)).resolves.toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error helpers', () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@IsString()
|
||||||
|
reference: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Item)
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFailing = () => {
|
||||||
|
const bad = new Item();
|
||||||
|
bad.qty = -5;
|
||||||
|
const o = new Order();
|
||||||
|
o.reference = 42 as any;
|
||||||
|
o.items = [bad];
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('flattenErrors produces dotted paths with array indices', async () => {
|
||||||
|
const errors = await validate(buildFailing());
|
||||||
|
const flat = flattenErrors(errors);
|
||||||
|
|
||||||
|
expect(flat['reference']).toEqual(['reference must be a string']);
|
||||||
|
expect(flat['items[0].qty']).toEqual(['qty must be at least 1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatErrors renders one line per failure', async () => {
|
||||||
|
const errors = await validate(buildFailing());
|
||||||
|
const text = formatErrors(errors);
|
||||||
|
|
||||||
|
expect(text).toContain('reference: reference must be a string');
|
||||||
|
expect(text).toContain('items[0].qty: qty must be at least 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collectErrorMessages returns just the messages', async () => {
|
||||||
|
const messages = collectErrorMessages(await validate(buildFailing()));
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages).toContain('qty must be at least 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty result for a valid object', async () => {
|
||||||
|
const o = new Order();
|
||||||
|
o.reference = 'ok';
|
||||||
|
o.items = [];
|
||||||
|
expect(flattenErrors(await validate(o))).toEqual({});
|
||||||
|
expect(formatErrors(await validate(o))).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a realistic API payload', () => {
|
||||||
|
it('maps a snake_case request and answers without the secret', async () => {
|
||||||
|
class SignUp {
|
||||||
|
@JsonReadOnly()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@JsonProperty('email_address')
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
id: 999, // client must not be able to set this
|
||||||
|
email_address: 'ada@example.com',
|
||||||
|
password: 'hunter2',
|
||||||
|
display_name: 'Ada',
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUp = await fromJson(SignUp, body, { namingStrategy: 'snake_case' });
|
||||||
|
expect(signUp.id).toBeUndefined();
|
||||||
|
expect(signUp.email).toBe('ada@example.com');
|
||||||
|
expect(signUp.password).toBe('hunter2');
|
||||||
|
expect(signUp.displayName).toBe('Ada');
|
||||||
|
|
||||||
|
signUp.id = 1;
|
||||||
|
const response = await toJson(signUp, { namingStrategy: 'snake_case' });
|
||||||
|
expect(JSON.parse(response)).toEqual({
|
||||||
|
id: 1,
|
||||||
|
email_address: 'ada@example.com',
|
||||||
|
display_name: 'Ada',
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
export class MetadataStorage {
|
|
||||||
private static instance: MetadataStorage;
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
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();
|
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
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: tsc's decorator emit reads `Symbol.metadata` directly rather than
|
||||||
|
// falling back the way we do — `typeof Symbol === "function" && Symbol.metadata ? … : void 0` —
|
||||||
|
// so without this a decorated class gets `metadata: undefined` and no rules at all.
|
||||||
|
//
|
||||||
|
// `sideEffects` in package.json keeps the statement through bundling, and must name `index.*`
|
||||||
|
// as well as this module: marking only this one leaves the barrel droppable, so the edge to it
|
||||||
|
// is pruned before this marking is ever read. Pinned by treeshake.test.ts.
|
||||||
|
((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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Translates a class property name into the name used in JSON.
|
||||||
|
*
|
||||||
|
* Applied only to properties that do not carry an explicit `@JsonProperty`, which always wins.
|
||||||
|
*/
|
||||||
|
export type NamingStrategyFn = (propertyKey: string) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A built-in strategy name, or your own function.
|
||||||
|
*
|
||||||
|
* The built-ins assume property names are written in the TypeScript convention (camelCase)
|
||||||
|
* and convert away from it.
|
||||||
|
*/
|
||||||
|
export type NamingStrategy =
|
||||||
|
| 'identity'
|
||||||
|
| 'camelCase'
|
||||||
|
| 'PascalCase'
|
||||||
|
| 'snake_case'
|
||||||
|
| 'SCREAMING_SNAKE_CASE'
|
||||||
|
| 'kebab-case'
|
||||||
|
| NamingStrategyFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits an identifier into lowercase words.
|
||||||
|
*
|
||||||
|
* Handles the two boundaries that matter in practice: a lowercase-or-digit followed by an
|
||||||
|
* uppercase (`firstName`), and an acronym running into a new word (`parseHTTPResponse`,
|
||||||
|
* where the split belongs before `Response`, not inside `HTTP`). Existing separators are
|
||||||
|
* treated as boundaries too, so an already-converted name survives a second pass unchanged.
|
||||||
|
*/
|
||||||
|
function words(propertyKey: string): string[] {
|
||||||
|
return propertyKey
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/[_\-\s]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(word => word.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (word: string): string => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word);
|
||||||
|
|
||||||
|
const BUILT_INS: Record<Exclude<NamingStrategy, NamingStrategyFn>, NamingStrategyFn> = {
|
||||||
|
identity: (key) => key,
|
||||||
|
camelCase: (key) => {
|
||||||
|
const parts = words(key);
|
||||||
|
if (parts.length === 0) return key;
|
||||||
|
return parts[0] + parts.slice(1).map(capitalize).join('');
|
||||||
|
},
|
||||||
|
PascalCase: (key) => words(key).map(capitalize).join('') || key,
|
||||||
|
snake_case: (key) => words(key).join('_') || key,
|
||||||
|
SCREAMING_SNAKE_CASE: (key) => words(key).join('_').toUpperCase() || key,
|
||||||
|
'kebab-case': (key) => words(key).join('-') || key,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a {@link NamingStrategy} to the function that implements it.
|
||||||
|
*
|
||||||
|
* @throws Error when given a name that is not one of the built-in strategies.
|
||||||
|
*/
|
||||||
|
export function resolveNamingStrategy(strategy: NamingStrategy | undefined): NamingStrategyFn {
|
||||||
|
if (!strategy) return BUILT_INS.identity;
|
||||||
|
if (typeof strategy === 'function') return strategy;
|
||||||
|
|
||||||
|
const builtIn = BUILT_INS[strategy];
|
||||||
|
if (!builtIn) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown naming strategy ${JSON.stringify(strategy)}. ` +
|
||||||
|
`Use one of: ${Object.keys(BUILT_INS).join(', ')}, or pass your own function.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return builtIn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, Min, MinLength, Matches, IsIn, ValidateNested, JsonType,
|
||||||
|
JsonPolymorphic, JsonSerialize, JsonSerializer, JsonMappingError,
|
||||||
|
toInstance, toInstanceArray, toPlain, toJson, fromJson, fromJsonArray, fromRequest, validate,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each block here pins down a defect that the engine used to have. The comment above the
|
||||||
|
* block describes the old, wrong behaviour.
|
||||||
|
*/
|
||||||
|
describe('regressions', () => {
|
||||||
|
describe('inheritance', () => {
|
||||||
|
// Was: a subclass re-decorating an inherited property registered its constraints on its
|
||||||
|
// own prototype, and the engine read only the nearest set — so every rule the base class
|
||||||
|
// declared was silently dropped.
|
||||||
|
it('merges validation constraints across the prototype chain', async () => {
|
||||||
|
class Base {
|
||||||
|
@MinLength(5)
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
class Sub extends Base {
|
||||||
|
@IsString()
|
||||||
|
override name: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = new Sub();
|
||||||
|
s.name = 'ab'; // satisfies Sub's @IsString, violates Base's @MinLength(5)
|
||||||
|
|
||||||
|
const errors = await validate(s);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.constraints).toHaveProperty('minLength');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces base constraints that the subclass never restates', async () => {
|
||||||
|
abstract class Media {
|
||||||
|
@IsString()
|
||||||
|
title: string = '';
|
||||||
|
}
|
||||||
|
class Book extends Media {
|
||||||
|
@IsString()
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = new Book();
|
||||||
|
b.title = 42 as any;
|
||||||
|
b.author = 'Fitzgerald';
|
||||||
|
|
||||||
|
const errors = await validate(b);
|
||||||
|
expect(errors.map(e => e.property)).toContain('title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not report an identical inherited rule twice', async () => {
|
||||||
|
class Base {
|
||||||
|
@IsString()
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
class Sub extends Base {
|
||||||
|
@IsString()
|
||||||
|
override type: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = new Sub();
|
||||||
|
s.type = 1 as any;
|
||||||
|
|
||||||
|
const errors = await validate(s);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(Object.keys(errors[0]!.constraints)).toEqual(['isString']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cycles', () => {
|
||||||
|
// Was: serialize() recursed forever on a cycle, exhausting an 8 GB heap and killing the
|
||||||
|
// process. A clear error beats an OOM.
|
||||||
|
it('reports a circular reference instead of exhausting the heap', async () => {
|
||||||
|
class Node {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
next?: any;
|
||||||
|
}
|
||||||
|
const a = new Node();
|
||||||
|
a.name = 'a';
|
||||||
|
a.next = a;
|
||||||
|
|
||||||
|
await expect(toPlain(a)).rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toPlain(a)).rejects.toThrow(/Circular reference/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still serializes a diamond, where one object is referenced twice', async () => {
|
||||||
|
class Leaf {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
class Holder {
|
||||||
|
left: Leaf;
|
||||||
|
right: Leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = new Leaf();
|
||||||
|
shared.id = 'shared';
|
||||||
|
const h = new Holder();
|
||||||
|
h.left = shared;
|
||||||
|
h.right = shared;
|
||||||
|
|
||||||
|
const plain = await toPlain(h);
|
||||||
|
expect(plain).toEqual({ left: { id: 'shared' }, right: { id: 'shared' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminates when validating a cyclic @ValidateNested graph', async () => {
|
||||||
|
class Person {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
friend?: Person;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = new Person();
|
||||||
|
a.name = 'a';
|
||||||
|
const b = new Person();
|
||||||
|
b.name = 'b';
|
||||||
|
a.friend = b;
|
||||||
|
b.friend = a;
|
||||||
|
|
||||||
|
await expect(validate(a)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('messages', () => {
|
||||||
|
// Was: the "each element in ..." prefix was glued onto every message, including ones the
|
||||||
|
// caller wrote, producing "each element in tags must all be strings".
|
||||||
|
it('reports a caller-supplied message verbatim under each:true', async () => {
|
||||||
|
class T {
|
||||||
|
@IsString({ each: true, message: 'tags must all be strings' })
|
||||||
|
tags: any[];
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.tags = [1];
|
||||||
|
|
||||||
|
const errors = await validate(t);
|
||||||
|
expect(errors[0]!.constraints['isString']).toBe('tags must all be strings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prefixes the library default message under each:true', async () => {
|
||||||
|
class T {
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags: any[];
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.tags = [1];
|
||||||
|
|
||||||
|
const errors = await validate(t);
|
||||||
|
expect(errors[0]!.constraints['isString']).toContain('each element in');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Was: two constraints sharing a name overwrote each other in the error record, so only
|
||||||
|
// the last failure was ever reported.
|
||||||
|
it('keeps every failure when two rules share a name', async () => {
|
||||||
|
class T {
|
||||||
|
@Min(10)
|
||||||
|
@Min(5)
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.n = 1;
|
||||||
|
|
||||||
|
const errors = await validate(t);
|
||||||
|
const messages = Object.values(errors[0]!.constraints);
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages).toEqual(expect.arrayContaining([
|
||||||
|
'n must be at least 5',
|
||||||
|
'n must be at least 10',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@Matches', () => {
|
||||||
|
// Was: a /g regex kept its lastIndex between calls, so validating the same value twice
|
||||||
|
// gave different answers — the second call spuriously failed.
|
||||||
|
it('is stateless when the pattern carries a g flag', async () => {
|
||||||
|
class T {
|
||||||
|
@Matches(/^[a-z]+$/g)
|
||||||
|
v: string;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.v = 'abc';
|
||||||
|
|
||||||
|
expect(await validate(t)).toHaveLength(0);
|
||||||
|
expect(await validate(t)).toHaveLength(0);
|
||||||
|
expect(await validate(t)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stateless when the pattern carries a y flag', async () => {
|
||||||
|
class T {
|
||||||
|
@Matches(/^[a-z]+$/y)
|
||||||
|
v: string;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.v = 'abc';
|
||||||
|
|
||||||
|
expect(await validate(t)).toHaveLength(0);
|
||||||
|
expect(await validate(t)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@JsonPolymorphic', () => {
|
||||||
|
abstract class Animal {
|
||||||
|
@IsString()
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
class Dog extends Animal {
|
||||||
|
@IsString()
|
||||||
|
breed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was: when the discriminator matched no subtype, the single-object branch fell through
|
||||||
|
// without assigning anything, so the property came back `undefined` and the caller's data
|
||||||
|
// vanished without a word.
|
||||||
|
it('keeps the raw value when the discriminator matches nothing', async () => {
|
||||||
|
class Holder {
|
||||||
|
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
|
||||||
|
pet: Animal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = await toInstance(Holder, { pet: { type: 'cat', sound: 'meow' } });
|
||||||
|
expect(h.pet).toBeDefined();
|
||||||
|
expect(h.pet).toEqual({ type: 'cat', sound: 'meow' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be told to reject an unknown discriminator instead', async () => {
|
||||||
|
class Holder {
|
||||||
|
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { onUnknown: 'error' })
|
||||||
|
pet: Animal;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(toInstance(Holder, { pet: { type: 'cat' } })).rejects.toThrow(/Unknown discriminator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can fall back to a default subtype', async () => {
|
||||||
|
class Unknown extends Animal {
|
||||||
|
@IsString()
|
||||||
|
override type = 'unknown';
|
||||||
|
}
|
||||||
|
class Holder {
|
||||||
|
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }], { fallback: Unknown })
|
||||||
|
pet: Animal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = await toInstance(Holder, { pet: { type: 'cat' } });
|
||||||
|
expect(h.pet).toBeInstanceOf(Unknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unmatched entries inside an array', async () => {
|
||||||
|
class Holder {
|
||||||
|
@JsonPolymorphic('type', [{ value: Dog, name: 'dog' }])
|
||||||
|
pets: Animal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = await toInstance(Holder, {
|
||||||
|
pets: [{ type: 'dog', breed: 'Lab' }, { type: 'cat', sound: 'meow' }],
|
||||||
|
});
|
||||||
|
expect(h.pets[0]).toBeInstanceOf(Dog);
|
||||||
|
expect(h.pets[1]).toEqual({ type: 'cat', sound: 'meow' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prototype handling', () => {
|
||||||
|
// Was: serialize() read `obj.constructor.prototype`, which throws for an object created
|
||||||
|
// with a null prototype because it has no `constructor`.
|
||||||
|
it('serializes a null-prototype object', async () => {
|
||||||
|
const o = Object.create(null);
|
||||||
|
o.a = 1;
|
||||||
|
o.b = { c: 2 };
|
||||||
|
|
||||||
|
await expect(toPlain(o)).resolves.toEqual({ a: 1, b: { c: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Was: `__proto__` arriving in a JSON body was copied straight onto the instance, which
|
||||||
|
// swaps the instance's prototype and detaches it from its own class.
|
||||||
|
it('drops __proto__ from untrusted input', async () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const malicious = JSON.parse('{"name":"x","__proto__":{"polluted":"yes"}}');
|
||||||
|
const dto = await toInstance(Dto, malicious);
|
||||||
|
|
||||||
|
expect(dto).toBeInstanceOf(Dto);
|
||||||
|
expect(Object.getPrototypeOf(dto)).toBe(Dto.prototype);
|
||||||
|
expect(({} as any).polluted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops constructor and prototype keys from untrusted input', async () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = await toInstance(Dto, JSON.parse('{"name":"x","constructor":1,"prototype":2}'));
|
||||||
|
expect(dto.constructor).toBe(Dto);
|
||||||
|
expect((dto as any).prototype).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom serializers', () => {
|
||||||
|
// Was: a @JsonSerialize serializer was invoked even when the property was null or
|
||||||
|
// undefined, so any serializer that touched the value crashed on an unset optional field.
|
||||||
|
it('is skipped for an unset optional property', async () => {
|
||||||
|
class IsoDate implements JsonSerializer<Date, string> {
|
||||||
|
serialize(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class T {
|
||||||
|
@JsonSerialize(IsoDate)
|
||||||
|
when?: Date | undefined;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
other: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = new T();
|
||||||
|
t.other = 'x';
|
||||||
|
t.when = undefined;
|
||||||
|
|
||||||
|
await expect(toPlain(t)).resolves.toEqual({ when: undefined, other: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still runs for a property that has a value', async () => {
|
||||||
|
class IsoDate implements JsonSerializer<Date, string> {
|
||||||
|
serialize(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class T {
|
||||||
|
@JsonSerialize(IsoDate)
|
||||||
|
when: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = new T();
|
||||||
|
t.when = new Date('1925-04-10T00:00:00Z');
|
||||||
|
|
||||||
|
await expect(toJson(t)).resolves.toBe('{"when":"1925-04-10"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('array entry points', () => {
|
||||||
|
class Item {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was: `toInstance`/`fromJson` accepted arrays at runtime but typed the result as `T`,
|
||||||
|
// so consumers had to cast to reach the elements.
|
||||||
|
it('toInstanceArray returns a correctly typed array', async () => {
|
||||||
|
const items = await toInstanceArray(Item, [{ name: 'a' }, { name: 'b' }]);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]).toBeInstanceOf(Item);
|
||||||
|
expect(items[0]!.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fromJsonArray parses and validates a JSON array', async () => {
|
||||||
|
const items = await fromJsonArray(Item, '[{"name":"a"}]');
|
||||||
|
expect(items[0]!.name).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toInstanceArray rejects a non-array payload', async () => {
|
||||||
|
await expect(toInstanceArray(Item, {} as any)).rejects.toThrow(JsonMappingError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fromJson still accepts an array for backwards compatibility', async () => {
|
||||||
|
const items = (await fromJson(Item, '[{"name":"a"}]')) as unknown as Item[];
|
||||||
|
expect(Array.isArray(items)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromRequest', () => {
|
||||||
|
it('reports a non-JSON body as a mapping error', async () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
const request = new Request('https://example.com', { method: 'POST', body: 'not json' });
|
||||||
|
|
||||||
|
await expect(fromRequest(Dto, request)).rejects.toThrow(JsonMappingError);
|
||||||
|
await expect(
|
||||||
|
fromRequest(Dto, new Request('https://example.com', { method: 'POST', body: '' }))
|
||||||
|
).rejects.toThrow(/not valid JSON/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@ValidateNested', () => {
|
||||||
|
it('accepts the documented { each: true } option', async () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@JsonType(() => Item)
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = new Item();
|
||||||
|
bad.qty = -5;
|
||||||
|
const o = new Order();
|
||||||
|
o.items = [bad];
|
||||||
|
|
||||||
|
const errors = await validate(o);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.children?.[0]?.children?.[0]?.property).toBe('qty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{ each: true } asserts the value really is an array', async () => {
|
||||||
|
class Item {
|
||||||
|
@IsInt()
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
items: Item[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = new Order();
|
||||||
|
o.items = 'nope' as any;
|
||||||
|
|
||||||
|
const errors = await validate(o);
|
||||||
|
expect(errors[0]!.constraints).toHaveProperty('nestedEach');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@IsIn with each:true', () => {
|
||||||
|
it('rejects a non-array value rather than passing it through', async () => {
|
||||||
|
class T {
|
||||||
|
@IsIn(['a', 'b'], { each: true })
|
||||||
|
tags: any;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.tags = 'not-allowed';
|
||||||
|
|
||||||
|
expect(await validate(t)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, JsonIgnore, JsonSerialize, JsonSerializer, JsonMappingError,
|
||||||
|
toPlain, toPlainSync, defineRule, modelOf, validateSync,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before 0.3.0 every case in this file produced `{}` (or index-keyed noise, or a bigint that
|
||||||
|
* made the caller's own `JSON.stringify` throw somewhere unrelated) with nothing logged and
|
||||||
|
* no error raised. A mapping layer that loses data quietly is worse than one that stops.
|
||||||
|
*/
|
||||||
|
describe('values JSON cannot carry', () => {
|
||||||
|
class Basket {
|
||||||
|
// Typed loosely on purpose: the point is what happens at runtime, and the decorators are
|
||||||
|
// deliberately absent so nothing is claiming to handle these.
|
||||||
|
items: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withItems = (items: unknown) => Object.assign(new Basket(), { items });
|
||||||
|
|
||||||
|
const cases: [string, unknown, RegExp][] = [
|
||||||
|
['a Map', new Map([['a', 1]]), /is a Map/],
|
||||||
|
['a Set', new Set([1, 2]), /is a Set/],
|
||||||
|
['a WeakMap', new WeakMap(), /is a WeakMap/],
|
||||||
|
['a WeakSet', new WeakSet(), /is a WeakSet/],
|
||||||
|
['a Promise', Promise.resolve(1), /is a Promise/],
|
||||||
|
['a RegExp', /abc/g, /is a RegExp/],
|
||||||
|
['an Error', new Error('boom'), /is an Error/],
|
||||||
|
['a TypeError', new TypeError('boom'), /is an Error/],
|
||||||
|
['an ArrayBuffer', new ArrayBuffer(8), /is an ArrayBuffer/],
|
||||||
|
['a DataView', new DataView(new ArrayBuffer(8)), /is a DataView/],
|
||||||
|
['a Uint8Array', new Uint8Array([1, 2, 3]), /is a Uint8Array/],
|
||||||
|
['a Float64Array', new Float64Array([1.5]), /is a Float64Array/],
|
||||||
|
['a bigint', 10n, /is a bigint/],
|
||||||
|
['a symbol', Symbol('x'), /is a symbol/],
|
||||||
|
['a function', () => 1, /is a function/],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, value, expected] of cases) {
|
||||||
|
it(`refuses ${label}`, () => {
|
||||||
|
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(JsonMappingError);
|
||||||
|
expect(() => toPlainSync(withItems(value), { validate: false })).toThrow(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('names the property in the message and points at the way out', () => {
|
||||||
|
expect(() => toPlainSync(withItems(new Map()), { validate: false }))
|
||||||
|
.toThrow(/items is a Map.*@JsonSerialize\(\).*@JsonIgnore\(\)/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the full path through nested objects and arrays', () => {
|
||||||
|
class Line { tags: any }
|
||||||
|
class Order { lines: any }
|
||||||
|
const order = Object.assign(new Order(), {
|
||||||
|
lines: [Object.assign(new Line(), { tags: [] }), Object.assign(new Line(), { tags: [new Set(['a'])] })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => toPlainSync(order, { validate: false })).toThrow(/lines\[1\]\.tags\[0\] is a Set/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the root when the offending value is the argument itself', () => {
|
||||||
|
expect(() => toPlainSync(new Map(), { validate: false })).toThrow(/the value passed in is a Map/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows the built-ins that do map cleanly', () => {
|
||||||
|
class Fine {
|
||||||
|
when = new Date('2024-01-01T00:00:00.000Z');
|
||||||
|
list = [1, 'two', true, null];
|
||||||
|
nested = { deep: { deeper: [{ ok: true }] } };
|
||||||
|
empty = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toPlainSync(new Fine(), { validate: false })).toEqual({
|
||||||
|
when: '2024-01-01T00:00:00.000Z',
|
||||||
|
list: [1, 'two', true, null],
|
||||||
|
nested: { deep: { deeper: [{ ok: true }] } },
|
||||||
|
empty: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Map once a serializer converts it', () => {
|
||||||
|
class TagsSerializer implements JsonSerializer<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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
IsString, IsInt, Min, MinLength, IsIn, ValidateNested, JsonType, JsonProperty,
|
||||||
|
JsonSerialize, JsonDeserialize, JsonSerializer, JsonDeserializer,
|
||||||
|
JsonIgnore, JsonWriteOnly, Validate, JsonMappingError, JsonValidationError, REDACTED,
|
||||||
|
validate, validateSync, validateOrReject, validateOrRejectSync,
|
||||||
|
toPlain, toPlainSync, toJson, toJsonSync,
|
||||||
|
toInstance, toInstanceSync, toInstanceArray, toInstanceArraySync,
|
||||||
|
fromJsonSync, fromJsonArraySync,
|
||||||
|
flattenErrors,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
class Upper implements JsonSerializer<string, string> {
|
||||||
|
serialize(value: string): string { return value.toUpperCase(); }
|
||||||
|
}
|
||||||
|
class Lower implements JsonDeserializer<string, string> {
|
||||||
|
deserialize(value: string): string { return value.toLowerCase(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
@JsonProperty('display_name')
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('synchronous API', () => {
|
||||||
|
it('toInstanceSync / fromJsonSync map and validate without a Promise', () => {
|
||||||
|
const user = toInstanceSync(User, { display_name: 'Ada', age: 36 });
|
||||||
|
expect(user).toBeInstanceOf(User);
|
||||||
|
expect(user.displayName).toBe('Ada');
|
||||||
|
|
||||||
|
const parsed = fromJsonSync(User, '{"display_name":"Ada","age":36}');
|
||||||
|
expect(parsed.displayName).toBe('Ada');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toPlainSync / toJsonSync round-trip', () => {
|
||||||
|
const user = new User();
|
||||||
|
user.displayName = 'Ada';
|
||||||
|
user.age = 36;
|
||||||
|
|
||||||
|
expect(toPlainSync(user)).toEqual({ display_name: 'Ada', age: 36 });
|
||||||
|
expect(toJsonSync(user)).toBe('{"display_name":"Ada","age":36}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateSync returns the same errors as validate', async () => {
|
||||||
|
const user = new User();
|
||||||
|
user.displayName = 'A';
|
||||||
|
user.age = -1;
|
||||||
|
|
||||||
|
const sync = validateSync(user);
|
||||||
|
const async = await validate(user);
|
||||||
|
expect(flattenErrors(sync)).toEqual(flattenErrors(async));
|
||||||
|
expect(Object.keys(flattenErrors(sync))).toEqual(['displayName', 'age']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws JsonValidationError on invalid input, like the async form', () => {
|
||||||
|
expect(() => toInstanceSync(User, { display_name: 'A', age: 5 })).toThrow(JsonValidationError);
|
||||||
|
expect(() => validateOrRejectSync(Object.assign(new User(), { displayName: 'A', age: 1 })))
|
||||||
|
.toThrow(JsonValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours options', () => {
|
||||||
|
const lenient = toInstanceSync(User, { display_name: 'A', age: -1 }, { validate: false });
|
||||||
|
expect(lenient.displayName).toBe('A');
|
||||||
|
|
||||||
|
expect(() => toInstanceSync(User, { display_name: 'Ada', age: 1, stray: 1 }, { unknownKeys: 'error' }))
|
||||||
|
.toThrow(/Unknown property "stray"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('array entry points work synchronously', () => {
|
||||||
|
class Item {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
expect(toInstanceArraySync(Item, [{ name: 'a' }])[0]!.name).toBe('a');
|
||||||
|
expect(fromJsonArraySync(Item, '[{"name":"b"}]')[0]!.name).toBe('b');
|
||||||
|
expect(() => toInstanceArraySync(Item, {} as any)).toThrow(JsonMappingError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs synchronous custom serializers and deserializers', () => {
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(Upper)
|
||||||
|
@JsonDeserialize(Lower)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const doc = toInstanceSync(Doc, { code: 'ABC' }, { validate: false });
|
||||||
|
expect(doc.code).toBe('abc');
|
||||||
|
expect(toPlainSync(doc)).toEqual({ code: 'ABC' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nesting, cycles and depth the same way', () => {
|
||||||
|
class Child { @IsString() name: string; }
|
||||||
|
class Parent {
|
||||||
|
@ValidateNested()
|
||||||
|
@JsonType(() => Child)
|
||||||
|
child: Child;
|
||||||
|
}
|
||||||
|
const parent = toInstanceSync(Parent, { child: { name: 'x' } });
|
||||||
|
expect(parent.child).toBeInstanceOf(Child);
|
||||||
|
|
||||||
|
const cyclic: any = new Parent();
|
||||||
|
cyclic.child = cyclic;
|
||||||
|
expect(() => toPlainSync(cyclic, { validate: false })).toThrow(/Circular reference/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('synchronous API refuses asynchronous hooks', () => {
|
||||||
|
class SlowSerializer implements JsonSerializer<string, string> {
|
||||||
|
async serialize(value: string): Promise<string> { return value.toUpperCase(); }
|
||||||
|
}
|
||||||
|
class SlowDeserializer implements JsonDeserializer<string, string> {
|
||||||
|
async deserialize(value: string): Promise<string> { return value.toLowerCase(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports a clear error for an async serializer and names the async alternative', () => {
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(SlowSerializer)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.code = 'abc';
|
||||||
|
|
||||||
|
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
|
||||||
|
expect(() => toPlainSync(doc, { validate: false })).toThrow(/toPlainSync\(\) requires every/);
|
||||||
|
expect(() => toPlainSync(doc, { validate: false })).toThrow(/Use toPlain\(\) instead/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error for an async deserializer', () => {
|
||||||
|
class Doc {
|
||||||
|
@JsonDeserialize(SlowDeserializer)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
expect(() => toInstanceSync(Doc, { code: 'ABC' }, { validate: false }))
|
||||||
|
.toThrow(/toInstanceSync\(\) requires every/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error for an async validator', () => {
|
||||||
|
class Doc {
|
||||||
|
@Validate(async (v: any) => v === 'ok')
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.code = 'ok';
|
||||||
|
expect(() => validateSync(doc)).toThrow(/validateSync\(\) requires every/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leave an unhandled rejection behind when it refuses', async () => {
|
||||||
|
class Exploding implements JsonSerializer<string, string> {
|
||||||
|
serialize(): Promise<string> { return Promise.reject(new Error('boom')); }
|
||||||
|
}
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(Exploding)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.code = 'x';
|
||||||
|
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
try {
|
||||||
|
expect(() => toPlainSync(doc, { validate: false })).toThrow(JsonMappingError);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
}
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the async API still supports asynchronous hooks', () => {
|
||||||
|
it('awaits an async serializer', async () => {
|
||||||
|
class Slow implements JsonSerializer<string, string> {
|
||||||
|
async serialize(value: string): Promise<string> {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1));
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Doc {
|
||||||
|
@JsonSerialize(Slow)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
other: string;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.code = 'abc';
|
||||||
|
doc.other = 'kept';
|
||||||
|
|
||||||
|
await expect(toPlain(doc)).resolves.toEqual({ code: 'ABC', other: 'kept' });
|
||||||
|
await expect(toJson(doc)).resolves.toBe('{"code":"ABC","other":"kept"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits an async deserializer, including inside a nested type', async () => {
|
||||||
|
class Slow implements JsonDeserializer<string, Date> {
|
||||||
|
async deserialize(value: string): Promise<Date> {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1));
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child {
|
||||||
|
@JsonDeserialize(Slow)
|
||||||
|
at: Date;
|
||||||
|
}
|
||||||
|
class Parent {
|
||||||
|
@JsonType(() => Child)
|
||||||
|
child: Child;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await toInstance(Parent, { child: { at: '2026-01-01T00:00:00Z' } }, { validate: false });
|
||||||
|
expect(parent.child.at).toBeInstanceOf(Date);
|
||||||
|
expect(parent.child.at.getUTCFullYear()).toBe(2026);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits an async deserializer inside an array', async () => {
|
||||||
|
class Slow implements JsonDeserializer<string, string> {
|
||||||
|
async deserialize(value: string): Promise<string> { return value.toUpperCase(); }
|
||||||
|
}
|
||||||
|
class Row {
|
||||||
|
@JsonDeserialize(Slow)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const rows = await toInstanceArray(Row, [{ code: 'a' }, { code: 'b' }], { validate: false });
|
||||||
|
expect(rows.map(r => r.code)).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits an async validator and reports its failure', async () => {
|
||||||
|
class Doc {
|
||||||
|
@Validate(async (v: any) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1));
|
||||||
|
return v === 'ok';
|
||||||
|
}, { message: 'must be ok' })
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
|
||||||
|
doc.code = 'ok';
|
||||||
|
await expect(validate(doc)).resolves.toEqual([]);
|
||||||
|
|
||||||
|
doc.code = 'wrong';
|
||||||
|
const errors = await validate(doc);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.constraints['custom']).toBe('must be ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('awaits an async validator under each: true and keeps the index', async () => {
|
||||||
|
class Doc {
|
||||||
|
@Validate(async (v: any) => v === 'ok', { each: true })
|
||||||
|
codes: string[];
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
|
||||||
|
doc.codes = ['ok', 'ok'];
|
||||||
|
await expect(validate(doc)).resolves.toEqual([]);
|
||||||
|
|
||||||
|
doc.codes = ['ok', 'ok', 'bad'];
|
||||||
|
const errors = await validate(doc);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.constraints['custom']).toContain('failed at index 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes sync and async validators on one object without losing either failure', async () => {
|
||||||
|
class Doc {
|
||||||
|
@IsString()
|
||||||
|
name: any;
|
||||||
|
|
||||||
|
@Validate(async (v: any) => v > 0, { message: 'must be positive' })
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.name = 123;
|
||||||
|
doc.amount = -5;
|
||||||
|
|
||||||
|
const flat = flattenErrors(await validate(doc));
|
||||||
|
expect(flat['name']).toEqual(['name must be a string']);
|
||||||
|
expect(flat['amount']).toEqual(['must be positive']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes provisional entries for async validators that pass', async () => {
|
||||||
|
class Doc {
|
||||||
|
@Validate(async () => true)
|
||||||
|
a: string;
|
||||||
|
|
||||||
|
@Validate(async () => true)
|
||||||
|
b: string;
|
||||||
|
}
|
||||||
|
await expect(validate(new Doc())).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateOrReject still rejects on an async failure', async () => {
|
||||||
|
class Doc {
|
||||||
|
@Validate(async () => false)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
await expect(validateOrReject(new Doc())).rejects.toThrow(JsonValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('write-only redaction in validation errors', () => {
|
||||||
|
it('redacts a @JsonWriteOnly value but keeps the failure message', async () => {
|
||||||
|
class Credentials {
|
||||||
|
@IsString()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = new Credentials();
|
||||||
|
creds.email = 'ada@example.com';
|
||||||
|
creds.password = 'hunter2';
|
||||||
|
|
||||||
|
const errors = await validate(creds);
|
||||||
|
const failure = errors.find(e => e.property === 'password')!;
|
||||||
|
|
||||||
|
expect(failure.value).toBe(REDACTED);
|
||||||
|
expect(failure.constraints['minLength']).toContain('12');
|
||||||
|
expect(JSON.stringify(errors)).not.toContain('hunter2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts @JsonIgnore values too', async () => {
|
||||||
|
class Record {
|
||||||
|
@JsonIgnore()
|
||||||
|
@IsString()
|
||||||
|
internalSecret: any;
|
||||||
|
}
|
||||||
|
const record = new Record();
|
||||||
|
record.internalSecret = 999;
|
||||||
|
|
||||||
|
const errors = await validate(record);
|
||||||
|
expect(errors[0]!.value).toBe(REDACTED);
|
||||||
|
expect(JSON.stringify(errors)).not.toContain('999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary property values in place', async () => {
|
||||||
|
class Doc {
|
||||||
|
@IsString()
|
||||||
|
name: any;
|
||||||
|
}
|
||||||
|
const doc = new Doc();
|
||||||
|
doc.name = 42;
|
||||||
|
|
||||||
|
const errors = await validate(doc);
|
||||||
|
expect(errors[0]!.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the secret out of a thrown JsonValidationError', async () => {
|
||||||
|
class SignUp {
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(toInstance(SignUp, { password: 'short' })).rejects.toThrow(JsonValidationError);
|
||||||
|
try {
|
||||||
|
await toInstance(SignUp, { password: 'short' });
|
||||||
|
} catch (error) {
|
||||||
|
expect(String((error as JsonValidationError).toString())).not.toContain('short');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts in the synchronous path as well', () => {
|
||||||
|
class Credentials {
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
const creds = new Credentials();
|
||||||
|
creds.password = 'hunter2';
|
||||||
|
|
||||||
|
expect(validateSync(creds)[0]!.value).toBe(REDACTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not redact a value that merely sits next to a secret', async () => {
|
||||||
|
class Form {
|
||||||
|
@IsIn(['a', 'b'])
|
||||||
|
choice!: 'a' | 'b';
|
||||||
|
|
||||||
|
@JsonWriteOnly()
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
const form = new Form();
|
||||||
|
form.choice = 'zzz' as 'a';
|
||||||
|
form.token = 'secret-token';
|
||||||
|
|
||||||
|
const errors = await validate(form);
|
||||||
|
expect(errors.find(e => e.property === 'choice')!.value).toBe('zzz');
|
||||||
|
expect(JSON.stringify(errors)).not.toContain('secret-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree-shakability is a property of the source that nothing else notices when it breaks.
|
||||||
|
*
|
||||||
|
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
|
||||||
|
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
|
||||||
|
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
|
||||||
|
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
|
||||||
|
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
|
||||||
|
* every consumer's bundle, and the only way to notice was to go and measure.
|
||||||
|
*
|
||||||
|
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
|
||||||
|
* something drifted, but naming the thing that should not be there says what.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Markers that identify a chunk of the library in minified output. */
|
||||||
|
const MARKER = {
|
||||||
|
isString: 'must be a string',
|
||||||
|
minLength: 'must be longer than or equal to',
|
||||||
|
isLatitude: 'must be a latitude',
|
||||||
|
isSemVer: 'must be a valid semantic version',
|
||||||
|
arraySize: 'must contain at least',
|
||||||
|
serializer: 'Circular reference',
|
||||||
|
representable: 'cannot be serialized to JSON',
|
||||||
|
deserializer: 'Unknown property',
|
||||||
|
validator: '[redacted]',
|
||||||
|
naming: 'SCREAMING_SNAKE_CASE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ENTRY = JSON.stringify(path.resolve('src/index.js'));
|
||||||
|
|
||||||
|
async function bundle(source: string): Promise<string> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
|
||||||
|
try {
|
||||||
|
const entry = path.join(dir, 'entry.ts');
|
||||||
|
await writeFile(entry, source);
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [entry],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
minify: true,
|
||||||
|
target: 'es2022',
|
||||||
|
write: false,
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
return result.outputFiles[0]!.text;
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts what a bundle kept and what it dropped.
|
||||||
|
*
|
||||||
|
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
|
||||||
|
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
|
||||||
|
* result and a broken harness look identical without it.
|
||||||
|
*/
|
||||||
|
function expectShaken(code: string, keeps: string[], drops: string[]) {
|
||||||
|
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
|
||||||
|
for (const marker of keeps) {
|
||||||
|
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
|
||||||
|
}
|
||||||
|
for (const marker of drops) {
|
||||||
|
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tree-shaking', () => {
|
||||||
|
it('drops the 67 rules you did not import', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { IsString } from ${ENTRY};
|
||||||
|
export const d = IsString();
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.isString], [
|
||||||
|
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
|
||||||
|
MARKER.serializer, MARKER.deserializer, MARKER.naming,
|
||||||
|
]);
|
||||||
|
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
|
||||||
|
// of the kind above (which trebled it), not to police every byte.
|
||||||
|
expect(code.length).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the deserializer and drops the serializer when only reading', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { toInstanceSync } from ${ENTRY};
|
||||||
|
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Validation is kept on purpose: `validate` defaults to true, so the entry point
|
||||||
|
// references it whatever the call site passes.
|
||||||
|
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the serializer and drops the deserializer when only writing', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { toPlainSync } from ${ENTRY};
|
||||||
|
export const f = (o) => toPlainSync(o, { validate: false });
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops both engines when only validating', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { validateSync } from ${ENTRY};
|
||||||
|
export const f = (o) => validateSync(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
|
||||||
|
* so it survives only because `sideEffects` names that module — and naming it is one hop short
|
||||||
|
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
|
||||||
|
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
|
||||||
|
*
|
||||||
|
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
|
||||||
|
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
|
||||||
|
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
|
||||||
|
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
|
||||||
|
*/
|
||||||
|
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { configure } from ${ENTRY};
|
||||||
|
export const f = (o) => configure(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
|
||||||
|
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs almost nothing to import only an error helper', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { flattenErrors } from ${ENTRY};
|
||||||
|
export const f = (e) => flattenErrors(e);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
|
||||||
|
expect(code.length).toBeLessThan(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still contains everything when everything is used', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import * as cereale from ${ENTRY};
|
||||||
|
export default cereale;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The counterweight to every assertion above: proves the markers are findable at all, so a
|
||||||
|
// "shaken" result upstream means shaken rather than misspelled.
|
||||||
|
expectShaken(code, Object.values(MARKER), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cases above bundle `src/`, which is where the annotations are written — so they cannot
|
||||||
|
* see what happens to them on the way into `dist/`. That is exactly where this broke: the
|
||||||
|
* flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose
|
||||||
|
* `minifyWhitespace` pass strips comments, annotations included. The published entry point
|
||||||
|
* kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check
|
||||||
|
* stayed green.
|
||||||
|
*/
|
||||||
|
describe('the published artifacts', () => {
|
||||||
|
const dist = path.resolve('dist');
|
||||||
|
const built = existsSync(path.join(dist, 'cereale.min.js'));
|
||||||
|
|
||||||
|
it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))};
|
||||||
|
export const d = IsString();
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
|
||||||
|
expect(code.length).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
|
||||||
|
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
|
||||||
|
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
|
||||||
|
* from the other side.
|
||||||
|
*/
|
||||||
|
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
|
||||||
|
export const f = (o) => configure(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
|
||||||
|
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
+1
-1
@@ -30,7 +30,7 @@ describe('Standalone Utility Functions', () => {
|
|||||||
user.age = '30' as any;
|
user.age = '30' as any;
|
||||||
const errors2 = await validate(user);
|
const errors2 = await validate(user);
|
||||||
expect(errors2).toHaveLength(1);
|
expect(errors2).toHaveLength(1);
|
||||||
expect(errors2[0].property).toBe('age');
|
expect(errors2[0]!.property).toBe('age');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transform to plain object directly', async () => {
|
it('should transform to plain object directly', async () => {
|
||||||
|
|||||||
+1102
-196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
Equals, NotEquals, IsEmpty, IsEnum, IsInstance,
|
||||||
|
Length, IsAlpha, IsAlphanumeric, IsNumberString, IsLowercase, IsUppercase,
|
||||||
|
Contains, NotContains, StartsWith, EndsWith,
|
||||||
|
IsUUID, IsJSON, IsDateString, IsSemVer, IsHexColor, IsIP,
|
||||||
|
IsDivisibleBy, IsPort, IsLatitude, IsLongitude, IsBigInt,
|
||||||
|
MinDate, MaxDate,
|
||||||
|
ArrayUnique, ArrayContains, ArrayNotContains,
|
||||||
|
ValidateIf, Allow, IsString, IsIn, IsOptional,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
class Subject {
|
||||||
|
val: any;
|
||||||
|
}
|
||||||
|
(Subject as any)[Symbol.metadata] = metadata;
|
||||||
|
|
||||||
|
const subject = new Subject();
|
||||||
|
subject.val = value;
|
||||||
|
|
||||||
|
const errors = await validate(subject);
|
||||||
|
return errors.length ? Object.keys(errors[0]!.constraints) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const passes = async (decorator: any, value: any) => expect(await check(decorator, value)).toEqual([]);
|
||||||
|
const fails = async (decorator: any, value: any) => expect((await check(decorator, value)).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
describe('equality and presence', () => {
|
||||||
|
it('@Equals / @NotEquals', async () => {
|
||||||
|
await passes(Equals('x'), 'x');
|
||||||
|
await fails(Equals('x'), 'y');
|
||||||
|
await passes(NotEquals('x'), 'y');
|
||||||
|
await fails(NotEquals('x'), 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsEmpty', async () => {
|
||||||
|
for (const empty of [null, undefined, '', [], {}]) await passes(IsEmpty(), empty);
|
||||||
|
for (const filled of ['a', [1], { a: 1 }, 0]) await fails(IsEmpty(), filled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsInstance', async () => {
|
||||||
|
class Thing {}
|
||||||
|
await passes(IsInstance(Thing), new Thing());
|
||||||
|
await fails(IsInstance(Thing), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@IsEnum', () => {
|
||||||
|
enum StringRole { Admin = 'admin', User = 'user' }
|
||||||
|
enum NumericLevel { Low, High }
|
||||||
|
|
||||||
|
it('accepts members of a string enum', async () => {
|
||||||
|
await passes(IsEnum(StringRole), 'admin');
|
||||||
|
await fails(IsEnum(StringRole), 'root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts members of a numeric enum without accepting its reverse-mapped names', async () => {
|
||||||
|
await passes(IsEnum(NumericLevel), 0);
|
||||||
|
await passes(IsEnum(NumericLevel), 1);
|
||||||
|
await fails(IsEnum(NumericLevel), 2);
|
||||||
|
// 'Low' is the reverse mapping, not a legal value
|
||||||
|
await fails(IsEnum(NumericLevel), 'Low');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strings', () => {
|
||||||
|
it('@Length with and without a maximum', async () => {
|
||||||
|
await passes(Length(2), 'ab');
|
||||||
|
await fails(Length(3), 'ab');
|
||||||
|
await passes(Length(2, 4), 'abc');
|
||||||
|
await fails(Length(2, 4), 'abcde');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsAlpha / @IsAlphanumeric', async () => {
|
||||||
|
await passes(IsAlpha(), 'abcDEF');
|
||||||
|
await fails(IsAlpha(), 'abc1');
|
||||||
|
await passes(IsAlphanumeric(), 'abc123');
|
||||||
|
await fails(IsAlphanumeric(), 'abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsNumberString', async () => {
|
||||||
|
await passes(IsNumberString(), '42');
|
||||||
|
await passes(IsNumberString(), '-1.5');
|
||||||
|
await fails(IsNumberString(), 'abc');
|
||||||
|
await fails(IsNumberString(), '');
|
||||||
|
await fails(IsNumberString(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsLowercase / @IsUppercase', async () => {
|
||||||
|
await passes(IsLowercase(), 'abc');
|
||||||
|
await fails(IsLowercase(), 'Abc');
|
||||||
|
await passes(IsUppercase(), 'ABC');
|
||||||
|
await fails(IsUppercase(), 'Abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@Contains / @NotContains / @StartsWith / @EndsWith', async () => {
|
||||||
|
await passes(Contains('ell'), 'hello');
|
||||||
|
await fails(Contains('xyz'), 'hello');
|
||||||
|
await passes(NotContains('xyz'), 'hello');
|
||||||
|
await fails(NotContains('ell'), 'hello');
|
||||||
|
await passes(StartsWith('he'), 'hello');
|
||||||
|
await fails(StartsWith('lo'), 'hello');
|
||||||
|
await passes(EndsWith('lo'), 'hello');
|
||||||
|
await fails(EndsWith('he'), 'hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@IsUUID', () => {
|
||||||
|
const v4 = '9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21';
|
||||||
|
|
||||||
|
it('accepts any version when unversioned', async () => {
|
||||||
|
await passes(IsUUID(), v4);
|
||||||
|
await passes(IsUUID(), '00000000-0000-0000-0000-000000000000'); // nil
|
||||||
|
await fails(IsUUID(), 'not-a-uuid');
|
||||||
|
await fails(IsUUID(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces a requested version', async () => {
|
||||||
|
await passes(IsUUID(4), v4);
|
||||||
|
await fails(IsUUID(1), v4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formats', () => {
|
||||||
|
it('@IsJSON', async () => {
|
||||||
|
await passes(IsJSON(), '{"a":1}');
|
||||||
|
await passes(IsJSON(), '[1,2]');
|
||||||
|
await fails(IsJSON(), '{a:1}');
|
||||||
|
await fails(IsJSON(), { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsDateString', async () => {
|
||||||
|
await passes(IsDateString(), '2026-08-03T00:00:00Z');
|
||||||
|
await fails(IsDateString(), 'not a date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsSemVer', async () => {
|
||||||
|
await passes(IsSemVer(), '1.2.3');
|
||||||
|
await passes(IsSemVer(), '1.0.0-alpha.1+build.5');
|
||||||
|
await fails(IsSemVer(), '1.2');
|
||||||
|
await fails(IsSemVer(), 'v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsHexColor', async () => {
|
||||||
|
await passes(IsHexColor(), '#fff');
|
||||||
|
await passes(IsHexColor(), '#A1B2C3');
|
||||||
|
await passes(IsHexColor(), '#A1B2C3FF');
|
||||||
|
await fails(IsHexColor(), 'fff');
|
||||||
|
await fails(IsHexColor(), '#ggg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsIP', async () => {
|
||||||
|
await passes(IsIP(4), '192.168.0.1');
|
||||||
|
await fails(IsIP(4), '256.0.0.1');
|
||||||
|
await fails(IsIP(4), '::1');
|
||||||
|
await passes(IsIP(6), '::1');
|
||||||
|
await passes(IsIP(), '10.0.0.1');
|
||||||
|
await fails(IsIP(), 'nope');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numbers', () => {
|
||||||
|
it('@IsDivisibleBy', async () => {
|
||||||
|
await passes(IsDivisibleBy(5), 10);
|
||||||
|
await fails(IsDivisibleBy(5), 11);
|
||||||
|
await fails(IsDivisibleBy(5), '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsPort', async () => {
|
||||||
|
await passes(IsPort(), 8080);
|
||||||
|
await passes(IsPort(), '443');
|
||||||
|
await fails(IsPort(), 70000);
|
||||||
|
await fails(IsPort(), -1);
|
||||||
|
await fails(IsPort(), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsLatitude / @IsLongitude', async () => {
|
||||||
|
await passes(IsLatitude(), 48.85);
|
||||||
|
await fails(IsLatitude(), 91);
|
||||||
|
await passes(IsLongitude(), 2.35);
|
||||||
|
await fails(IsLongitude(), 181);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@IsBigInt', async () => {
|
||||||
|
await passes(IsBigInt(), 10n);
|
||||||
|
await fails(IsBigInt(), 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dates', () => {
|
||||||
|
it('@MinDate / @MaxDate with a fixed bound', async () => {
|
||||||
|
const bound = new Date('2026-01-01T00:00:00Z');
|
||||||
|
await passes(MinDate(bound), new Date('2026-06-01T00:00:00Z'));
|
||||||
|
await fails(MinDate(bound), new Date('2025-06-01T00:00:00Z'));
|
||||||
|
await passes(MaxDate(bound), new Date('2025-06-01T00:00:00Z'));
|
||||||
|
await fails(MaxDate(bound), new Date('2026-06-01T00:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@MinDate accepts a thunk so the bound moves', async () => {
|
||||||
|
await passes(MinDate(() => new Date(Date.now() - 1000)), new Date());
|
||||||
|
await fails(MinDate(() => new Date(Date.now() + 60_000)), new Date());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-date', async () => {
|
||||||
|
await fails(MinDate(new Date(0)), '2026-01-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arrays', () => {
|
||||||
|
it('@ArrayUnique by value', async () => {
|
||||||
|
await passes(ArrayUnique(), [1, 2, 3]);
|
||||||
|
await fails(ArrayUnique(), [1, 2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@ArrayUnique by extracted key', async () => {
|
||||||
|
const byId = (item: any) => item.id;
|
||||||
|
await passes(ArrayUnique(byId), [{ id: 1 }, { id: 2 }]);
|
||||||
|
await fails(ArrayUnique(byId), [{ id: 1 }, { id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('@ArrayContains / @ArrayNotContains', async () => {
|
||||||
|
await passes(ArrayContains(['a']), ['a', 'b']);
|
||||||
|
await fails(ArrayContains(['c']), ['a', 'b']);
|
||||||
|
await passes(ArrayNotContains(['c']), ['a', 'b']);
|
||||||
|
await fails(ArrayNotContains(['a']), ['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@ValidateIf', () => {
|
||||||
|
class Payment {
|
||||||
|
@IsIn(['card', 'invoice'])
|
||||||
|
method!: 'card' | 'invoice';
|
||||||
|
|
||||||
|
@ValidateIf<Payment>(o => o.method === 'card')
|
||||||
|
@IsString()
|
||||||
|
cardNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('skips the constraint when the condition is false', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'invoice';
|
||||||
|
expect(await validate(p)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the constraint when the condition is true', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'card';
|
||||||
|
|
||||||
|
const errors = await validate(p);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]!.property).toBe('cardNumber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the condition is true and the value is valid', async () => {
|
||||||
|
const p = new Payment();
|
||||||
|
p.method = 'card';
|
||||||
|
p.cardNumber = '4111111111111111';
|
||||||
|
expect(await validate(p)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@Allow', () => {
|
||||||
|
it('declares a property so strict unknown-key policies keep it', async () => {
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Allow()
|
||||||
|
metadata: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await toInstance(
|
||||||
|
Dto,
|
||||||
|
{ name: 'x', metadata: { anything: true }, stray: 1 },
|
||||||
|
{ unknownKeys: 'strip' }
|
||||||
|
);
|
||||||
|
expect(d.metadata).toEqual({ anything: true });
|
||||||
|
expect((d as any).stray).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('new validators cooperate with existing options', () => {
|
||||||
|
it('honours each: true', async () => {
|
||||||
|
class T {
|
||||||
|
@IsUUID(4, { each: true })
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21'];
|
||||||
|
expect(await validate(t)).toEqual([]);
|
||||||
|
|
||||||
|
t.ids = ['9b2e4c1a-77bd-4f2e-8c33-1d9a6b0e5f21', 'nope'];
|
||||||
|
expect(await validate(t)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours @IsOptional', async () => {
|
||||||
|
class T {
|
||||||
|
@IsOptional()
|
||||||
|
@IsSemVer()
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
expect(await validate(t)).toEqual([]);
|
||||||
|
|
||||||
|
t.version = 'bad';
|
||||||
|
expect(await validate(t)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom message', async () => {
|
||||||
|
class T {
|
||||||
|
@IsPort({ message: 'give me a real port' })
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
const t = new T();
|
||||||
|
t.port = -1;
|
||||||
|
|
||||||
|
const errors = await validate(t);
|
||||||
|
expect(errors[0]!.constraints['isPort']).toBe('give me a real port');
|
||||||
|
});
|
||||||
|
});
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* A Vite plugin that lowers TC39 standard decorators, for projects on Vite 8 or Vitest 4.
|
||||||
|
*
|
||||||
|
* Those versions transform TypeScript with oxc, which does not implement the standard
|
||||||
|
* decorator transform yet. It does not report that: it leaves the decorator syntax in the
|
||||||
|
* output, so `vitest` prints "0 test" next to a bare `SyntaxError`, and `vite build` reports
|
||||||
|
* success while emitting a bundle that throws `SyntaxError` the moment anything imports it.
|
||||||
|
*
|
||||||
|
* Nothing here is specific to cereale — any library built on standard decorators needs it —
|
||||||
|
* but cereale ships it because a consumer's first experience of the library should not be a
|
||||||
|
* syntax error with no obvious cause. Delete it once oxc supports the transform.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // vite.config.ts / vitest.config.ts
|
||||||
|
* import { standardDecorators } from 'cereale/vite';
|
||||||
|
*
|
||||||
|
* export default defineConfig({ plugins: [standardDecorators()] });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The transform is done by esbuild if it is installed, otherwise by the TypeScript compiler.
|
||||||
|
* cereale depends on neither; one of the two is present in essentially every TypeScript
|
||||||
|
* project, and the plugin says which to install if somehow neither is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape Vite expects of a plugin, declared here rather than imported.
|
||||||
|
*
|
||||||
|
* `cereale/vite` must not drag `vite` into a consumer's type-checking just to describe its own
|
||||||
|
* return value — this object is structurally assignable to Vite's `Plugin`.
|
||||||
|
*/
|
||||||
|
export interface StandardDecoratorsPlugin {
|
||||||
|
name: string;
|
||||||
|
enforce: 'pre';
|
||||||
|
transform(code: string, id: string): Promise<{ code: string; map: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardDecoratorsOptions {
|
||||||
|
/**
|
||||||
|
* Decides which modules to transform. Receives the resolved module id.
|
||||||
|
*
|
||||||
|
* The default takes `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded
|
||||||
|
* because lowering decorators there means also deciding what happens to the JSX, and
|
||||||
|
* getting that wrong is worse than not handling it; pass an `include` of your own if you
|
||||||
|
* declare decorated classes in `.tsx` files.
|
||||||
|
*/
|
||||||
|
include?: (id: string) => boolean;
|
||||||
|
/** ECMAScript target for the emitted code. Defaults to `es2022`, the first with class fields. */
|
||||||
|
target?: string;
|
||||||
|
/**
|
||||||
|
* Which tool does the transform. `'auto'` (the default) prefers esbuild for speed and falls
|
||||||
|
* back to the TypeScript compiler; name one explicitly to keep a build reproducible, or to
|
||||||
|
* fail loudly rather than silently switch if the preferred one is not installed.
|
||||||
|
*/
|
||||||
|
transformer?: 'auto' | 'esbuild' | 'typescript';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INCLUDE = (id: string): boolean =>
|
||||||
|
/\.[cm]?ts(\?.*)?$/.test(id) && !id.includes('/node_modules/');
|
||||||
|
|
||||||
|
type Transformer = (code: string, id: string, target: string) => Promise<{ code: string; map: string }>;
|
||||||
|
|
||||||
|
function isMissingModule(error: unknown): boolean {
|
||||||
|
const code = (error as { code?: unknown } | null)?.code;
|
||||||
|
return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function esbuildTransformer(): Promise<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
-1
@@ -5,5 +5,6 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"outDir": "dist/cjs",
|
"outDir": "dist/cjs",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,5 +4,6 @@
|
|||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"outDir": "dist/esm",
|
"outDir": "dist/esm",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/example.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -8,7 +8,7 @@
|
|||||||
// Environment Settings
|
// Environment Settings
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"target": "ES2025",
|
"target": "ES2025",
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|
||||||
// Other Outputs
|
// Other Outputs
|
||||||
@@ -33,8 +33,12 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"experimentalDecorators": 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.
|
||||||
},
|
},
|
||||||
|
// 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`.
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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()],
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts', 'src/example.ts', 'src/index.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user