diff --git a/.changeset/dead-testing-cjs-not-published.md b/.changeset/dead-testing-cjs-not-published.md new file mode 100644 index 0000000000..e524b1ce69 --- /dev/null +++ b/.changeset/dead-testing-cjs-not-published.md @@ -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. diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 4e61c57b8f..e9814915f9 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -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", diff --git a/packages/metadata-core/tsup.config.ts b/packages/metadata-core/tsup.config.ts index ef53c3e7bf..f09047352c 100644 --- a/packages/metadata-core/tsup.config.ts +++ b/packages/metadata-core/tsup.config.ts @@ -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)` @@ -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'] }, +]); diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index 19a40e267b..53466f9483 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -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": { diff --git a/packages/services/service-cluster/tsup.config.ts b/packages/services/service-cluster/tsup.config.ts index 31546183ca..a895e6be22 100644 --- a/packages/services/service-cluster/tsup.config.ts +++ b/packages/services/service-cluster/tsup.config.ts @@ -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'] }, +]);