From e58317931ad4074c57965762b97a8c49a06c3eea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:19:34 +0000 Subject: [PATCH 1/3] fix(core): one declaration serves both module formats, so exports give types per condition Core's declaration pass runs once, for CJS; the ESM .d.ts files are one-line re-exports of the .d.cts ones, so ObjectKernel has one identity whichever resolution mode reaches it. exports now hands each condition its own types, and the two core rows leave TYPED_EXEMPTIONS in check-dual-build-cjs-loads (shrink-only: the entries are now correct). Claude-Session: https://claude.ai/code/session_01VPkxx6ackwXchvtVNidtum Co-authored-by: Claude --- ...6205-core-one-declaration-per-condition.md | 12 ++++ packages/core/package.json | 22 +++++-- packages/core/tsup.config.ts | 62 ++++++++++++++++--- scripts/check-dual-build-cjs-loads.mjs | 35 +++-------- 4 files changed, 93 insertions(+), 38 deletions(-) create mode 100644 .changeset/26205-core-one-declaration-per-condition.md diff --git a/.changeset/26205-core-one-declaration-per-condition.md b/.changeset/26205-core-one-declaration-per-condition.md new file mode 100644 index 00000000000..4639e776a79 --- /dev/null +++ b/.changeset/26205-core-one-declaration-per-condition.md @@ -0,0 +1,12 @@ +--- +'@objectstack/core': patch +--- + +fix(core): one declaration serves both module formats, so the export map hands each condition its own `types` + +Clause-②: no +Metadata-protocol: none + +`@objectstack/core` used to emit two separate declarations of every class, `dist/index.d.ts` and `dist/index.d.cts`, and its export map served the ESM-kind `index.d.ts` to both conditions, so a TypeScript consumer resolving `require` was told the package is ESM (attw `FalseESM` on `.` and `./logger`). Serving the `.d.cts` to `require` was not possible while the two files declared `ObjectKernel` twice: its `private plugins` member makes the two copies incompatible types for any program that reaches core through both resolution modes. + +Core's declaration build now runs once, for the CommonJS format. `dist/index.d.cts` and `dist/logger.d.cts` carry the declarations, and `dist/index.d.ts` and `dist/logger.d.ts` are one-line re-exports of them, so both conditions hand out the same classes. `exports` now gives types per condition, `{ import: { types, default }, require: { types, default } }`, on both subpaths. No subpath is added or removed, no JavaScript file changes, and the `dist` file names are unchanged. Nothing for a consumer to do. diff --git a/packages/core/package.json b/packages/core/package.json index 5f7cf571e15..3efedfab56e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,14 +9,24 @@ "sideEffects": false, "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, "./logger": { - "types": "./dist/logger.d.ts", - "import": "./dist/logger.js", - "require": "./dist/logger.cjs" + "import": { + "types": "./dist/logger.d.ts", + "default": "./dist/logger.js" + }, + "require": { + "types": "./dist/logger.d.cts", + "default": "./dist/logger.cjs" + } } }, "scripts": { diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 63e561824a7..81a49d85875 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,4 +1,6 @@ -import { defineConfig } from 'tsup'; +import { writeFile } from 'node:fs/promises'; + +import { defineConfig, type Options } from 'tsup'; import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; @@ -10,13 +12,59 @@ import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs' * so importing the client SDK in a browser bundle does NOT drag in * Node-only kernel internals (e.g. `process.cpuUsage()`). */ -export default defineConfig({ - entry: ['src/index.ts', 'src/logger.ts'], +const ENTRY_NAMES = ['index', 'logger'] as const; +const emitDts = !process.env.OS_SKIP_DTS; + +const shared: Options = { + entry: ENTRY_NAMES.map((name) => `src/${name}.ts`), splitting: false, sourcemap: true, - clean: true, - dts: !process.env.OS_SKIP_DTS, - format: ['esm', 'cjs'], target: 'es2020', esbuildOptions: dropSourcesContent, -}); +}; + +/** + * ONE declaration per entry, shared by both formats. + * + * tsup's own `dts` emits a declaration per FORMAT — `index.d.ts` beside the ESM + * build and `index.d.cts` beside the CJS one — and those are two separate + * declarations of every class. TypeScript compares a class with a `private` + * member nominally, so `ObjectKernel` (which carries `private plugins` and + * reaches every plugin through `PluginContext.getKernel()`) becomes two + * incompatible types for any compilation that reaches core through both + * resolution modes, the moment the export map serves each format its own + * declaration. Measured: per-condition `types` over the twin declarations + * fail `pnpm --filter @objectstack/plugin-dev build` with TS2345 in + * `src/dev-plugin.ts` (its CommonJS build `await import()`s + * `@objectstack/objectql`, which reaches core the other way). + * + * So the declaration pass runs once, for CJS only, and the ESM `index.d.ts` is + * a one-line re-export of `index.d.cts`: each condition is still handed a + * declaration of its own module kind (attw: no `FalseESM`, no `FalseCJS`), + * but every class in both IS the `.d.cts` one. The re-export runs ESM → CJS on + * purpose: an ES module may import a CommonJS one under every `module` + * setting, while the reverse (`.d.cts` re-exporting `.d.ts`) is TS1479 in a + * consumer's program: measured under `module: node16` on TypeScript 6.0 and + * 5.6, and under `nodenext` too on 5.6. + * + * The JS half's `onSuccess` writes the `.d.ts` files and the declaration half + * never emits one, so the two halves (which tsup runs concurrently) never + * write the same file. `clean` lives on the JS half only: tsup's clean keeps + * declaration files, so it cannot delete what the declaration half writes. + */ +export default defineConfig([ + { + ...shared, + clean: true, + dts: false, + format: ['esm', 'cjs'], + onSuccess: emitDts + ? async () => { + await Promise.all( + ENTRY_NAMES.map((name) => writeFile(`dist/${name}.d.ts`, `export * from './${name}.cjs';\n`)), + ); + } + : undefined, + }, + ...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['cjs'] } satisfies Options] : []), +]); diff --git a/scripts/check-dual-build-cjs-loads.mjs b/scripts/check-dual-build-cjs-loads.mjs index 99ca76cf032..2a7dbd2b3fa 100644 --- a/scripts/check-dual-build-cjs-loads.mjs +++ b/scripts/check-dual-build-cjs-loads.mjs @@ -440,23 +440,15 @@ const DUAL_FORMAT_BEHAVIOUR_PROBES = [ * the type axis, and it is a property of the DEPENDENCY, never of the consumer * that trips over it. * - * `@objectstack/core` is where the repo actually meets it. `ObjectKernel` - * carries `private plugins` and travels through `PluginContext.getKernel()` - * into every plugin's `init`, so the moment core ships two declarations, any - * compilation that reaches core through BOTH resolution modes sees two - * `ObjectKernel`s. Measured on this branch, whole-repo `pnpm build`: - * - * 28 packages split (core included) RED — @objectstack/verify, 5 × TS2345 - * 27 packages split (core held back) GREEN — 71/71 tasks - * 28 + the 37-package ESM mirror RED — @objectstack/plugin-dev, TS2345 - * - * The mirror round is the informative one: it moves the failure rather than - * removing it. Splitting `@objectstack/objectql` is what fixes `verify` and - * what breaks `plugin-dev`, because a split dependency resolves core one way - * from its `.d.mts` and the other from its `.d.ts`. So the exemption is not - * "core is hard", it is: **core cannot be split until `ObjectKernel`'s - * identity stops being nominal**, and that is a decision about core's public - * types, not about an exports map. + * `@objectstack/core` is where the repo met it: `ObjectKernel` carries + * `private plugins` and travels through `PluginContext.getKernel()` into every + * plugin's `init`, so twin declarations of core broke any compilation that + * reaches core through BOTH resolution modes (`@objectstack/plugin-dev`'s + * CommonJS build, TS2345). The cure is at the dependency, not in this table: + * core's declaration pass runs once and its other-format declaration is a + * one-line re-export of it (`packages/core/tsup.config.ts`), so both + * conditions hand out the SAME classes. A package that meets the hazard again + * takes that cure first; a row here is for an entry that cannot. * * ⛔ An exemption here is NOT a quieter red. It is a declaration that the * entry's `.d.cts` ships unreachable ON PURPOSE, with the measurement that @@ -466,14 +458,7 @@ const DUAL_FORMAT_BEHAVIOUR_PROBES = [ * * @type {Record} */ -const TYPED_EXEMPTIONS = Object.freeze({ - '@objectstack/core#.': { - reason: 'Splitting core\'s declarations gives `ObjectKernel` (which carries `private plugins`) two nominal identities, and it crosses every plugin boundary via `PluginContext.getKernel()`. Measured: with core split, `pnpm build` fails in @objectstack/verify with 5 × TS2345; with core held back and the other 27 packages split, 71/71 tasks pass. Blocked on a decision about core\'s public types, not on this exports map (#13112).', - }, - '@objectstack/core#./logger': { - reason: 'Same declaration set as `@objectstack/core#.` — the subpath is emitted from the same tsup pass and splits the same identities. Held back with its sibling so core has one resolution story rather than two (#13112).', - }, -}); +const TYPED_EXEMPTIONS = Object.freeze({}); const EXIT_OK = 0; const EXIT_FINDINGS = 1; From 4bb9800c74af9259354d3754b7a92a37eb65e08f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:25:41 +0000 Subject: [PATCH 2/3] fix(core): keep the declarations in the ESM .d.ts and re-export them from the .d.cts Putting the declarations in the .d.cts made core's own imports of @objectstack/spec resolve through spec's require condition, and spec's twin declarations then failed driver-memory's build (TS2345). With the ESM .d.ts as the one declaration, everything core's types import resolves as before. Claude-Session: https://claude.ai/code/session_01VPkxx6ackwXchvtVNidtum Co-authored-by: Claude --- ...6205-core-one-declaration-per-condition.md | 4 ++- packages/core/tsup.config.ts | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.changeset/26205-core-one-declaration-per-condition.md b/.changeset/26205-core-one-declaration-per-condition.md index 4639e776a79..ae73a0a7075 100644 --- a/.changeset/26205-core-one-declaration-per-condition.md +++ b/.changeset/26205-core-one-declaration-per-condition.md @@ -9,4 +9,6 @@ Metadata-protocol: none `@objectstack/core` used to emit two separate declarations of every class, `dist/index.d.ts` and `dist/index.d.cts`, and its export map served the ESM-kind `index.d.ts` to both conditions, so a TypeScript consumer resolving `require` was told the package is ESM (attw `FalseESM` on `.` and `./logger`). Serving the `.d.cts` to `require` was not possible while the two files declared `ObjectKernel` twice: its `private plugins` member makes the two copies incompatible types for any program that reaches core through both resolution modes. -Core's declaration build now runs once, for the CommonJS format. `dist/index.d.cts` and `dist/logger.d.cts` carry the declarations, and `dist/index.d.ts` and `dist/logger.d.ts` are one-line re-exports of them, so both conditions hand out the same classes. `exports` now gives types per condition, `{ import: { types, default }, require: { types, default } }`, on both subpaths. No subpath is added or removed, no JavaScript file changes, and the `dist` file names are unchanged. Nothing for a consumer to do. +Core's declaration build now runs once, for the ESM format. `dist/index.d.ts` and `dist/logger.d.ts` carry the declarations, as before, and `dist/index.d.cts` and `dist/logger.d.cts` are one-line re-exports of them, so both conditions hand out the same classes. `exports` now gives types per condition, `{ import: { types, default }, require: { types, default } }`, on both subpaths. No subpath is added or removed, no JavaScript file changes, and the `dist` file names are unchanged. + +A CommonJS consumer compiling with `module: node16` (or with TypeScript older than 6 under `nodenext`) used to get TS1479 at its own `import ... from '@objectstack/core'`. That diagnostic now sits inside core's `.d.cts`, where `skipLibCheck` silences it. Nothing else for a consumer to do. diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 81a49d85875..de6c99db6cd 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -38,16 +38,22 @@ const shared: Options = { * `src/dev-plugin.ts` (its CommonJS build `await import()`s * `@objectstack/objectql`, which reaches core the other way). * - * So the declaration pass runs once, for CJS only, and the ESM `index.d.ts` is - * a one-line re-export of `index.d.cts`: each condition is still handed a - * declaration of its own module kind (attw: no `FalseESM`, no `FalseCJS`), - * but every class in both IS the `.d.cts` one. The re-export runs ESM → CJS on - * purpose: an ES module may import a CommonJS one under every `module` - * setting, while the reverse (`.d.cts` re-exporting `.d.ts`) is TS1479 in a - * consumer's program: measured under `module: node16` on TypeScript 6.0 and - * 5.6, and under `nodenext` too on 5.6. + * So the declaration pass runs once, for ESM only, and the CommonJS + * `index.d.cts` is a one-line re-export of `index.d.ts`: each condition is + * still handed a declaration of its own module kind (attw: no `FalseESM`), but + * every class in both IS the `.d.ts` one, and what that declaration imports + * from `@objectstack/spec` resolves exactly as it did when both conditions + * read the `.d.ts`. ⛔ Not the other way round: with the declarations in the + * `.d.cts` and the `.d.ts` re-exporting it, core's own imports of spec resolve + * through spec's `require` condition, and spec's twin declarations then fail + * `@objectstack/driver-memory`'s build (TS2345, `ComposedReadableIds` against + * itself). The `.d.cts` → `.d.ts` re-export is TS1479 inside this package's + * file for a consumer on `module: node16` (or on TypeScript 5.6 under + * `nodenext`), which `skipLibCheck` silences; the single `types` entry this + * replaces raised that same TS1479 at the consumer's own import, where no + * option silences it. * - * The JS half's `onSuccess` writes the `.d.ts` files and the declaration half + * The JS half's `onSuccess` writes the `.d.cts` files and the declaration half * never emits one, so the two halves (which tsup runs concurrently) never * write the same file. `clean` lives on the JS half only: tsup's clean keeps * declaration files, so it cannot delete what the declaration half writes. @@ -61,10 +67,10 @@ export default defineConfig([ onSuccess: emitDts ? async () => { await Promise.all( - ENTRY_NAMES.map((name) => writeFile(`dist/${name}.d.ts`, `export * from './${name}.cjs';\n`)), + ENTRY_NAMES.map((name) => writeFile(`dist/${name}.d.cts`, `export * from './${name}.js';\n`)), ); } : undefined, }, - ...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['cjs'] } satisfies Options] : []), + ...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['esm'] } satisfies Options] : []), ]); From e645ebb3b5390728338f05d7095e4314718a2c1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:32:36 +0000 Subject: [PATCH 3/3] docs(changeset): say which TypeScript the nodenext TS1479 was measured on Claude-Session: https://claude.ai/code/session_01VPkxx6ackwXchvtVNidtum Co-authored-by: Claude --- .changeset/26205-core-one-declaration-per-condition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/26205-core-one-declaration-per-condition.md b/.changeset/26205-core-one-declaration-per-condition.md index ae73a0a7075..cacfdfb4f90 100644 --- a/.changeset/26205-core-one-declaration-per-condition.md +++ b/.changeset/26205-core-one-declaration-per-condition.md @@ -11,4 +11,4 @@ Metadata-protocol: none Core's declaration build now runs once, for the ESM format. `dist/index.d.ts` and `dist/logger.d.ts` carry the declarations, as before, and `dist/index.d.cts` and `dist/logger.d.cts` are one-line re-exports of them, so both conditions hand out the same classes. `exports` now gives types per condition, `{ import: { types, default }, require: { types, default } }`, on both subpaths. No subpath is added or removed, no JavaScript file changes, and the `dist` file names are unchanged. -A CommonJS consumer compiling with `module: node16` (or with TypeScript older than 6 under `nodenext`) used to get TS1479 at its own `import ... from '@objectstack/core'`. That diagnostic now sits inside core's `.d.cts`, where `skipLibCheck` silences it. Nothing else for a consumer to do. +A CommonJS consumer compiling with `module: node16` (or, measured on TypeScript 5.6, under `nodenext` too) used to get TS1479 at its own `import ... from '@objectstack/core'`. That diagnostic now sits inside core's `.d.cts`, where `skipLibCheck` silences it. Nothing else for a consumer to do.