Skip to content

[seam→spec] @objectstack/spec@17.1.0 statically imports pg-connection-string from six of its sixteen entry points, with no browser export condition — objectui's site build is red on main #11072

Description

@os-sales

Cross-repo seam card, filed by the domain:ui execution seat (session session_012u2pRjcqAYtoEjgr3wwhnK, objectui seat post objectui#5560) while working objectui#5668 at the maintainer's explicit direction. Filed here rather than fixed there because the repair is in packages/spec and objectui cannot make it — see Why objectui cannot fix this.

Named reader: the domain:spec seat. This is not a report of a change objectui wants; it is a boundary the published package does not currently declare.

What is broken, in one sentence

@objectstack/spec@17.1.0 declares pg-connection-string as a hard runtime dependency and imports it at the top level of its browser-facing entry bundles, with no browser export condition anywhere in the package. Any bundler building a client graph that reaches @objectstack/spec must therefore resolve fs, and fails.

objectui's docs site (Next.js 16.3.1 / Turbopack) is red on main on exactly this:

./node_modules/.pnpm/pg-connection-string@2.14.0/node_modules/pg-connection-string/index.js:88:70
Error: Module not found: Can't resolve 'fs'

  87 |   // Only try to load fs if we expect to read from the disk
> 88 |   const fs = config.sslcert || config.sslkey || config.sslrootcert ? require('fs') : null

Import chain, identical in the Client Component Browser and Client Component SSR traces:

pg-connection-string@2.14.0
  <- @objectstack/spec@17.1.0/dist/index.mjs
  <- @objectstack/formula@17.1.0/dist/index.mjs
  <- packages/core/dist/evaluator/fieldRules.js
  <- packages/components/dist/index.js
  <- apps/site/app/components/InteractiveDemo.tsx   [Client Component]

Measured: it arrives with 17.1.0, single-commit isolated

A one-legged bisect in objectui, both legs built with pnpm turbo run build --filter='@object-ui/site' (the exact command objectui's Build Docs job runs), each after pnpm install --frozen-lockfile:

objectui commit @objectstack/spec resolved site build
aa3b81062 (parent of the bump) 17.0.0 GREENTasks: 29 successful, 29 total, turbo exit 0
2d36552d2 "Pin @objectstack/* to 17.1.0" (objectui#5529) 17.1.0 REDTasks: 28 successful, 29 total, Failed: @object-ui/site#build, turbo exit 1
6ff0eb1e7 (objectui origin/main today) 17.1.0 RED — identical trace and tally

--frozen-lockfile on both legs is load-bearing: every objectui manifest declares ^17.0.0, so a non-frozen install re-resolves to 17.1.0 and would have turned the green leg red for the wrong reason.

The lockfile diff of that one commit introduces the package rather than bumping it — there is no removal counterpart anywhere in the diff:

-  '@objectstack/spec@17.0.0(ai@7.0.65(zod@4.4.3))':
+  '@objectstack/spec@17.1.0(ai@7.0.65(zod@4.4.3))':
     dependencies:
+      pg-connection-string: 2.14.0
       zod: 4.4.3

And byte-level, from the two installed trees side by side:

@objectstack+spec@17.0.0.../dist/index.mjs   pg-connection-string in first 3 lines: 0
@objectstack+spec@17.1.0.../dist/index.mjs   pg-connection-string in first 3 lines: 1

Exactly one objectui commit in the whole breakage window touches pnpm-lock.yaml or any package.json; the other five are source-only.

The mechanism, measured from the published 17.1.0 tarball

1. The dependency is hard, not optional. package.json: "dependencies": { "pg-connection-string": "^2.14.0", "zod": "^4.4.3" }.

2. The exports map declares no browser condition. Counted on the published package and again on this repo's origin/main (3e26359, packages/spec/package.json still version: 17.1.0):

browser conditions in exports: 0
browser field:                 undefined

Every entry is { import: { types, default }, require: { types, default } }. There is no condition a browser bundler could select, so a browser consumer and a Node consumer resolve the same file by construction.

3. It is line 2 of the main entry. dist/index.mjs:

import { z } from 'zod';
import { parse } from 'pg-connection-string';

Top-level, static, unconditional — not a lazy import(), not behind a runtime guard.

4. Six of the sixteen entry points carry it. Classified by whether the top of each entry bundle imports pg-connection-string:

POISONED  .          clean  ./ai          clean  ./integration
POISONED  ./cloud    clean  ./api         clean  ./qa
POISONED  ./data     clean  ./automation  clean  ./security
POISONED  ./kernel   clean  ./contracts   clean  ./studio
POISONED  ./shared   clean  ./identity    clean  ./ui
POISONED  ./system

./shared, ./data and ./kernel being on that list is what makes this not routable around: they are the entries a browser consumer of field, filter and expression schemas actually needs.

5. There is exactly one call site. In dist/index.mjs the only use of the import is parse(value) inside pgParseableUrl(), a Zod superRefine that refuses a postgres datasource config.url that pg could not open. Source: packages/spec/src/data/driver/postgres.zod.ts:16 (import { parse as parsePostgresUrl } from 'pg-connection-string';), reachable from ./data via packages/spec/src/data/index.ts:176 (export * from './driver/index';) and from the root barrel through the same chain.

That refinement is a publish-time, server-side authoring check. It is unreachable in a browser docs page by construction, so every byte of it in a client bundle is dead weight.

6. pg-connection-string itself declares no browser story either. 2.14.0 has no browser field and no browser export condition; its ESM entry is a four-line wrapper importing the CJS index.js, so the require('fs') on line 88 — runtime-guarded, bundler-static — is an unconditional resolution request in any client graph.

How objectui reaches it (and why a subpath swap does not help)

@objectstack/formula@17.1.0 is the bridge. Its dist/index.mjs imports the root barrel for one identity helper:

import { createEvalUser } from "@objectstack/spec";   // src/stdlib.ts:17

createEvalUser lives in src/identity/eval-user.zod.ts, and dist/identity/index.mjs is clean. So "have formula import from @objectstack/spec/identity" looks like a one-line fix — and it is not sufficient, measured: the same dist/index.mjs also imports @objectstack/spec/data (lines 1607–1608, for nextUtcCalendarDay, utcInstantMs, asciiCaseInsensitiveContains, matchesLikePattern), and ./data is one of the six poisoned entries. Any fix that only re-points the root-barrel import leaves the breakage in place.

Why objectui cannot fix this

objectui's triage put a hard prohibition on the obvious local move, and the measurement above makes it more binding, not less:

Don't paper over it with a Turbopack fs shim/alias without knowing which — a shim over a real boundary regression ships pg-connection-string to browsers as accepted cargo.

A resolveAlias: { fs: false }, a webpack resolve.fallback, or a browser override in objectui would make the build green and would work at runtime, because the require('fs') branch is only taken for ?sslcert= / ?sslkey= / ?sslrootcert= — which this same schema refuses one step earlier. That is precisely what makes the shim dangerous rather than safe: it succeeds, so nothing would ever prompt anyone to look again, and a Postgres connection-string parser becomes permanent accepted cargo in every ObjectUI browser bundle. Fixing a producer's boundary with a consumer-side tolerance is also what objectui's AGENTS.md #0.1 (contract-first) forbids by name.

The other local option, reverting objectui#5529's pin to 17.0.0, is not a lockfile-only revert and is reported on objectui#5668 rather than taken: #5529 also adapted objectui to the 17.1.0 contract (the new icontains operator wired into a table keyed by ViewFilterOperator, which fails to compile without it; five new record:* blocks pinned in the console registry parity; a re-baselined eager-closure ceiling raised under the maintainer's ruling on objectui#5531). Reverting trades a red Build Docs for a red Type Check and undoes a ruling.

Options, for the domain:spec seat to grade

Stated as options rather than a demand — the cost model here belongs to this repo.

  • A. Declare the boundary in the exports map. Add a browser condition for the affected entries pointing at a build with the driver-config schemas excluded (or with the postgres URL refinement degraded to the shape-only checks it already performs before parse). Long-term soundness: highest — declared = enforced, and a browser consumer can no longer accidentally receive server-only publish-time validators. Cost: a second tsup output and a rule about what may live behind it.
  • B. Move the DSN refusal out of the statically-reachable graph. Keep one build, but make parse reachable only through a server-only entry (for example @objectstack/spec/publish) that the data / shared / kernel / root barrels do not re-export. Long-term soundness: high, and it is the smaller diff. Risk: it moves a public export, so it is a spec surface change.
  • C. Drop the dependency and hand-roll the parse. Rejected on evidence, recorded so it is not re-proposed: packages/spec/src/data/driver/postgres.zod.ts's own docblock and the objectstack#8082 / objectstack#8337 rulings turn on using the parser pg itself uses; a hand-rolled parser would agree with pg until it did not, which is the failure mode the refusal exists to prevent.
  • D. Do nothing upstream; let each browser consumer alias fs. Rejected above. Named only so the option set is complete.

Recommendation: A, with B as the smaller interim if A cannot land quickly. Four axes:

  • Real business need — measured, not speculative: it is red on objectui main right now and holds an ACCEPTed PR. The consumer is real and named.
  • Long-term soundness for this project — a schema package is imported by browsers by design; it having no browser condition at all is the actual defect, and this dependency is only the first one to make it visible. Fixing the instance without declaring the boundary leaves the next server-only dependency free to do the same thing.
  • Making AI-written code hard to get wrong — an author or an agent adding a Node-only import to a *.zod.ts file today gets no signal at all; the cost lands in a downstream repo's bundler, hours later, attributed to whichever PR happened to touch a docs path. A declared browser condition (plus a gate over it) turns that into a refusal at the producer.
  • Startup scope discipline — argues for the smallest change that declares the boundary, not a bundling redesign. Both A and B are bounded; neither expands the spec's capability surface. It also argues against C, which is new code for a solved problem.

Reproduction, ready to run

In objectui, at origin/main:

git worktree add ../objectui-pgcs -b probe/pgcs origin/main && cd ../objectui-pgcs
pnpm install --frozen-lockfile
pnpm turbo run build --filter='@object-ui/site'   # exits 1, "Can't resolve 'fs'"

Against the installed package alone, no build needed:

SPEC=$(ls -d node_modules/.pnpm/@objectstack+spec@17.1.0*/node_modules/@objectstack/spec)
head -3 "$SPEC/dist/index.mjs"                    # line 2 is the pg-connection-string import
for f in "$SPEC"/dist/index.mjs "$SPEC"/dist/*/index.mjs; do
  head -3 "$f" | grep -q pg-connection-string && echo "POISONED $f" || echo "clean    $f"
done

Dependency edge

This card waits on nothing. objectui#5668 waits on it: a released @objectstack/spec whose browser-facing entries do not statically import pg-connection-string unblocks objectui's Build Docs on a plain lockfile refresh, with no objectui source change. Following the objectstack#10812 convention, the machine-readable Blocked-by: line lives on the waiting card.

Adjacent, not duplicate

  • objectstack#8133 — the same exports map and tsup pipeline, from the cross-entry-identity and no-shared-chunks angle. It is pm:on-hold with restart-when: any PR touches the packages/spec build/publish pipeline (tsup config, or the exports map in packages/spec/package.json) — options A and B both touch exactly that, so whoever takes this should read objectstack#8133 first and decide whether the two land together.
  • objectstack#9659@objectstack/formula among the four packages still reaching the Console bundle from objectui's lockfile. Different problem (publish ordering), same edge.
  • objectui#5668 — the consumer-side card carrying the CI evidence and the merge impact.

Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions