Skip to content

@objectstack/spec ships 18 self-contained entry bundles with no shared chunks, so a consumer mixing entry points gets different ConnectorSchema / defineConnector objects (measured) #8133

Closed as not planned

Description

@hotlong

Restart-when: any PR touches the packages/spec build/publish pipeline (tsup config, or the exports map in packages/spec/package.json), or a consumer is measured breaking on cross-entry identity or bundle size

Filing unassigned; found while implementing #7991 (PR #8129, the packages/qa/downstream-contract source alias). Deliberately not fixed there — that card's ruling is that a problem the dist boundary was hiding is a finding in its own right, and this one is about how packages/spec is built and published, which is a different blast radius from a QA harness alias.

The fact, measured against the published dist directly

Not through vitest, not through any alias — a bare import() of the built artifacts:

const a = await import('./packages/spec/dist/index.mjs');
const b = await import('./packages/spec/dist/integration/index.mjs');
a.defineConnector === b.defineConnector   // false
a.ConnectorSchema  === b.ConnectorSchema  // false

Both specifiers are public: @objectstack/spec and @objectstack/spec/integration are entry points in the package's own exports map, and both export these symbols. A third party that imports one from the root and one from the namespace holds two different objects.

Why: the subpath bundles share nothing

Each entry bundle inlines its whole dependency graph. dist/integration/index.mjs opens with exactly one import, and it is external:

import { z } from 'zod';

There is no shared internal chunk to import — so ConnectorSchema's definition is emitted independently into every entry bundle that reaches it. Measured on a fresh build:

  • 18 entry points in the exports map, each a self-contained bundle.
  • ConnectorSchema is defined in 5 of them (index, integration, kernel, system, cloud).
  • Largest bundles: system/index.mjs 1.9M, index.mjs 1.6M, kernel/index.mjs 1.4M, cloud/index.mjs 1.3M, api/index.mjs 1.2M.
  • packages/spec/dist totals 101M.

Impact — stated honestly, because I did not demonstrate a live break

What is measured is the duplication and the identity split. What I did not do is find a consumer that breaks on it today, so this should be graded rather than assumed:

  • Identity comparison across entry points is silently false. Anything keyed on schema identity — a Map or WeakMap keyed by a Zod schema, a registry that de-dupes by object reference, an instanceof-style check — gets two entries where the author expected one. This fails quietly; there is no error, just a lookup miss.
  • zod itself is external, so the usual worst case (two Zod runtimes disagreeing about what a ZodType is) does not apply here. That meaningfully lowers the severity and is why I am not filing this as a P0.
  • Payload duplication is real regardless of identity: a consumer importing three namespaces pulls three overlapping copies.

The reason it is worth a card even undemonstrated: this is the shape of bug that is invisible until it isn't, and #7991 is a fresh example of the same family — a property of dist that no test could see, being consumed as if it were a property of the source.

Note on how this surfaced

PR #8129 adds a pin case asserting defineConnector is one object across the root and the integration namespace. Aliased to source it passes (one tree, one copy); unaliased it fails. That pin's purpose there is to guard the alias and to stand watch for a dual instance, and it does both — but the underlying duplication it revealed belongs to the build, not to that harness, which is why it is filed here instead.

Fix directions (not chosen here)

  • A — enable code splitting for the multi-entry build so shared modules land in a common chunk that every entry imports. Restores identity and collapses the duplication; needs a check that the emitted chunk layout is compatible with the exports map and with CJS consumers.
  • B — accept the duplication and remove the ambiguity by making namespace subpaths re-export from the root bundle rather than rebuild it.
  • C — decide the identity guarantee is not offered and say so in the package docs, so nobody writes identity-keyed code against it.

A is the contract-first shape: the symbols are documented as the same exports, so they should be the same objects. Which of these is right is a maintainer call about how spec is published, which is why this is filed rather than patched.

Prior art (searched before filing)

Searched open issues for dual-instance / subpath-exports / duplicate-chunk / tsup-code-splitting phrasings against this repo; no open issue covers this. Related but distinct:


Generated by Claude Code


Generated by Claude Code

Activity

  1. added theissue type on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    Triage: routed domain:spec-tooling (build/publish machinery of packages/spec — the machine face, not the semantic face), held as finding, type Task.

    Grading rationale: measured and real (identity split + 101M dist + 5 independent copies of ConnectorSchema), but no live break demonstrated — the card itself says so, and zod being external removes the worst dual-runtime case. Under the startup-scope lens this does not earn a dispatch slot today; under the AI-error-resistance lens the identity trap is real but currently guarded by the #8129 pin standing watch. Choosing between code-splitting the published artifact (A), root re-export (B), or documenting no-identity-guarantee (C) is a publish-shape decision with CJS-compat blast radius — if this promotes, it promotes to needs-user-decision, not straight to queue.

    Re-grade triggers, either of which reopens grading: (1) any consumer measured breaking on cross-entry identity or bundle size in practice; (2) the next time spec's build/publish pipeline is touched for another reason — fold the decision in then, when the surface is already hot.


    Generated by Claude Code

  3. hotlong commented on Aug 12, 2026

    @hotlong
    ContributorAuthor

    The same duplication has a type-axis cost, measured under #8021 (PR #8176) — recording it here rather than filing a twin, since it is this issue's root cause seen from the other side.

    #8021 put a paths block in packages/qa/downstream-contract/tsconfig.json so the backward-compatibility gate's typecheck reads packages/spec/src instead of packages/spec/dist/*.d.ts. The obvious way to pin the bare-entry rule looked like the type-level twin of this issue's runtime assertion (expect(fromPackageRoot).toBe(fromNamespace)): an Equal identity check between typeof defineConnector reached through @objectstack/spec and through @objectstack/spec/integration.

    It had to be rejected on measurement. In the mixed state that check exists to detect — root resolving to dist, the namespace resolving to src, i.e. exactly the two-copies condition this issue describes — tsc had not finished comparing the two zod-derived types after nine minutes and was killed. The same configuration with that one assertion removed completes in seconds, so the cost is the cross-tree structural comparison itself, not the mixed program.

    So the runtime symptom of the 18-self-contained-entry-bundles layout is "two different function objects"; the type-level symptom is a structural comparison that does not terminate in useful time. Practical consequence for anyone else pinning this invariant: the identity assertion is available on the runtime axis and not available on the type axis. PR #8176 pins the bare-entry rule by simulating tsc's paths resolution instead, the way check:test-source-alias pins the Vite side.


    Generated by Claude Code


    Generated by Claude Code

  4. hotlong commented on Aug 13, 2026

    @hotlong
    ContributorAuthor

    State-machine migration, maintainer-authorized (2026-08-13, live triage session: 「我授权你先迁标签」; semantics on #8449): this card is a graded hold — verdict and re-grade triggers are in the grading comments above — so finding → pm:on-hold. Verdict, triggers, domain unchanged.


    Generated by Claude Code

  5. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    Contributor

    Evidence update, not a release (triage seat, session session_014qTKTqjme5Fp5BH9iRmy6t): #10031 measured a consumer-side bundle-size cost of this package's publish shape (+237.3 KB per subpath import, no tree-shaking, no sideEffects field) — adjacent to this card's Restart-when second arm but a deliberate consumer trade (objectui#5287 pinned a local copy), not a break, so the hold stands. #10031 is now in the decision inbox with a recommendation to decide both publish-pipeline questions together; if that ruling lands, it supersedes this hold — the implementing seat should treat both cards as one worksite.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    关 not_planned —— 维护者逐张复核 on-hold 卡时同意关闭;体积那一半已解决,身份那一半没有实际受害者

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T08:17Z。维护者 2026-09-23 在分诊会话里逐张复核 pm:on-hold 卡,对第三组的回复原文:「第三组 同意」。 本卡在那一组里的建议是关闭。

    呈给维护者的事实(本地 main 上核对过)

    重开条件

    有使用方实测到因为跨入口身份不一致而出错(例如以 schema 对象作键的 Map/WeakMap 查不到),或者因为包体积出了真实问题。届时按正文 A/B/C 三个方向选一个。

    关闭理由:not_planned,同时摘掉 pm:on-hold。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions