build(spec): drop the DTS pass's duplicate type check — 93% to 83% of its 6144 MB ceiling - #20483
Conversation
The spec DTS pass sat at 92% of its 6144 MB heap ceiling (5658 MB live after mark-compact, 8 GB cgroup, 8cdbe0c), so CI runs near the margin ended in ERR_WORKER_OUT_OF_MEMORY. rollup-plugin-dts forces noEmitOnError, so the pass type-checked every file it emitted, in each of the one-per-entry programs tsup's bundled rollup-plugin-dts builds. That check duplicates the typecheck script over the same tsconfig. noCheck drops it: 5083 MB live, 134s instead of 181-194s. The emitted declarations are the same types. They differ from main only in union/property order, and main's own reruns differ that way too. The docblock now records the per-entry-program cause, today's measurements, and the fact that the pass is not byte-stable. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
…ec-dts-heap-ceiling
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa384d4a0f0d5d06fee2e027eec1dcb7fd679481 && git checkout aa384d4a0f0d5d06fee2e027eec1dcb7fd679481
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e028591857980ae69b9f9badb380dfa61367e62 8645ad7e07578e276c78225e9785c80b6465ab7e && git checkout -B drift-repro 8e028591857980ae69b9f9badb380dfa61367e62 && git merge --no-ff 8645ad7e07578e276c78225e9785c80b6465ab7e
node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62 |
…in-dts grouping (spec 4997 → 882 MB live heap) (objectstack-ai#20499) Fixes objectstack-ai#20419 Clause-②: no ## What changes This is round 2 of the card: the root-cause fix. The seat ruled route A, a pnpm patch of tsup's bundled rollup-plugin-dts, and the maintainer's veto window runs until this PR lands. Round 1 (objectstack-ai#20483) landed the `noCheck` mitigation. - **`patches/tsup@8.5.1.patch`** changes 5 lines in `dist/rollup.js`, which is tsup's bundled rollup-plugin-dts 6.1.1. When tsup passes a tsconfig path, every entry is now grouped by the tsconfig's directory. Before, an entry that hit the config cache kept its own directory, so each entry directory got its own `ts.Program`. The patch was written with `pnpm patch` / `pnpm patch-commit`. - **`pnpm-workspace.yaml`** gets `patchedDependencies: tsup@8.5.1`, with a comment on why the patch exists and what a tsup bump owes. `patch-commit` wrote the key into `package.json`'s `pnpm` field; it was moved here, and `package.json` is unchanged. - **`pnpm-lock.yaml`** was regenerated by `pnpm install`. The diff is the `patchedDependencies` block plus the `patch_hash` on each tsup resolution (+16 / −11). No other resolution moved. The lockfile was byte-identical before and after the key moved from `package.json` to the yaml. - **`packages/spec/tsup.config.ts`** changes only its docblock: the one-program mechanism, the tsup-bump duty, today's table, and the wider equality needed to compare trees. The 6144 ceiling is unchanged. No package source changes. ## Census: which packages build more than one program **Static census.** All 67 workspace packages whose scripts run tsup were checked. Each config was loaded the way tsup loads it (`bundle-require`), with the build script's `--config` and positional entries applied, and the plugin's grouping simulated. 12 packages had more than one program; the other 55 have one DTS entry. **Empirical census.** The declaration pass of every package in the census closure (31 packages) was built with a `ts.createProgram` probe preloaded into the DTS worker. It confirms the 12. Programs before → after the patch: | package | before | after | |---|---|---| | `@objectstack/spec` | 18 | 1 | | `@objectstack/platform-objects` | 11 | 1 | | `@objectstack/metadata` | 3 (roots 1+3+1) | 1 | | `@objectstack/metadata-core` (two config items) | 1+2 | 1+1 | | `@objectstack/service-cluster` (two config items) | 1+2 | 1+1 | | `@objectstack/core`, `lint`, `objectql`, `plugin-auth`, `plugin-webhooks`, `service-datasource`, `types` | 2 each | 1 each | | the 18 single-entry packages in the closure | 1 | 1 | Round 1's estimate was off in both directions. It listed `create-objectstack`, whose DTS pass has one entry (`dts.entry`), and it missed `platform-objects`, whose object-form `entry` gave 11 programs. ## Measurements: spec at the 6144 ceiling in an 8192 MB cgroup **Method.** - The DTS pass ran exactly as the build script spells it, plus `--trace-gc`. - It ran in a cgroup-v1 memory cgroup capped at 8192 MB. Round 1 proved that cap kills first. - The tree is `8113763026` (with `noCheck`) against the same tree plus the patch (`78cbf5dfbf`). Spec's source is identical in both. - Wall times are shared-box readings. | | programs | live heap after a mark-compact | largest heap before one | peak RSS | wall | |---|---|---|---|---|---| | DTS pass, unpatched | 18 | 4997 MB | 5425 MB | 5694 MB | 153 s | | DTS pass, patched | 1 (18 roots, 396 emits) | 882 MB (4 mark-compacts, so a lower bound) | 1677 MB | 3537 MB | 41 s | | whole `build` script, unpatched | | | | 5740 MB | 189 s (DTS 148 s) | | whole `build` script, patched | | | | 3526 MB | 87 s (DTS 45 s) | The ceiling now has about 4.5 GB of headroom. It stays at 6144; lowering it is a separate change. ## The patch cannot rot silently (measured) All three cases ran with the repo's pnpm 10.31.0, from `packageManager`. - **tsup moves off 8.5.1** (a scratch project with the same patch file and key, and tsup 8.5.0 installed): `pnpm install` exits 1 with `ERR_PNPM_UNUSED_PATCH The following patches were not used: tsup@8.5.1`, and `--frozen-lockfile` exits 1 with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. - **Positive control** (the same scratch project on tsup 8.5.1): the install exits 0, and the installed `dist/rollup.js` carries the patch marker once. - **The yaml key is what pnpm reads** (in this tree): with the `patchedDependencies` block removed, `pnpm install --frozen-lockfile` exits 1 with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`, which is what CI's frozen install would hit. The removal was done with `scripts/ablation-replace.mjs`, and the restore was proven blob-equal to HEAD. ## The emitted declarations are the same types Per the seat's Q2 answer: canonical equality with negative controls replaces byte identity. **Trees compared.** All 31 packages of the census closure were built twice, in dependency order: unpatched at `8113763026`, then patched at `78cbf5dfbf`. Only tsup differs between the two builds. Every `dist/**/*.d.{ts,mts,cts}` was saved each time. **Canonical form.** Each file is re-printed through the TypeScript printer with: - union members sorted; - type literals made only of property signatures sorted; - top-level statements sorted; - `import` / `export` specifiers sorted; - rollup's chunk hashes stripped. Shared chunks are paired by chunk-name-agnostic content, and the pairing must be unique on both sides. **Results.** - 29 of 31 packages are byte-identical before and after, including 11 of the 12 census packages. - `@objectstack/spec`: 130 files, 0 differ canonically. One chunk is paired by content: `data-engine` (unpatched) is `analytics.zod` (patched). The files differ only in the ways round 1 recorded. - `@objectstack/metadata-protocol` has one entry, so the patch does not change its program count. It differs in bytes only, through union order inlined from spec, and is canonically equal even with statement sorting off. - The other 29 packages are canonically equal as well: 0 differing files, 0 pairing problems. **Negative controls.** In a copy of each patched tree, the first real `string` type node became `number`, located by the TypeScript parser and never inside a comment. The control ran on: - one entry file in each of the 12 census packages (for `platform-objects`, `identity/index.d.ts`, because its `index.d.ts` only re-exports); - one chunk file in each census package that has chunks: `lint`, `metadata-core`, `objectql`, and spec's renamed `analytics.zod` chunk. Every control was caught, and each one named exactly the mutated file. A mutated chunk also fails the content pairing ("no partner"). One control (`platform-objects`) was first pointed at a directory, errored, and was re-run on a real file. That first attempt is not counted. ## Gates, at the merged head (`57e5191f21` = this branch + `origin/main` `fc0db22bcf`) `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 60 commands over 4 changed paths. **Derived set.** All 60 derived gates exited 0. `--ran` reconciliation: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED". That includes: - `check:override-consistency`, `check:vendor-export-contract-resolve`, `check:workspace-manifest-cycles` and `check:osv-exemptions`; - `check:dts-closure`: 72 built packages, 166/166 declared declaration files present; - `check:dual-build-cjs-loads`; - `check:lean-entry-closure`. **Builds.** - `turbo run build --force` over the census closure: 29/29 tasks, 0 cached. - The rest of the workspace: `turbo run build --filter=!@objectstack/docs`, 72/72 tasks. - `pnpm --filter @objectstack/spec build` inside the 8192 MB cgroup: exit 0, peak RSS 3680 MB, 77.6 s, DTS 38.7 s. - `check-dts-emitted`: 36/36. - `check-dts-references`: 130 files, 394/394. - `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15 artifacts up to date. - `pnpm install --frozen-lockfile`: exit 0. **Tests.** The 8 spec test files that read `tsup.config.ts` as text: 130 passed. **Scope.** The patch reaches only the DTS worker: tsup's `index.js` loads `dist/rollup.js` in one place, `new Worker(… "./rollup.js")`. The JS passes are untouched. **Changeset.** None; `skip-changeset`. Per Q2: canonical equality holds for every census package, no JS output or shipped source moves, and 29 of 31 packages are byte-identical. ## Acceptance notes - **The census counts DTS entries, not `entry`.** A tsup package whose `dts.entry` narrows to one file (`create-objectstack`) has one program, whatever its `entry` says. Object-form `entry` counts too (`platform-objects`). - **Chunk names and statement order moved in spec only.** The other 11 census packages emit identical bytes. Those are the kinds of drift two unpatched builds of spec already show, so anything that compares spec's declaration trees by byte digest reads phantom changes (round 1's note stands). - **Every tsup bump now owes the patch a decision.** pnpm refuses an unused patch, as measured above, so the bump PR is where the patch is re-derived or retired. Upstream: rollup-plugin-dts 6.5.1 still has the same code. The report below is for the maintainer to file. - **Upstream reporting stays with the maintainer.** Nothing was written outside this org. ## Draft upstream report (for the maintainer to file at rollup-plugin-dts) > **Title:** `createPrograms` builds one `ts.Program` per entry directory when a `tsconfig` path is passed > > **Version:** rollup-plugin-dts 6.5.1 (also 6.1.1, as bundled by tsup 8.5.1). > > **What happens:** In `getCompilerOptions`, when `overrideConfigPath` (the plugin's `tsconfig` option) is set, the config cache key is that path. The first entry misses the cache, and `dirName` becomes the config's directory. Every later entry hits the cache, and `dirName` stays `path.dirname(input)`. `createPrograms` starts a new program whenever `dirName` changes between consecutive inputs, so entries in different directories each get their own `ts.Program`. Each of those programs parses, binds, and declaration-emits its whole reachable graph again. > > **Impact:** In a package with 18 entries in 18 directories, the declaration pass built 18 programs. It needed a 4997 MB live heap and 153 s. With one program it needed an 882 MB live heap and 41 s, and the emitted declarations were the same types. tsup always passes `tsconfig`, so every multi-entry tsup build takes this path. > > **Suggested fix:** On a cache hit with `overrideConfigPath` set, return the config's directory. Equivalently: `if (overrideConfigPath) dirName = path.dirname(path.resolve(process.cwd(), overrideConfigPath));` before the cached options are read. > > **Reproduction:** Two entries, `src/a/index.ts` and `src/b/index.ts`, passed with `tsconfig: 'tsconfig.json'`. Count the `ts.createProgram` calls: 2. With the line above: 1. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20419
Clause-②: no
What changes
One file,
packages/spec/tsup.config.ts:compilerOptions: { noCheck: true }.The
buildscript inpackages/spec/package.jsonis unchanged, and so is the 6144 ceiling.This PR says
Part of, notFixes. It is a measured mitigation that stays inside the card's file surface. The root cause has a much larger fix, but that fix moves emitted declaration bytes in about a dozen packages. The dispatch routes that kind of change to a separate decision (last section), so the card stays open for it.Measurement: the pass at its ceiling
How it was measured.
NODE_OPTIONS=--max-old-space-size=6144 BUILD_DTS=true tsup), plus--trace-gcon node's argv.--trace-gc.8cdbe0c6e5(dispatch base)8cdbe0c6e5ec6a275177(base + main atdc0ab6a2ed)ec6a2751778cdbe0c6e5(13:30Z) anddc0ab6a2ed(15:13Z). In that last reading, the largest heap before a mark-compact was 5984 MB. The worker's hard heap limit is 6192 MB (6144 old space plus the young generation). That is the margin where V8 gives up withERR_WORKER_OUT_OF_MEMORY. Heap growth mid-pass on the Test Core run was not measured here.scripts/ablation-replace.mjs:compilerOptionsblock (anchor 1 → 0).0bc508b29eab, andgit diff HEADwas empty afterwards.8645ad7e07.pnpm --filter @objectstack/spec buildran inside the same 8 GB cgroup:check-dts-emitted: 36/36 declared declaration files present.check-dts-references: 130 files, 394/394 relative references resolved.Root cause: one
ts.Programper entrycreateProgramsgroups entries into programs by a directory key.getCompilerOptionshits its config cache for every entry after the first, and a cache hit keys the entry by its own directory instead of the config's.Evidence.
ts.createProgramprobe (a--requirepreload in the DTS worker) on a three-entry pass printed three programs, each with one root. They held 193, 123 and 274 non-declaration source files, and did 145, 0 and 69 emits.src/index.ts: 3261 MB;tsc --noEmitover the whole package peaks at 1103 MB in 18 s.Why
noCheckis safenoEmitOnError. Before each emit, every program therefore ran a full semantic check of the file it was emitting.typecheckscript already does that check, astsc --noEmitover the sametsconfig.json, in the requiredTypeScript Type Checkjob.noCheckdrops only the duplicate. Syntactic, option, global and declaration diagnostics still fail the pass, so a declaration that cannot be emitted still stops the build.src/is reported bytypecheck, as it already was. AGENTS.md's Build & Test block already says tsup never type-checks.Emitted declarations: the same types. Byte identity was never a property of main.
The dispatch asked for a byte-identical tree. That cannot be measured against main, because main does not produce the same bytes twice:
8cdbe0c6e5, same config, gave three tree digests:7bf19190…,5cf3234c…,6fd2cecf…. The digest is one sha256 over the sorted list of per-file sha256 values.So the trees were compared in an order-insensitive form. Each file is re-printed through the TypeScript printer with:
Nothing else is normalised.
8cdbe0c6e5: main run b, main run c and the noCheck run give one normalised digest, with 0 files differing.ec6a275177: main behaviour, noCheck, and the tree from the full build give one normalised digest, with 0 files differing.⇒ noCheck's tree differs from main's only in the ways two builds of main already differ from each other.
Why not split the pass across entries
The split was measured. Two halves peak at 3261 MB and 1442 MB, so memory would fit.
But a split redraws rollup's shared chunks. The halves emitted 60 + 32 files against the single pass's 128. A split publishes duplicated declarations across entries, and a class or
unique symboldeclared twice stops being one type. That changes what publishes, which the dispatch puts on a different card.What reads the changed file
dts: false.buildtask. The file is one of spec's package inputs, so spec and its dependents rebuild once in CI.scripts/check-dev-prereqs.mjs. Its build-input hash includestsup.config.ts. A dev dist built before this change reads as stale until it is rebuilt, which is the intended behaviour.turbo run build --filter=@objectstack/docs, so it runs the same pass, now lighter.No consumer of the
buildscript invokes anything new.Gates, final head
8645ad7e07pnpm --filter @objectstack/spec build: exit 0 (8 GB cgroup, above).pnpm --filter @objectstack/spec check:generated: exit 0. All 15 generated artifacts are up to date against the dist that build emitted, with a declaration stamp match.pnpm check:turbo-task-graph: exit 0.pnpm check:dts-closure: exit 0 (36/36).pnpm check:nul-bytes: exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 51 commands.--ranreconciled them as 51 accounted: 49 exit 0, and 2 NOT MEASURED (exit 3, prerequisite):check:dual-build-cjs-loadsneeds every package built.check:lean-entry-closureneeds@objectstack/objectqlbuilt.check:browser-reachable-entries,check:entry-nameability,check:dual-source-exportsandcheck:exported-anyall exit 0.pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: 568 files, 16691 passed, 1 todo.tsup.config.tsas text also passed on their own run (130 tests).typecheckwas not run. The changed file is in none of the package's three tsc programs (tsconfig.jsonandtsconfig.test.jsonincludesrc/**/*;tsconfig.scripts.jsonincludesscripts/**/*), so its inputs match main's.Changeset: none, and
skip-changeset. The JS outputs and every shipped source file are untouched. The declaration tree matches main's in the order-insensitive form above. A byte digest cannot tell this build apart from another build of main.Acceptance notes
8cdbe0c6e5. The docblock's measured table was replaced with today's numbers, dated by commit.Decision needed: the root cause
Cutting 18 programs to one takes the pass from about 5.7 GB to 1.4 GB live, and from about 185 s to 53 s. Measured on spec, the one-program tree matches main's once three more kinds of ordering are also normalised:
import/exportbraces,data-engine→analytics.zod, paired by content).The same kinds of change would reach every other multi-entry tsup package (about eleven, including
core,objectql,metadata,typesandplugin-auth). The options are in the dispatch report on the card; the recommendation there is apnpm patchof tsup's bundled rollup-plugin-dts plus an upstream report.Generated by Claude Code