Skip to content

Commit 3a2d38c

Browse files
build(spec): one module graph with shared chunks, so each schema ships once and is one object across entries (#25748)
Fixes #8133 Clause-②: no Metadata-protocol: none Ruling-ref: none ## What changes `@objectstack/spec`'s JS build is now **one esbuild module graph with shared chunks** for every output (ESM and CJS, Node and `browser`-conditioned). A module two entries reach is one module in one `dist/chunk-HASH.{mjs,js}` file, so it ships once and is the same object through either entry. Only `packages/spec/tsup.config.ts` changes behaviour. The `exports` map and the declaration build are untouched. ### Why a bare `splitting: true` was not enough (measured, then rejected) With `splitting: true` on the existing two passes, `./shared` and `./migrations` (subpaths with no `browser` condition, so a browser bundler loads their ordinary bundles) started linking the chunk that imports `pg-connection-string`. esbuild assigns a module to a chunk by **file-level** reachability, so an entry links every chunk its import graph touches, used or not. Per-entry tree-shaking had kept them clean. That is the breakage class `check:browser-reachable-entries` rule 2 refuses: the controlled experiment below failed to bundle with `Could not resolve "fs"`. A plain two-pass split would also have left the browser graph and the Node graph as two copies of everything they share. ### What the config does instead (`oneModuleGraph`) - **One graph.** Entries: the 19 ordinary outputs at `dist/ENTRY` plus the 5 `browser`-conditioned outputs at `dist/browser/ENTRY`. The browser swap of the pg-grammar arm (the 2026-08-22 ruling's mechanism, keyed to the same seam specifier) now applies to the whole graph. - **Node-side copy of only the seam's importers.** The Node outputs of the 5 `browser`-conditioned entries are built from a `node-side` esbuild namespace. It re-instantiates exactly the modules from which the seam is reachable (measured at BASE: **28 of 459** source modules: the entry barrels, the driver configs, the datasource, stack and conversion/migration registries). Every other module resolves back to the shared graph. - **Measured per build, not listed.** A metafile pre-pass over those entries' Node graph computes the set. It **refuses the build** when no module imports the seam any more: then the `browser` conditions are moot, and withdrawing them is a decision. - **Behaviour unchanged.** The Node outputs of the conditioned entries still run the pg grammar. Every other output still never runs it. Their per-entry bundles linked no pg grammar at all, which the gate held at BASE. Where a chunk they share reaches the driver configs, they now carry the browser twin. ## Measurements All readings are on this worktree. BASE = `origin/main` `47af3dd`. HEAD = `5a40a420` (this branch with `origin/main` `c61f75d1` merged; every HEAD reading below was re-taken there, and was identical at the pre-merge head `19a8800a` except the experiment, 602.9 KB there). Full `pnpm --filter @objectstack/spec build` both times. | | BASE | HEAD | |---|---|---| | `a.defineConnector === b.defineConnector` (root vs `/integration`), ESM | false | **true** | | same, CJS | false | **true** | | shared object/function exports identical across entries, `browser` view (119 pairs) | 0 | **119** | | same, Node view (119 pairs) | 0 | **114** | | `.mjs` files defining `var FieldSchema =` | 14 | **1** | | `.mjs` files defining `var ConnectorSchema =` | 3 | **1** | | total `packages/spec/dist` | 123.5M | **53.5M** | | JS + maps (excl. `.d.ts`) | 93.9M | **23.9M** | | declaration files | 134 | 134 (same set, hash-stripped) | | objectui import experiment, gzip 9 | 1,628.4 KB | **603.6 KB** (−63%) | - **Node view, 5 not identical.** All 5 are root vs `/shared`: `MAP_SUPPORTED_FIELDS`, `METADATA_ALIASES`, `normalizeMetadataCollection`, `normalizePluginMetadata`, `normalizeStackInput`. They live in `shared/metadata-collection.zod.ts`, which reaches the seam, so the root's Node output carries the node-side copy and `/shared` (unconditioned) the shared one. Under the `browser` condition they are one object. - **`ConnectorSchema` on the card.** The card's `a.ConnectorSchema === b.ConnectorSchema` compares `undefined` to an object: the root entry does not export `ConnectorSchema` at top level, before or after. Only `/integration` does. `defineConnector` is the meaningful half, and it holds. - **Controlled experiment.** The card's experiment, re-derived on today's trees. objectui `a3c0da9` (current `main`) value imports: **256 symbols across 11 subpaths**, parsed with the TypeScript parser, type-only imports excluded. Each import comes from the subpath objectui uses. esbuild `bundle` + `minify` + `platform: browser`, gzip level 9, zod bundled in every arm. - BASE per-entry `dist`: **1,628.4 KB**. - HEAD `dist`: **603.6 KB**. - Control, one module graph from `src` with the same pure-annotation and swap plugins: **538.5 KB**. - The card's figures (1,542 / 452 KB on `18.0.0`, 232 symbols) are the same shape on an older tree. - **Chunk graph.** Acyclic in both formats: 0 cycles over 1,181 ESM and 1,158 CJS relative edges. This is why CJS takes shared chunks safely (Rollup converts each chunk ESM→CJS under tsup's `treeshake`, with no partially-initialized `require`). `check:dual-build-cjs-loads` loads every `require` entry and is green. **NOT MEASURED: objectui `Bundle Analysis` `vendor-objectstack`.** That number comes from objectui CI on a pin that carries this change: objectui's own pin move, which also re-baselines its eager-closure ceilings. The experiment above is the local stand-in, not that number. ## Deep-path consumers (risk: chunk names are not stable) - **objectui** `a3c0da9`: three `@objectstack/spec/dist/index.{js,mjs}` strings, all regex probe inputs in tests, not imports (`scripts/__tests__/vite-objectstack-spec-dist.test.ts:481`, `:946`, `:1165`; `scripts/__tests__/vite-objectstack-client-dist.test.ts:398`). Both files still exist. - `scripts/objectstack-pin.mjs` hard-links the package's whole tree, so chunks travel with it. - The `vendor-objectstack` group test matches any file under the package directory, so chunks group there. - **HotCRM** `a4b3560`: zero `@objectstack/spec/dist` / `src` / `json-schema` deep imports. ## Gates and tests All on `5a40a420` unless stated. Exit codes were captured before any pipe. - **Derived union** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 72 commands. **71 exit 0, 1 exit 1** (`pnpm --filter @objectstack/spec run check:liveness`, the main-red below). `--ran` reconciliation: `72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero from 72 recorded exit codes. On the pre-merge head `19a8800a`, all 72 exited 0. One of them, `check-plugin-teardown-shape --self-test`, first exited 3 because of a shallow clone, then 0 after `git fetch --depth=1` of its pinned fixture commit. - **Dist-reading gates on `5a40a420`:** `check:browser-reachable-entries` (48 bundles walked transitively; 10 server-only links, all on the Node side of the 5 conditioned entries), `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:dts-closure`, `check:sourcemap-no-sources-content`, `check:published-files` and `check:dev-prereqs` (local-only; after rebuilding the two packages the merge moved): all exit 0. - **Whole workspace build** at `19a8800a`, `pnpm build`: `Tasks: 76 successful, 76 total`, `examples/*` included. - **Every consumer's tests** at `19a8800a`, root `pnpm test` (`turbo run test test:repo`, all 152 tasks, every package that reads `@objectstack/spec` through `dist`): `Tasks: 152 successful, 152 total`, lock `VERDICT command-exit 0`. The run was interrupted once by a worker restart and resumed from turbo's cache: 125 cached, 27 executed in the second run, and no failure in either run. - Inside it, the dogfood battery: Test Files 295 passed, 3 skipped (298); Tests 2520 passed, 18 skipped (2538). - `@objectstack/cli` (unit + integration): 500 passed, 1 skipped (501); Tests 6291 passed. - `packages/qa/downstream-contract`: 3 files / 31 tests passed. - `@objectstack/spec`: `test` 721/721 files, `test:repo` 79/79. - **`@objectstack/spec` on `5a40a420`:** `typecheck` exit 0, including the test layer that compiles `tsup.config.ts` through `pure-schema-construction.test.ts` (that reading was taken at `19a8800a`). `test` gives 720 passed, 1 failed (721); the failing file is `scripts/liveness/check-liveness.test.ts` (23 tests), the same main-red. `test:repo` was not reached after it. - **Declaration build unaffected:** the DTS-pass options object (`BUILD_DTS=true`, plugins by name) is identical at BASE and HEAD (sha256 prefix `ccf850853f3c0699`). There are 134 declaration files, the same set once hashes are stripped. The DTS pass is not byte-stable from run to run (`tsup.config.ts`'s docblock), so it is compared by its inputs and its file set, not by a digest. - **Ablations** (`scripts/ablation-replace.mjs`, each leg verified on disk, each restored blob-identical to `HEAD` `9c9b83dc`, then rebuilt). Predicted, then observed: - **A1** (browser swap off): predicted red on rule 2. Observed red, 14 problems: all 10 `dist/browser/**` bundles plus `./shared` and `./migrations` in both formats. The first attempt was refused by the tool as a no-op (the replacement was a substring of the anchor), so nothing ran; re-run with a unique replacement. - **A2** (node-side twinning off): predicted a positive-control red. Observed `POSITIVE CONTROL FAILED`. - **A3** (seam regex matches nothing): predicted the build refuses. Observed `one-module-graph: no module of the Node graph of … imports the pg-grammar arm` in both the ESM and CJS runs. - Restore leg: rebuilt, `check:browser-reachable-entries` green, every measurement identical. **Main-red, not this PR's: `check:liveness` and `check-liveness.test.ts`.** Green on `19a8800a`, red after merging `origin/main` `c61f75d1`, and still red on `origin/main` `f9a1e93f`. This PR's diff vs `main` touches no ledger, no `service-automation` file and nothing under `src/`. - **The failing row.** The ledger row `translation/flows.screens` cites `packages/services/service-automation/src/engine.ts#renderFlowTextSlot`. - **Why it was green before.** `engine.ts` named `screens` only through a comment ("two screens down"). `0189524a` (#25734) moved that comment into `run-resume.ts`. - **Why a repoint is not enough.** `renderFlowTextSlot` itself now lives in `node-text-rendering.ts`, which never names `screens` either. - **Proposed patch, for the owning lane (not applied here).** Drop the `engine.ts#renderFlowTextSlot` clause from that row's `evidence`. `builtin/screen-nodes.ts#registerScreenNodes`, already cited, is the reader that spells the key. Or cite `node-text-rendering.ts#renderFlowTextSlot` with an evidence-scope note. Fix PR known to this run: none. CI on this PR is the authority for the families above: the required five, Build Core's bundle-size step, Test Core. ## Acceptance notes - **Comments under `packages/spec/src/**` that describe the old layout.** The dispatch puts `src/**` out of scope, so they are not edited here. Each is now inaccurate about the layout, not about behaviour: - `src/index.ts:73`: "Every subpath entry is a self-contained bundle". - `src/conversions/registry.ts:18144`: "Each published entry is one flat file (`tsup.config.ts`, `splitting: false`)". The PURE-annotation reasoning it supports still holds per chunk. - `src/api/package-api-assembled.zod.ts:40`: a historical sentence. Its TSDoc renders into the generated `content/docs/references/api/package-api-assembled.mdx`. Proposed: one comment-only follow-up, routed by the seat. - `packages/spec/README.md` (published) is corrected here. `check:browser-reachable-entries`' docblock said "this tree cannot exercise" a chunk walk; now it does, and the docblock says so. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q3XCv7AXcUSSD479Vt1LXy)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 5e7cf63 commit 3a2d38c

4 files changed

Lines changed: 220 additions & 48 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
build(spec): every entry point shares one module graph, so a schema two entries reach ships once and is the same object through either
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`@objectstack/spec`'s JS outputs used to be one self-contained bundle per entry point. A consumer that imported several subpaths bundled one copy of every schema those subpaths share (`FieldSchema` was defined in 14 published `.mjs` files), and the same symbol reached through two subpaths was two different objects (`defineConnector` from `@objectstack/spec` was not `defineConnector` from `@objectstack/spec/integration`).
11+
12+
The build now emits shared chunks (`dist/chunk-<hash>.mjs` and `dist/chunk-<hash>.js`) that every entry imports. Measured on the console's own import list (256 value imports across 11 subpaths, esbuild `bundle` + `minify` + `platform: browser`, gzip level 9): 1,628.4 KB gzipped from the per-entry layout, 602.9 KB from this one. `FieldSchema` is defined once. Across the 119 export names two entries both expose, all 119 are now the same object under the `browser` condition, and 114 under Node, against 0 before. The 5 that still differ under Node are the metadata-collection normalizers reached through both `@objectstack/spec` and `@objectstack/spec/shared`: they sit on the path to the Node-only postgres URL grammar, so the Node outputs of the `browser`-conditioned entries keep their own copy of them.
13+
14+
What to do: nothing. The `exports` map and the type declarations are unchanged. Import only through the `exports` map: the chunk files are not entry points, and their names change from build to build.

‎packages/spec/README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ The package publishes one entry per protocol domain (`@objectstack/spec/data`,
1919
(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract
2020
and the `defineStack()` manifest-collection vocabulary, two deliberately
2121
distinct maps per objectstack#8424).
22-
Each entry is a self-contained bundle: what an entry's module graph reaches is
23-
what every consumer of that entry pays for.
22+
The entries share one module graph: a module two entries reach ships once, in
23+
a shared chunk under `dist/`, and is the same object through either entry.
24+
Chunks are not entry points — import only through the `exports` map. Chunk
25+
boundaries follow FILE-level reachability, so an entry links every chunk its
26+
module graph touches: what an entry's module graph reaches is still what every
27+
consumer of that entry may pay for.
2428

2529
**Standing principle** (maintainer ruling 2026-08-20, recorded verbatim on
2630
objectstack#10096):

‎packages/spec/scripts/check-browser-reachable-entries.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,14 @@
6161
* `dist/<entry>/index.mjs` convention. Repointing a subpath at a
6262
* schema-bearing bundle moves what this reads, exactly as it moves what a
6363
* consumer loads.
64-
* - **"it only reads one file."** Today every entry is self-contained
65-
* (`splitting: false`, and the scan finds zero relative specifiers across all
66-
* 34 bundles), so a one-file check would pass today and go quietly blind the
67-
* day `splitting` is turned on and zod arrives through a shared chunk. So the
68-
* walk is TRANSITIVE over relative hops, and a relative specifier that does
69-
* not resolve is a hard error — an incomplete walk reporting "no zod" is the
70-
* false green this gate exists to prevent. The self-test drives that walk
71-
* over a synthetic chunk chain, because this tree cannot exercise it.
64+
* - **"it only reads one file."** Every entry imports shared chunks
65+
* (`tsup.config.ts`, `oneModuleGraph`), so zod or a server-only package
66+
* arrives through a relative hop far more often than in the entry file
67+
* itself. So the walk is TRANSITIVE over relative hops, and a relative
68+
* specifier that does not resolve is a hard error — an incomplete walk
69+
* reporting "no zod" is the false green this gate exists to prevent. The
70+
* self-test drives that walk over a synthetic chunk chain as well, so the
71+
* walk stays pinned whatever the build's chunk layout is.
7272
* - **"it reads prose."** The bundles carry documentation strings, and one of
7373
* them really does contain the text `from '@objectstack/spec/ui'` inside a
7474
* `reason:` string in `dist/index.mjs` — a naive grep reports it as an

‎packages/spec/tsup.config.ts‎

Lines changed: 192 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { readFile } from 'node:fs/promises';
4-
import { dirname, join } from 'node:path';
4+
import { dirname, join, relative, sep } from 'node:path';
55
import ts from 'typescript';
66
import { defineConfig, type Options } from 'tsup';
77

@@ -161,9 +161,9 @@ const entries = [
161161
'src/identity/index.ts',
162162
'src/shared/index.ts',
163163
// [#10096] Schema-free fine-grained entry for the `/meta` URL-spelling
164-
// contract — per-entry self-contained bundling is unchanged (#8133 stays on
165-
// hold); this entry's whole graph is two pure modules, so "self-contained"
166-
// costs a few hundred bytes here by construction.
164+
// contract: its whole graph is two pure modules, so the chunks it links are
165+
// a few hundred bytes by construction (`check:browser-reachable-entries`
166+
// holds it zod-free across every chunk it reaches).
167167
'src/meta-spelling/index.ts',
168168
// The ADR-0087 migration chain + change manifest, off the root entry: its
169169
// registry is mostly `os migrate meta` guidance text, and its import-time work
@@ -208,25 +208,175 @@ const browserConditionedEntries = [
208208
];
209209

210210
/**
211-
* [#11072] Resolve the pg-grammar arm to its browser twin — the whole
212-
* mechanism by which the `browser`-conditioned bundles exclude the
213-
* driver-config validators' Node-only dependency.
211+
* [#11072] The one audited import whose target differs between the Node and
212+
* the browser builds: the pg-grammar arm, resolved to its browser twin
213+
* (`pg-url-grammar.browser.ts`) everywhere except on the Node side of the
214+
* `browser`-conditioned entries.
214215
*
215216
* Keyed to the seam module's specifier, NOT to `pg-connection-string`
216217
* itself, on purpose: a blanket alias of the package would silently degrade
217218
* any FUTURE import site nobody audited, whereas this swap covers exactly
218-
* the one audited seam and a new direct import lands in the browser bundles
219-
* where `check:browser-reachable-entries` refuses it at this producer.
219+
* the one audited seam and a new direct import lands in the browser-reachable
220+
* bundles where `check:browser-reachable-entries` refuses it at this producer.
220221
*/
221-
const swapServerOnlyGrammarArm: Plugin = {
222-
name: 'swap-server-only-grammar-arm',
223-
setup(build) {
224-
build.onResolve({ filter: /[\\/]pg-url-grammar\.server$/ }, (args) => ({
225-
path: join(dirname(args.importer), 'pg-url-grammar.browser.ts'),
226-
}));
222+
const SERVER_ONLY_GRAMMAR_ARM = /[\\/]pg-url-grammar\.server$/;
223+
224+
/** The esbuild namespace of the Node-side copies, and the entry-point spelling that selects it. */
225+
const NODE_SIDE = 'node-side';
226+
const NODE_SIDE_ENTRY = `${NODE_SIDE}:`;
227+
228+
/** Marks a resolution asked on behalf of a Node-side module, so the browser swap stands aside for it. */
229+
const NODE_SIDE_RESOLVE = { nodeSide: true };
230+
231+
/** The output name of an entry: `src/data/index.ts` → `data/index`. */
232+
const outName = (entry: string): string => entry.replace(/^src\//, '').replace(/\.ts$/, '');
233+
234+
/**
235+
* [#8133] Every JS output of this package, as ONE esbuild module graph, so
236+
* that a module two entries reach is ONE module in ONE shared chunk, and the
237+
* symbol it defines is the same object through either entry
238+
* (`@objectstack/spec`'s `defineConnector` IS `@objectstack/spec/integration`'s).
239+
*
240+
* The per-entry layout this replaced inlined each entry's whole graph into its
241+
* own bundle, so a consumer importing several subpaths bundled several copies
242+
* of every schema they share: measured on `@objectstack/spec@18.0.0`, the
243+
* symbols objectui's console imports cost 1,542 KB gzipped from that layout and
244+
* 452 KB from one graph, and `FieldSchema` was defined in 14 published files.
245+
* `sideEffects: false` and `pureSchemaConstruction` (#10031) let a bundler drop
246+
* what one entry never reaches; they cannot merge copies across entries. Only
247+
* shared chunks can.
248+
*
249+
* ## Why one graph needs two copies of a few modules
250+
*
251+
* A browser bundler loads the ordinary bundles of every entry outside
252+
* `browserConditionedEntries`, so those must never link the pg-grammar arm's
253+
* Node-only dependency (`check:browser-reachable-entries`, rule 2). With
254+
* per-entry bundles, tree-shaking kept it out. With shared chunks it cannot:
255+
* esbuild assigns a module to a chunk by FILE-LEVEL reachability, so an entry
256+
* links every chunk its import graph touches, used or not — measured with a
257+
* bare `splitting: true`, `./shared` and `./migrations` linked the chunk that
258+
* imports `pg-connection-string`, the #11072 breakage. So the browser swap
259+
* above applies to the whole graph, and the Node outputs of the
260+
* `browser`-conditioned entries are built from a NODE-SIDE copy of exactly the
261+
* modules that reach the seam (measured: 28 of 459 at the time of writing; the
262+
* entry files themselves, the barrels above the driver configs, the conversion
263+
* and migration registries) — every other module is shared by every output,
264+
* Node and browser alike.
265+
*
266+
* Which modules reach the seam is not a list kept here. It is measured on each
267+
* build, by a metafile pre-pass over the Node graph of those entries, and the
268+
* pre-pass refuses when the seam is no longer in it — in which case the
269+
* `browser` conditions are moot and withdrawing them is a decision, not
270+
* something this plugin should paper over.
271+
*
272+
* Behaviour is unchanged for every output: the Node outputs of the conditioned
273+
* entries still run the pg grammar; every other output still never runs it
274+
* (the per-entry bundles of those entries linked no pg grammar at all, which
275+
* `check:browser-reachable-entries` held), and now carries its browser twin
276+
* where a chunk it shares reaches the driver configs. The same symbol is the
277+
* same object across all outputs except the exports of the node-side modules,
278+
* which keep one object per side.
279+
*/
280+
const oneModuleGraph: Plugin = {
281+
name: 'one-module-graph',
282+
async setup(build) {
283+
const cwd = build.initialOptions.absWorkingDir ?? process.cwd();
284+
// A copy, never an edit in place: tsup hands the SAME entry object to the
285+
// ESM and the CJS run, so an in-place edit would hide these entries from
286+
// whichever run starts second.
287+
const points = { ...(build.initialOptions.entryPoints as Record<string, string>) };
288+
const nodeSideSources: string[] = [];
289+
for (const [out, source] of Object.entries(points)) {
290+
if (out.startsWith('browser/') || !browserConditionedEntries.includes(source)) continue;
291+
points[out] = NODE_SIDE_ENTRY + source;
292+
nodeSideSources.push(source);
293+
}
294+
build.initialOptions.entryPoints = points;
295+
const nodeSide = nodeSideSources.length > 0 ? await modulesReachingTheSeam(build, cwd, nodeSideSources) : new Set();
296+
const toKey = (path: string): string => relative(cwd, path).split(sep).join('/');
297+
298+
build.onResolve({ filter: new RegExp(`^${NODE_SIDE_ENTRY}`) }, (args) =>
299+
args.kind === 'entry-point' ? { path: args.path.slice(NODE_SIDE_ENTRY.length), namespace: NODE_SIDE } : undefined,
300+
);
301+
build.onResolve({ filter: SERVER_ONLY_GRAMMAR_ARM, namespace: 'file' }, (args) =>
302+
args.pluginData?.nodeSide === true ? undefined : { path: join(dirname(args.importer), 'pg-url-grammar.browser.ts') },
303+
);
304+
// An import made by a node-side module: resolved as usual, minus the swap,
305+
// then kept on the node side only when its target reaches the seam.
306+
build.onResolve({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
307+
const resolved = await build.resolve(args.path, {
308+
kind: args.kind,
309+
importer: args.importer,
310+
resolveDir: args.resolveDir,
311+
namespace: 'file',
312+
pluginData: NODE_SIDE_RESOLVE,
313+
});
314+
if (resolved.errors.length > 0) return { errors: resolved.errors };
315+
if (!resolved.external && resolved.namespace === 'file' && nodeSide.has(toKey(resolved.path))) {
316+
return { path: toKey(resolved.path), namespace: NODE_SIDE };
317+
}
318+
const { path, external, namespace, sideEffects, suffix } = resolved;
319+
return { path, external, namespace, sideEffects, suffix };
320+
});
321+
build.onLoad({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
322+
const file = join(cwd, args.path);
323+
const source = await readFile(file, 'utf8');
324+
return { contents: annotatePureCalls(source, file) ?? source, loader: 'ts', resolveDir: dirname(file) };
325+
});
227326
},
228327
};
229328

329+
/**
330+
* The source modules, keyed by their path relative to `cwd`, from which the
331+
* seam is reachable in the Node graph of `sources` — the set that needs a
332+
* node-side copy. Refuses when the seam is not in that graph.
333+
*/
334+
async function modulesReachingTheSeam(
335+
build: Parameters<Plugin['setup']>[0],
336+
cwd: string,
337+
sources: readonly string[],
338+
): Promise<ReadonlySet<string>> {
339+
const { metafile } = await build.esbuild.build({
340+
entryPoints: [...sources],
341+
absWorkingDir: cwd,
342+
bundle: true,
343+
write: false,
344+
metafile: true,
345+
format: 'esm',
346+
platform: 'node',
347+
packages: 'external',
348+
outdir: 'node-side-probe',
349+
logLevel: 'silent',
350+
});
351+
const importers = new Map<string, string[]>();
352+
const reached = new Set<string>();
353+
for (const [file, input] of Object.entries(metafile.inputs)) {
354+
for (const imported of input.imports) {
355+
if (imported.external) continue;
356+
if (imported.original !== undefined && SERVER_ONLY_GRAMMAR_ARM.test(imported.original)) reached.add(imported.path);
357+
importers.set(imported.path, [...(importers.get(imported.path) ?? []), file]);
358+
}
359+
}
360+
if (reached.size === 0) {
361+
throw new Error(
362+
`one-module-graph: no module of the Node graph of ${sources.join(', ')} imports the pg-grammar arm ` +
363+
`(${SERVER_ONLY_GRAMMAR_ARM}). Either the seam moved — update SERVER_ONLY_GRAMMAR_ARM — or these ` +
364+
'entries no longer reach the driver-config validators, and their `browser` conditions are moot: ' +
365+
'withdrawing them is a decision, not something this build should paper over.',
366+
);
367+
}
368+
const pending = [...reached];
369+
for (let file = pending.pop(); file !== undefined; file = pending.pop()) {
370+
for (const importer of importers.get(file) ?? []) {
371+
if (!reached.has(importer)) {
372+
reached.add(importer);
373+
pending.push(importer);
374+
}
375+
}
376+
}
377+
return reached;
378+
}
379+
230380
/**
231381
* Generate DTS separately to avoid memory issues — this pass is by far the
232382
* heaviest thing this package's build does, and `package.json` runs it under an
@@ -313,8 +463,10 @@ const swapServerOnlyGrammarArm: Plugin = {
313463
*/
314464
const isDts = process.env.BUILD_DTS === 'true';
315465

316-
// Exported for `scripts/pure-schema-construction.test.ts`, which builds the
317-
// `./migrations` entry with these options; tsup itself reads only the default.
466+
// The per-entry options. The DTS pass reads exactly these (declarations once
467+
// per entry, one program — the docblock above). Exported for
468+
// `scripts/pure-schema-construction.test.ts`, which builds single entries with
469+
// them; the published JS is `jsConfig` below, which derives from them.
318470
export const mainConfig: Options = {
319471
entry: entries,
320472
splitting: false,
@@ -330,27 +482,29 @@ export const mainConfig: Options = {
330482
};
331483

332484
/**
333-
* The `browser`-conditioned outputs (#11072) — same entry shapes, same
334-
* formats, same target as the main pass, differing ONLY in the grammar-arm
335-
* swap and the `dist/browser/` outDir. No DTS pass of its own: the browser
336-
* build's public API is identical by construction (the swapped module keeps
337-
* the contract), so the exports map points its `browser.types` at the main
338-
* build's declarations.
485+
* Every JS output, as one module graph with shared chunks (`oneModuleGraph`,
486+
* #8133): each entry at `dist/<entry>`, and the `browser`-conditioned outputs
487+
* (#11072) at `dist/browser/<entry>` — the same entry shapes, formats and
488+
* target, differing only in the grammar-arm swap. No DTS of their own: the
489+
* browser build's public API is identical by construction (the swapped module
490+
* keeps the contract), so the exports map points `browser.types` at the main
491+
* declarations. Chunks land at the `dist/` root, `chunk-<hash>.mjs` and
492+
* `chunk-<hash>.js`; nothing outside this package may name one — the exports
493+
* map is the only contract, and it is unchanged.
494+
*
495+
* `oneModuleGraph` goes FIRST: its node-side modules must be loaded by its own
496+
* `onLoad`, not by `pureSchemaConstruction`'s file-namespace reading, and it
497+
* annotates them the same way.
339498
*/
340-
const browserConfig: Options = {
341-
entry: browserConditionedEntries,
342-
outDir: 'dist/browser',
343-
splitting: false,
344-
sourcemap: true,
345-
clean: false, // dist was cleaned (or preserved) by the main pass
346-
dts: false,
347-
format: ['esm', 'cjs'],
348-
target: 'es2020',
349-
treeshake: true,
350-
esbuildPlugins: [pureSchemaConstruction, swapServerOnlyGrammarArm],
351-
esbuildOptions: dropSourcesContent,
499+
const jsConfig: Options = {
500+
...mainConfig,
501+
entry: Object.fromEntries([
502+
...entries.map((entry) => [outName(entry), entry]),
503+
...browserConditionedEntries.map((entry) => [`browser/${outName(entry)}`, entry]),
504+
]),
505+
splitting: true,
506+
esbuildPlugins: [oneModuleGraph, pureSchemaConstruction],
352507
};
353508

354-
// The DTS pass re-runs only the main config (declarations once, per entry);
355-
// the JS pass emits the Node outputs and then the browser-conditioned ones.
356-
export default defineConfig(isDts ? mainConfig : [mainConfig, browserConfig]);
509+
// The DTS pass re-runs only the per-entry config; the JS pass is the one graph.
510+
export default defineConfig(isDts ? mainConfig : jsConfig);

0 commit comments

Comments
 (0)