🌳 fix: make cereale/min tree-shake too, and correct what I overclaimed

An audit of the last commit found two real problems and several claims of
mine that went further than the evidence.

**cereale/min was not tree-shakable.** scripts/build-bundle.mjs used
esbuild's `minify: true`, whose minifyWhitespace pass strips comments —
/*#__PURE__*/ annotations included. The published entry point therefore
reproduced exactly the bug the previous commit fixed: one decorator came
out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from
the per-module entry. Every source-level check stayed green, because they
all bundled src/ and the annotations are stripped on the way into dist/.

It is now minified for syntax and identifiers but not whitespace: 33.9 KB
raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire
for a file that behaves correctly however it is used. One decorator via
cereale/min is now 1,996 bytes. The build asserts the annotation count
survives, and treeshake.test.ts now bundles the published artifact as well
as the source — the gap that let this through.

**sideEffects was partly inert.** `./dist/cjs/metadata.js` could never
match: the build writes dist/cjs/package.json, which becomes the nearest
descriptor for everything beneath it, so bundlers read sideEffects from
there. That file now carries its own declaration. `./src/metadata.ts` was
missing while src/ is published, which declared the Symbol.metadata install
droppable in the source tree. Five module-scope caches in utils.ts are
annotated for the same reason as the rules.

I checked the audit's third blocker — that the Symbol.metadata install is
dropped by bundlers — and it is not. It survives every case where it is
load-bearing (a decorator import, toPlainSync, modelOf, and a decorated
model bundled with an app). It is dropped only when importing nothing but
flattenErrors, which needs no metadata, so that is correct.

Corrections to my own wording:
- "all three bundlers land within 20 bytes" held only for the one-decorator
  row; larger imports differ by up to a few hundred bytes
- "measured through three bundlers, and pinned by a test" read as though the
  test covered all three; it covers esbuild, on source and on dist
- "every rule is a top-level call" — 30 of the 68 are
- the docs page "loads nothing from the network" — it fetches its own
  vendored compiler, same-origin, on first Run. It has no third-party
  dependencies, which is the claim I should have made
- cereale/min's size, everywhere it appears

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 16:41:32 +00:00
parent 2a2f7345ad
commit c48a106a05
8 changed files with 118 additions and 39 deletions
+24 -14
View File
@@ -27,8 +27,9 @@ user.greet(); // your methods are still there
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
interactive playground that runs this library in your browser, the full decorator reference,
and the toolchain matrix. The page is self-contained and loads nothing from the network; it is
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
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 is served from
`docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
## Where it fits
@@ -498,29 +499,38 @@ it is one `Symbol.toStringTag` read per object.
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 three bundlers, and
[pinned by a test](src/treeshake.test.ts) so it cannot quietly regress:
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` | 287 | 292 | 291 |
| one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,823 |
| `toPlainSync` | 7,744 | 7,769 | 7,832 |
| `toInstanceSync` | 7,900 | 7,942 | 7,956 |
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,372 |
| `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.
This did not come for free. Every rule is a top-level call — `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.
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