You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3a2d38c
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments