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
37 changes: 37 additions & 0 deletions .changeset/dead-testing-cjs-not-published.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@objectstack/metadata-core': patch
'@objectstack/service-cluster': patch
---

fix(metadata-core,service-cluster): stop emitting and publishing the CJS half of `./testing` (#13013)

#13001 made both `./testing` subpaths ESM-only, dropping the `require` condition
that pointed at `dist/testing.cjs`. The build kept emitting those files and
`files: ["dist"]` kept packing them, so every release shipped bytes no exports
condition could reach. Measured with `npm pack --dry-run`, before → after:

| package | files | unpacked | dropped |
|---|---|---|---|
| `@objectstack/metadata-core` | 22 → 16 | 3.3 MB → 3.2 MB | `testing.cjs` (28.0 kB), `testing.cjs.map` (48.4 kB), `testing.d.cts` (9.4 kB), `chunk-H2D6OJ76.cjs` (4.2 kB) + map (10.6 kB), `repository-*.d.cts` |
| `@objectstack/service-cluster` | 15 → 12 | 364.1 kB → 336.9 kB | `testing.cjs` (11.9 kB), `testing.cjs.map` (14.5 kB), `testing.d.cts` (794 B) |

Nothing reachable changed. The whole ESM surface of both packages — `index.js`,
`testing.js`, their maps, the shared chunk, and every declaration the manifest
names — is **byte-for-byte identical** to the previous build (sha256, before vs
after). `index.cjs` changes only because what was a shared CJS chunk is now
inlined into the sole remaining CJS entry.

Each `tsup.config.ts` becomes an array of two configs split **by format** —
ESM keeps both entries, CJS takes `src/index.ts` alone. The split is by format
and never by entry: `index` and `testing` share a chunk carrying the error
classes, and one config per entry would give `testing.js` its own copies, so
`ConflictError` reached through `@objectstack/metadata-core/testing` would stop
being the class thrown by `@objectstack/metadata-core` — which the published
contract suite asserts (`.rejects.toBeInstanceOf(ConflictError)`).

`clean` moves out of tsup and into the `build` script (`rm -rf dist && tsup`).
tsup runs an array config through `Promise.all`, so the halves build
concurrently and a `clean` in either races the other's writes; the script-level
clean is also stronger than tsup's own, which preserves `*.d.{ts,cts,mts}` and
would therefore have left a stale `dist/testing.d.cts` behind on every rebuild
of an existing worktree.
2 changes: 1 addition & 1 deletion packages/metadata-core/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,7 @@
"CHANGELOG.md"
],
"scripts": {
"build": "tsup && node ../../scripts/check-dts-emitted.mjs",
"build": "rm -rf dist && tsup && node ../../scripts/check-dts-emitted.mjs",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run",
Expand Down
55 changes: 48 additions & 7 deletions packages/metadata-core/tsup.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineConfig } from 'tsup';
import { defineConfig, type Options } from 'tsup';

export default defineConfig({
entry: ['src/index.ts', 'src/testing.ts'],
// Everything both halves below share. Spelled once so the two cannot drift in
// anything except the two properties they exist to differ in: `entry`/`format`.
//
// [#13013] `clean` is NOT here, and is `false` in both halves — deliberately.
// tsup runs an array config through `Promise.all` (`tsup/dist/index.js`, the
// `Array.isArray(configData)` map), so the halves build CONCURRENTLY: a `clean`
// in either one races the other's writes and can delete output that has already
// landed, in either direction. The output folder is emptied once, before tsup
// starts, by the `build` script in package.json. That is also a STRONGER clean
// than tsup's own, which unshifts `!**/*.d.{ts,cts,mts}` and so PRESERVES stale
// declarations — including exactly the `dist/testing.d.cts` this split exists
// to stop emitting, which would otherwise survive every rebuild of an existing
// worktree.
const shared: Options = {
splitting: true,
sourcemap: true,
clean: true,
clean: false,
dts: !process.env.OS_SKIP_DTS,
format: ['esm', 'cjs'],
target: 'es2020',
// [#12971] LOAD-BEARING. `artifact-forward-conversion.ts` anchors its
// `@objectstack/spec` version lookup with `createRequire(import.meta.url)`
Expand All@@ -32,7 +43,37 @@ export default defineConfig({
// history. `pnpm check:dual-build-cjs-loads` holds the class: it
// `require()`s every dual-built package's CJS entry point and reds on this
// exact SyntaxError. Need-based injection — nothing here references
// `__dirname`/`__filename`, so the ESM build's shim path is a no-op.
// `__dirname`/`__filename`, so the ESM build's shim path is a no-op, which
// is why it stays on BOTH halves rather than only the CJS one: identical
// options mean the ESM output is byte-for-byte what the single config
// emitted before the split.
shims: true,
external: ['vitest'],
});
};

// [#13013] The split is by FORMAT, never by ENTRY — that distinction is the
// whole design and reversing it is a silent breaking change.
//
// `./testing` lost its `require` condition in #13001, so `dist/testing.cjs`,
// its map and `dist/testing.d.cts` became unreachable through the manifest
// while `files: ["dist"]` kept packing them for npm. Only the CJS half needs
// to drop that entry.
//
// ⛔ Do NOT "simplify" this into one config per ENTRY. Both entries stay
// together in the ESM half because they SHARE A CHUNK
// (`src/errors.ts` + `src/canonicalize.ts`), and that chunk carries the error
// CLASSES. One config per entry gives `testing.js` its own copy of them, so
// `ConflictError` reached through `@objectstack/metadata-core/testing` stops
// being the class thrown by `@objectstack/metadata-core` — and the contract
// suite this entry point exists to publish asserts exactly that identity
// (`src/contract-suite.ts`: `.rejects.toBeInstanceOf(ConflictError)`). Every
// downstream driver package running the suite would fail on a change that
// looks like a build-config tidy-up.
//
// The CJS half needs no such care: `.` is its only entry point, so there is
// exactly one copy of those modules in the CJS output either way (with one
// entry esbuild inlines what used to be a shared chunk).
export default defineConfig([
{ ...shared, entry: ['src/index.ts', 'src/testing.ts'], format: ['esm'] },
{ ...shared, entry: ['src/index.ts'], format: ['cjs'] },
]);
2 changes: 1 addition & 1 deletion packages/services/service-cluster/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@
}
},
"scripts": {
"build": "tsup && node ../../../scripts/check-dts-emitted.mjs",
"build": "rm -rf dist && tsup && node ../../../scripts/check-dts-emitted.mjs",
"test": "vitest run"
},
"dependencies": {
Expand Down
42 changes: 36 additions & 6 deletions packages/services/service-cluster/tsup.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { defineConfig } from 'tsup';
import { defineConfig, type Options } from 'tsup';

export default defineConfig({
entry: ['src/index.ts', 'src/testing.ts'],
// Everything both halves below share. Spelled once so the two cannot drift in
// anything except the two properties they exist to differ in: `entry`/`format`.
//
// [#13013] `clean` is NOT here, and is `false` in both halves — deliberately.
// tsup runs an array config through `Promise.all` (`tsup/dist/index.js`, the
// `Array.isArray(configData)` map), so the halves build CONCURRENTLY: a `clean`
// in either one races the other's writes and can delete output that has already
// landed, in either direction. The output folder is emptied once, before tsup
// starts, by the `build` script in package.json. That is also a STRONGER clean
// than tsup's own, which unshifts `!**/*.d.{ts,cts,mts}` and so PRESERVES stale
// declarations — including exactly the `dist/testing.d.cts` this split exists
// to stop emitting, which would otherwise survive every rebuild of an existing
// worktree.
const shared: Options = {
splitting: true,
sourcemap: true,
clean: true,
clean: false,
dts: !process.env.OS_SKIP_DTS,
format: ['esm', 'cjs'],
target: 'es2020',
external: ['vitest'],
});
};

// [#13013] The split is by FORMAT, never by ENTRY.
//
// `./testing` lost its `require` condition in #13001, so `dist/testing.cjs`,
// its map and `dist/testing.d.cts` became unreachable through the manifest
// while `files: ["dist"]` kept packing them for npm. Only the CJS half needs
// to drop that entry.
//
// ⛔ Do NOT "simplify" this into one config per ENTRY. Both entries stay
// together in the ESM half so that anything they share stays a shared chunk
// rather than two copies with two module identities. Nothing is shared here
// today — `src/testing.ts` imports only `vitest` and type-only symbols, and
// the build emits no chunk at all — but that is a property of today's source,
// not of this config, and the sibling `packages/metadata-core` carries the
// measured version of what per-entry splitting costs when it stops holding.
export default defineConfig([
{ ...shared, entry: ['src/index.ts', 'src/testing.ts'], format: ['esm'] },
{ ...shared, entry: ['src/index.ts'], format: ['cjs'] },
]);
Loading