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/26205-core-one-declaration-per-condition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@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 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, 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.
22 changes: 16 additions & 6 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
68 changes: 61 additions & 7 deletions packages/core/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,13 +12,65 @@ 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 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.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.
*/
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.cts`, `export * from './${name}.js';\n`)),
);
}
: undefined,
},
...(emitDts ? [{ ...shared, clean: false, dts: { only: true }, format: ['esm'] } satisfies Options] : []),
]);
35 changes: 10 additions & 25 deletions scripts/check-dual-build-cjs-loads.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -466,14 +458,7 @@ const DUAL_FORMAT_BEHAVIOUR_PROBES = [
*
* @type {Record<string, {reason: string}>}
*/
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;
Expand Down
Loading