Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/8133-spec-shared-chunks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

build(spec): every entry point shares one module graph, so a schema two entries reach ships once and is the same object through either

Clause-②: no
Metadata-protocol: none

`@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`).

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.

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.
8 changes: 6 additions & 2 deletions packages/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ The package publishes one entry per protocol domain (`@objectstack/spec/data`,
(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract
and the `defineStack()` manifest-collection vocabulary, two deliberately
distinct maps per objectstack#8424).
Each entry is a self-contained bundle: what an entry's module graph reaches is
what every consumer of that entry pays for.
The entries share one module graph: a module two entries reach ships once, in
a shared chunk under `dist/`, and is the same object through either entry.
Chunks are not entry points — import only through the `exports` map. Chunk
boundaries follow FILE-level reachability, so an entry links every chunk its
module graph touches: what an entry's module graph reaches is still what every
consumer of that entry may pay for.

**Standing principle** (maintainer ruling 2026-08-20, recorded verbatim on
objectstack#10096):
Expand Down
16 changes: 8 additions & 8 deletions packages/spec/scripts/check-browser-reachable-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@
* `dist/<entry>/index.mjs` convention. Repointing a subpath at a
* schema-bearing bundle moves what this reads, exactly as it moves what a
* consumer loads.
* - **"it only reads one file."** Today every entry is self-contained
* (`splitting: false`, and the scan finds zero relative specifiers across all
* 34 bundles), so a one-file check would pass today and go quietly blind the
* day `splitting` is turned on and zod arrives through a shared chunk. So the
* walk is TRANSITIVE over relative hops, and a relative specifier that does
* not resolve is a hard error — an incomplete walk reporting "no zod" is the
* false green this gate exists to prevent. The self-test drives that walk
* over a synthetic chunk chain, because this tree cannot exercise it.
* - **"it only reads one file."** Every entry imports shared chunks
* (`tsup.config.ts`, `oneModuleGraph`), so zod or a server-only package
* arrives through a relative hop far more often than in the entry file
* itself. So the walk is TRANSITIVE over relative hops, and a relative
* specifier that does not resolve is a hard error — an incomplete walk
* reporting "no zod" is the false green this gate exists to prevent. The
* self-test drives that walk over a synthetic chunk chain as well, so the
* walk stays pinned whatever the build's chunk layout is.
* - **"it reads prose."** The bundles carry documentation strings, and one of
* them really does contain the text `from '@objectstack/spec/ui'` inside a
* `reason:` string in `dist/index.mjs` — a naive grep reports it as an
Expand Down
230 changes: 192 additions & 38 deletions packages/spec/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { dirname, join, relative, sep } from 'node:path';
import ts from 'typescript';
import { defineConfig, type Options } from 'tsup';

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

/**
* [#11072] Resolve the pg-grammar arm to its browser twin — the whole
* mechanism by which the `browser`-conditioned bundles exclude the
* driver-config validators' Node-only dependency.
* [#11072] The one audited import whose target differs between the Node and
* the browser builds: the pg-grammar arm, resolved to its browser twin
* (`pg-url-grammar.browser.ts`) everywhere except on the Node side of the
* `browser`-conditioned entries.
*
* Keyed to the seam module's specifier, NOT to `pg-connection-string`
* itself, on purpose: a blanket alias of the package would silently degrade
* any FUTURE import site nobody audited, whereas this swap covers exactly
* the one audited seam and a new direct import lands in the browser bundles
* where `check:browser-reachable-entries` refuses it at this producer.
* the one audited seam and a new direct import lands in the browser-reachable
* bundles where `check:browser-reachable-entries` refuses it at this producer.
*/
const swapServerOnlyGrammarArm: Plugin = {
name: 'swap-server-only-grammar-arm',
setup(build) {
build.onResolve({ filter: /[\\/]pg-url-grammar\.server$/ }, (args) => ({
path: join(dirname(args.importer), 'pg-url-grammar.browser.ts'),
}));
const SERVER_ONLY_GRAMMAR_ARM = /[\\/]pg-url-grammar\.server$/;

/** The esbuild namespace of the Node-side copies, and the entry-point spelling that selects it. */
const NODE_SIDE = 'node-side';
const NODE_SIDE_ENTRY = `${NODE_SIDE}:`;

/** Marks a resolution asked on behalf of a Node-side module, so the browser swap stands aside for it. */
const NODE_SIDE_RESOLVE = { nodeSide: true };

/** The output name of an entry: `src/data/index.ts` → `data/index`. */
const outName = (entry: string): string => entry.replace(/^src\//, '').replace(/\.ts$/, '');

/**
* [#8133] Every JS output of this package, as ONE esbuild module graph, so
* that a module two entries reach is ONE module in ONE shared chunk, and the
* symbol it defines is the same object through either entry
* (`@objectstack/spec`'s `defineConnector` IS `@objectstack/spec/integration`'s).
*
* The per-entry layout this replaced inlined each entry's whole graph into its
* own bundle, so a consumer importing several subpaths bundled several copies
* of every schema they share: measured on `@objectstack/spec@18.0.0`, the
* symbols objectui's console imports cost 1,542 KB gzipped from that layout and
* 452 KB from one graph, and `FieldSchema` was defined in 14 published files.
* `sideEffects: false` and `pureSchemaConstruction` (#10031) let a bundler drop
* what one entry never reaches; they cannot merge copies across entries. Only
* shared chunks can.
*
* ## Why one graph needs two copies of a few modules
*
* A browser bundler loads the ordinary bundles of every entry outside
* `browserConditionedEntries`, so those must never link the pg-grammar arm's
* Node-only dependency (`check:browser-reachable-entries`, rule 2). With
* per-entry bundles, tree-shaking kept it out. With shared chunks it cannot:
* esbuild assigns a module to a chunk by FILE-LEVEL reachability, so an entry
* links every chunk its import graph touches, used or not — measured with a
* bare `splitting: true`, `./shared` and `./migrations` linked the chunk that
* imports `pg-connection-string`, the #11072 breakage. So the browser swap
* above applies to the whole graph, and the Node outputs of the
* `browser`-conditioned entries are built from a NODE-SIDE copy of exactly the
* modules that reach the seam (measured: 28 of 459 at the time of writing; the
* entry files themselves, the barrels above the driver configs, the conversion
* and migration registries) — every other module is shared by every output,
* Node and browser alike.
*
* Which modules reach the seam is not a list kept here. It is measured on each
* build, by a metafile pre-pass over the Node graph of those entries, and the
* pre-pass refuses when the seam is no longer in it — in which case the
* `browser` conditions are moot and withdrawing them is a decision, not
* something this plugin should paper over.
*
* Behaviour is unchanged for every output: the Node outputs of the conditioned
* entries still run the pg grammar; every other output still never runs it
* (the per-entry bundles of those entries linked no pg grammar at all, which
* `check:browser-reachable-entries` held), and now carries its browser twin
* where a chunk it shares reaches the driver configs. The same symbol is the
* same object across all outputs except the exports of the node-side modules,
* which keep one object per side.
*/
const oneModuleGraph: Plugin = {
name: 'one-module-graph',
async setup(build) {
const cwd = build.initialOptions.absWorkingDir ?? process.cwd();
// A copy, never an edit in place: tsup hands the SAME entry object to the
// ESM and the CJS run, so an in-place edit would hide these entries from
// whichever run starts second.
const points = { ...(build.initialOptions.entryPoints as Record<string, string>) };
const nodeSideSources: string[] = [];
for (const [out, source] of Object.entries(points)) {
if (out.startsWith('browser/') || !browserConditionedEntries.includes(source)) continue;
points[out] = NODE_SIDE_ENTRY + source;
nodeSideSources.push(source);
}
build.initialOptions.entryPoints = points;
const nodeSide = nodeSideSources.length > 0 ? await modulesReachingTheSeam(build, cwd, nodeSideSources) : new Set();
const toKey = (path: string): string => relative(cwd, path).split(sep).join('/');

build.onResolve({ filter: new RegExp(`^${NODE_SIDE_ENTRY}`) }, (args) =>
args.kind === 'entry-point' ? { path: args.path.slice(NODE_SIDE_ENTRY.length), namespace: NODE_SIDE } : undefined,
);
build.onResolve({ filter: SERVER_ONLY_GRAMMAR_ARM, namespace: 'file' }, (args) =>
args.pluginData?.nodeSide === true ? undefined : { path: join(dirname(args.importer), 'pg-url-grammar.browser.ts') },
);
// An import made by a node-side module: resolved as usual, minus the swap,
// then kept on the node side only when its target reaches the seam.
build.onResolve({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
const resolved = await build.resolve(args.path, {
kind: args.kind,
importer: args.importer,
resolveDir: args.resolveDir,
namespace: 'file',
pluginData: NODE_SIDE_RESOLVE,
});
if (resolved.errors.length > 0) return { errors: resolved.errors };
if (!resolved.external && resolved.namespace === 'file' && nodeSide.has(toKey(resolved.path))) {
return { path: toKey(resolved.path), namespace: NODE_SIDE };
}
const { path, external, namespace, sideEffects, suffix } = resolved;
return { path, external, namespace, sideEffects, suffix };
});
build.onLoad({ filter: /.*/, namespace: NODE_SIDE }, async (args) => {
const file = join(cwd, args.path);
const source = await readFile(file, 'utf8');
return { contents: annotatePureCalls(source, file) ?? source, loader: 'ts', resolveDir: dirname(file) };
});
},
};

/**
* The source modules, keyed by their path relative to `cwd`, from which the
* seam is reachable in the Node graph of `sources` — the set that needs a
* node-side copy. Refuses when the seam is not in that graph.
*/
async function modulesReachingTheSeam(
build: Parameters<Plugin['setup']>[0],
cwd: string,
sources: readonly string[],
): Promise<ReadonlySet<string>> {
const { metafile } = await build.esbuild.build({
entryPoints: [...sources],
absWorkingDir: cwd,
bundle: true,
write: false,
metafile: true,
format: 'esm',
platform: 'node',
packages: 'external',
outdir: 'node-side-probe',
logLevel: 'silent',
});
const importers = new Map<string, string[]>();
const reached = new Set<string>();
for (const [file, input] of Object.entries(metafile.inputs)) {
for (const imported of input.imports) {
if (imported.external) continue;
if (imported.original !== undefined && SERVER_ONLY_GRAMMAR_ARM.test(imported.original)) reached.add(imported.path);
importers.set(imported.path, [...(importers.get(imported.path) ?? []), file]);
}
}
if (reached.size === 0) {
throw new Error(
`one-module-graph: no module of the Node graph of ${sources.join(', ')} imports the pg-grammar arm ` +
`(${SERVER_ONLY_GRAMMAR_ARM}). Either the seam moved — update SERVER_ONLY_GRAMMAR_ARM — or these ` +
'entries no longer reach the driver-config validators, and their `browser` conditions are moot: ' +
'withdrawing them is a decision, not something this build should paper over.',
);
}
const pending = [...reached];
for (let file = pending.pop(); file !== undefined; file = pending.pop()) {
for (const importer of importers.get(file) ?? []) {
if (!reached.has(importer)) {
reached.add(importer);
pending.push(importer);
}
}
}
return reached;
}

/**
* Generate DTS separately to avoid memory issues — this pass is by far the
* heaviest thing this package's build does, and `package.json` runs it under an
Expand Down Expand Up @@ -313,8 +463,10 @@ const swapServerOnlyGrammarArm: Plugin = {
*/
const isDts = process.env.BUILD_DTS === 'true';

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

/**
* The `browser`-conditioned outputs (#11072) — same entry shapes, same
* formats, same target as the main pass, differing ONLY in the grammar-arm
* swap and the `dist/browser/` outDir. No DTS pass of its own: the browser
* build's public API is identical by construction (the swapped module keeps
* the contract), so the exports map points its `browser.types` at the main
* build's declarations.
* Every JS output, as one module graph with shared chunks (`oneModuleGraph`,
* #8133): each entry at `dist/<entry>`, and the `browser`-conditioned outputs
* (#11072) at `dist/browser/<entry>` — the same entry shapes, formats and
* target, differing only in the grammar-arm swap. No DTS of their own: the
* browser build's public API is identical by construction (the swapped module
* keeps the contract), so the exports map points `browser.types` at the main
* declarations. Chunks land at the `dist/` root, `chunk-<hash>.mjs` and
* `chunk-<hash>.js`; nothing outside this package may name one — the exports
* map is the only contract, and it is unchanged.
*
* `oneModuleGraph` goes FIRST: its node-side modules must be loaded by its own
* `onLoad`, not by `pureSchemaConstruction`'s file-namespace reading, and it
* annotates them the same way.
*/
const browserConfig: Options = {
entry: browserConditionedEntries,
outDir: 'dist/browser',
splitting: false,
sourcemap: true,
clean: false, // dist was cleaned (or preserved) by the main pass
dts: false,
format: ['esm', 'cjs'],
target: 'es2020',
treeshake: true,
esbuildPlugins: [pureSchemaConstruction, swapServerOnlyGrammarArm],
esbuildOptions: dropSourcesContent,
const jsConfig: Options = {
...mainConfig,
entry: Object.fromEntries([
...entries.map((entry) => [outName(entry), entry]),
...browserConditionedEntries.map((entry) => [`browser/${outName(entry)}`, entry]),
]),
splitting: true,
esbuildPlugins: [oneModuleGraph, pureSchemaConstruction],
};

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