From 71c9737b358470df45f1d1d96e4e485f326c8486 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 16:24:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20declare=20the=20browser=20bou?= =?UTF-8?q?ndary=20=E2=80=94=20browser=20export=20condition=20excluding=20?= =?UTF-8?q?the=20pg-grammar=20arm=20(#11072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option A per the maintainer's 2026-08-22 ruling: the five entries whose graph reaches the driver-config validators (., ./data, ./system, ./kernel, ./cloud) gain a browser export condition pointing at dist/browser/ bundles in which the postgres url refinement's pg-grammar arm (pg-connection-string, whose parse statically resolves require('fs')) is swapped for a dependency-free twin. Node-side resolution and behaviour are unchanged. check:browser-reachable-entries grows a second rule: every bundle a browser bundler resolves (browser-conditioned or not) must link no Node builtin and no server-only package, with a positive control on the Node side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MsbKEG4LtERSLaDrbehM3e --- packages/spec/package.json | 50 +++ .../check-browser-reachable-entries.ts | 288 +++++++++++++++++- .../src/data/driver/pg-url-grammar.browser.ts | 35 +++ .../src/data/driver/pg-url-grammar.server.ts | 134 ++++++++ .../src/data/driver/pg-url-grammar.test.ts | 66 ++++ packages/spec/src/data/driver/postgres.zod.ts | 98 +----- packages/spec/tsup.config.ts | 78 ++++- 7 files changed, 657 insertions(+), 92 deletions(-) create mode 100644 packages/spec/src/data/driver/pg-url-grammar.browser.ts create mode 100644 packages/spec/src/data/driver/pg-url-grammar.server.ts create mode 100644 packages/spec/src/data/driver/pg-url-grammar.test.ts diff --git a/packages/spec/package.json b/packages/spec/package.json index 46d9cd00f1..186c612535 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -8,6 +8,16 @@ "sideEffects": false, "exports": { ".": { + "browser": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/browser/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/browser/index.js" + } + }, "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" @@ -18,6 +28,16 @@ } }, "./data": { + "browser": { + "import": { + "types": "./dist/data/index.d.mts", + "default": "./dist/browser/data/index.mjs" + }, + "require": { + "types": "./dist/data/index.d.ts", + "default": "./dist/browser/data/index.js" + } + }, "import": { "types": "./dist/data/index.d.mts", "default": "./dist/data/index.mjs" @@ -28,6 +48,16 @@ } }, "./system": { + "browser": { + "import": { + "types": "./dist/system/index.d.mts", + "default": "./dist/browser/system/index.mjs" + }, + "require": { + "types": "./dist/system/index.d.ts", + "default": "./dist/browser/system/index.js" + } + }, "import": { "types": "./dist/system/index.d.mts", "default": "./dist/system/index.mjs" @@ -38,6 +68,16 @@ } }, "./kernel": { + "browser": { + "import": { + "types": "./dist/kernel/index.d.mts", + "default": "./dist/browser/kernel/index.mjs" + }, + "require": { + "types": "./dist/kernel/index.d.ts", + "default": "./dist/browser/kernel/index.js" + } + }, "import": { "types": "./dist/kernel/index.d.mts", "default": "./dist/kernel/index.mjs" @@ -128,6 +168,16 @@ } }, "./cloud": { + "browser": { + "import": { + "types": "./dist/cloud/index.d.mts", + "default": "./dist/browser/cloud/index.mjs" + }, + "require": { + "types": "./dist/cloud/index.d.ts", + "default": "./dist/browser/cloud/index.js" + } + }, "import": { "types": "./dist/cloud/index.d.mts", "default": "./dist/cloud/index.mjs" diff --git a/packages/spec/scripts/check-browser-reachable-entries.ts b/packages/spec/scripts/check-browser-reachable-entries.ts index f04cf86047..b7cd33c2f1 100644 --- a/packages/spec/scripts/check-browser-reachable-entries.ts +++ b/packages/spec/scripts/check-browser-reachable-entries.ts @@ -92,6 +92,37 @@ * not adopted. If a future entry ever ships as something a static scan cannot * follow — a wildcard subpath, a conditional export tree — that is the point to * revisit it, and the reconciliation above is what will force the conversation. + * (#11072 added the first conditional tree — the `browser` condition — and the + * scan follows it: `browserTargetsOf` reads that condition's targets and they + * are walked like any other bundle, so nothing here went blind.) + * + * ## SECOND RULE (#11072): the browser-resolvable surface links nothing Node-only + * + * The first rule above is about zod (bundle WEIGHT, judged only for entries + * declared browser-reachable). This rule is about bundle FEASIBILITY, and it is + * judged for EVERY module entry, because a browser bundler resolves every one + * of them: `@objectstack/spec@17.1.0` shipped `pg-connection-string` — whose + * `parse` statically resolves `require('fs')` — at the top of six entry + * bundles, and objectui's docs site went red on `Can't resolve 'fs'` (#11072). + * The maintainer's 2026-08-22 ruling (Option A) declares the boundary in the + * exports map: the affected entries carry a `browser` condition pointing at a + * build with the pg-grammar arm swapped out (`tsup.config.ts`, + * `swapServerOnlyGrammarArm`). What this rule holds, per subpath: + * + * - a subpath WITH a `browser` condition: the browser-conditioned bundles + * must link no Node builtin and no declared server-only package + * (`SERVER_ONLY_EXTERNALS`) — the swap really happened, in both format + * halves; + * - a subpath WITHOUT one: its ordinary bundles are exactly what a browser + * bundler loads, so THEY must link no Node builtin and no server-only + * package. This is the half that refuses the NEXT server-only dependency + * at the producer — an author adding a Node-only import to a `*.zod.ts` + * today reds this gate instead of a downstream repo's bundler hours later; + * - positive control: across the NODE-side bundles of the browser-conditioned + * subpaths the scan must still find at least one server-only link. Zero + * means either the instrument went blind or the dependency left the Node + * graph — in which case the `browser` conditions are moot and should be + * withdrawn, a decision, not a silent green. * * ## It reads BUILT output, so an unbuilt tree is NOT MEASURED * @@ -116,6 +147,7 @@ import { utimesSync, writeFileSync, } from 'node:fs'; +import { builtinModules } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -133,6 +165,38 @@ function isZodSpecifier(spec: string): boolean { return spec === 'zod' || spec.startsWith('zod/'); } +/** + * npm dependencies of this package that are Node-only by construction — + * linking one from any browser-resolvable bundle is the #11072 defect. + * `pg-connection-string`'s `parse` reaches `require('fs')` (runtime-guarded, + * bundler-static), it has no `browser` field and no `browser` condition, so a + * browser bundler that reaches it must resolve `fs` and fails. Add a package + * here the day it enters `dependencies` with the same shape. + */ +const SERVER_ONLY_EXTERNALS = new Set(['pg-connection-string']); + +const NODE_BUILTINS = new Set(builtinModules); + +/** The package half of a bare specifier: `fs/promises` → `fs`, `@s/p/x` → `@s/p`. */ +function packageOf(spec: string): string { + return spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]!; +} + +/** A specifier naming a declared server-only dependency, subpaths included. */ +export function isServerOnlyExternal(spec: string): boolean { + return SERVER_ONLY_EXTERNALS.has(packageOf(spec)); +} + +/** + * A specifier no browser bundler can resolve without a shim: a Node builtin + * (bare or `node:`-prefixed, subpaths included — `fs/promises` is `fs`) or a + * declared server-only dependency (subpaths included, for the same reason). + */ +export function isNodeOnlySpecifier(spec: string): boolean { + if (spec.startsWith('node:')) return true; + return NODE_BUILTINS.has(packageOf(spec)) || isServerOnlyExternal(spec); +} + function isRelative(spec: string): boolean { return spec.startsWith('./') || spec.startsWith('../'); } @@ -360,6 +424,52 @@ function targetsOf(entry: unknown): string[] { return out; } +/** + * The files a BROWSER bundler loads for one subpath, when the subpath declares + * a `browser` condition (#11072); `[]` when it declares none. + * + * Both format halves are read, exactly as `targetsOf` reads them and for the + * same reason: the browser condition's `import` and `require` branches are + * separate bundles from separate tsup passes, and a swap that held for the ESM + * half while the CJS half still linked the parser is not a boundary. A flat + * string / `default` browser condition is accepted too, so a future + * simplification of the map degrades into a SCANNED shape rather than out of + * coverage. + */ +export function browserTargetsOf(entry: unknown): string[] { + if (!entry || typeof entry !== 'object') return []; + const browser = (entry as Record).browser; + if (typeof browser === 'string') return [browser]; + if (!browser || typeof browser !== 'object') return []; + const record = browser as Record; + const out: string[] = []; + for (const key of ['import', 'require']) { + const branch = record[key]; + if (!branch) continue; + if (typeof branch === 'string') { + out.push(branch); + continue; + } + if (typeof branch !== 'object') continue; + const target = (branch as Record).default; + if (typeof target === 'string') out.push(target); + } + const fallback = record.default; + if (typeof fallback === 'string') out.push(fallback); + return out; +} + +/** + * The #11072 verdict for one walked bundle that a browser bundler would load: + * every linked Node builtin / server-only external, with its linkers. Exported + * so the self-test can drive both verdicts over fixtures — this tree's own + * bundles are clean, and a rule only ever observed green is indistinguishable + * from one that matches nothing (#4690). + */ +export function nodeOnlyLinksOf(graph: BundleGraph): [string, string[]][] { + return [...graph.externals.entries()].filter(([spec]) => isNodeOnlySpecifier(spec)); +} + function reconcile(exportsMap: ExportsMap, ledger: Ledger, problems: string[]): void { const declared = new Map(); const claim = (subpath: string, section: string): void => { @@ -425,20 +535,30 @@ function audit(): never { const notAModule = new Set(ledger.notAModule); let zodLinksFound = 0; let bundlesScanned = 0; + // #11072 positive control: server-only links seen on the NODE side of the + // subpaths that declare a `browser` condition (see the header's second rule). + let browserConditionedSubpaths = 0; + let serverOnlyLinksOnNodeSide = 0; const judged = ledger.browserReachable; for (const subpath of Object.keys(exportsMap)) { if (notAModule.has(subpath)) continue; const contract = judged[subpath]; - - for (const target of targetsOf(exportsMap[subpath])) { + const entryValue = exportsMap[subpath]; + const nodeTargets = targetsOf(entryValue); + const browserTargets = browserTargetsOf(entryValue); + const hasBrowserCondition = browserTargets.length > 0; + if (hasBrowserCondition) browserConditionedSubpaths++; + + /** Resolve, walk and instrument one target; null when there is no graph to judge. */ + const walkTarget = (target: string): BundleGraph | null => { const file = join(PKG_DIR, target); if (!existsSync(file)) { problems.push( `'${subpath}' resolves to ${target}, which does not exist. The exports map points at a\n` + ` file this build did not emit.`, ); - continue; + return null; } const graph = walkBundle(PKG_DIR, file); @@ -451,10 +571,47 @@ function audit(): never { ); } - const zodLinkers = [...graph.externals.entries()].filter(([spec]) => isZodSpecifier(spec)); - zodLinksFound += zodLinkers.length; + zodLinksFound += [...graph.externals.keys()].filter(isZodSpecifier).length; + return graph; + }; + + /** The #11072 refusal for one bundle a browser bundler would load. */ + const refuseNodeOnlyLinks = (target: string, graph: BundleGraph): void => { + for (const [spec, linkers] of nodeOnlyLinksOf(graph)) { + const why = hasBrowserCondition + ? ` This is a browser-conditioned bundle, so the grammar-arm swap did not cover this\n` + + ` link (tsup.config.ts, swapServerOnlyGrammarArm — keyed to the one audited seam on\n` + + ` purpose). Route the Node-only work through a module the browser pass swaps out, or\n` + + ` drop the import.` + : ` This subpath declares NO browser condition, so ${target} is exactly the file a\n` + + ` browser bundler loads — this link is #11072's defect: the consumer's build fails\n` + + ` on \`Can't resolve 'fs'\` hours later, attributed to nothing. Either keep the\n` + + ` entry's graph free of Node-only imports, or give the entry a browser-conditioned\n` + + ` build that excludes them (the #11072 pattern: seam module + tsup swap + a\n` + + ` \`browser\` condition in the exports map).`; + problems.push( + `'${subpath}': ${target} links the Node-only specifier '${spec}'\n` + + ` (via ${linkers.join(', ')}).\n${why}`, + ); + } + }; + + for (const target of nodeTargets) { + const graph = walkTarget(target); + if (!graph) continue; + + if (hasBrowserCondition) { + // The node side of a browser-conditioned subpath MAY link server-only + // packages — that is the point of the condition. Count the links as + // the positive control instead of judging them. + serverOnlyLinksOnNodeSide += [...graph.externals.keys()].filter(isServerOnlyExternal).length; + } else { + refuseNodeOnlyLinks(target, graph); + } + if (!contract) continue; + const zodLinkers = [...graph.externals.entries()].filter(([spec]) => isZodSpecifier(spec)); for (const [spec, linkers] of zodLinkers) { problems.push( `'${subpath}' is DECLARED browser-reachable but its built graph links '${spec}'\n` + @@ -479,6 +636,34 @@ function audit(): never { ); } } + + for (const target of browserTargets) { + const graph = walkTarget(target); + if (!graph) continue; + refuseNodeOnlyLinks(target, graph); + } + } + + // #11072's positive control. The node-only refusals above are green when the + // scan finds nothing — which is also what a blind scan reports, and what a + // tree whose Node graph dropped the dependency reports. Neither may pass as + // a silent green: the first is an instrument failure, the second means the + // `browser` conditions no longer buy anything and should be withdrawn — a + // maintainer decision, like every other exports-map move. + if (browserConditionedSubpaths > 0 && serverOnlyLinksOnNodeSide === 0) { + console.error( + `\n❌ check:browser-reachable-entries — POSITIVE CONTROL FAILED (#11072).\n\n` + + ` ${browserConditionedSubpaths} subpath(s) declare a \`browser\` condition, but the scan found ZERO\n` + + ` server-only links (${[...SERVER_ONLY_EXTERNALS].join(', ')}) in their NODE-side bundles. So this run\n` + + ` cannot tell "the browser bundles are clean" apart from "this scan can no longer see a\n` + + ` server-only link at all".\n\n` + + ` The two causes, both real:\n` + + ` • the Node graph no longer links the server-only package — then the browser conditions\n` + + ` are moot: withdraw them (exports map + tsup.config.ts + this gate's list) instead of\n` + + ` keeping a boundary that guards nothing;\n` + + ` • the emitted import shape moved out from under the scanner (see SPECIFIER_KEYWORDS).\n`, + ); + process.exit(1); } // The calibration. A specifier scan cannot see a zod that got INLINED into the @@ -517,7 +702,10 @@ function audit(): never { `✅ check:browser-reachable-entries — ${declaredCount} declared browser-reachable ` + `entr${declaredCount === 1 ? 'y links' : 'ies link'} no zod; ` + `${bundlesScanned} bundle(s) scanned, ${zodLinksFound} zod link(s) seen elsewhere ` + - `(instrument calibrated); exports map fully classified.`, + `(instrument calibrated); the browser-resolvable surface links no Node builtin and no ` + + `server-only package (#11072: ${browserConditionedSubpaths} browser-conditioned subpath(s), ` + + `${serverOnlyLinksOnNodeSide} server-only link(s) on their Node side — positive control ` + + `held); exports map fully classified.`, ); process.exit(0); } @@ -789,6 +977,94 @@ function selfTest(): never { JSON.stringify(targets), ); + // ── #11072: the browser condition's targets, and only when declared ────── + const conditioned = { + browser: { + import: { types: './dist/x/index.d.mts', default: './dist/browser/x/index.mjs' }, + require: { types: './dist/x/index.d.ts', default: './dist/browser/x/index.js' }, + }, + import: { types: './dist/x/index.d.mts', default: './dist/x/index.mjs' }, + require: { types: './dist/x/index.d.ts', default: './dist/x/index.js' }, + }; + const browserTargets = browserTargetsOf(conditioned); + check( + 'reads the browser condition — BOTH format halves', + JSON.stringify(browserTargets) === + JSON.stringify(['./dist/browser/x/index.mjs', './dist/browser/x/index.js']), + JSON.stringify(browserTargets), + ); + check( + 'the browser condition does not leak into the node-side targets', + JSON.stringify(targetsOf(conditioned)) === + JSON.stringify(['./dist/x/index.mjs', './dist/x/index.js']), + JSON.stringify(targetsOf(conditioned)), + ); + const flatBrowser = browserTargetsOf({ browser: './b.mjs', import: { default: './x.mjs' } }); + check( + 'a flat string browser condition degrades into a SCANNED shape', + JSON.stringify(flatBrowser) === JSON.stringify(['./b.mjs']), + JSON.stringify(flatBrowser), + ); + check( + 'no browser condition → no browser targets', + browserTargetsOf({ import: { default: './x.mjs' } }).length === 0, + ); + + // ── #11072: what counts as Node-only ───────────────────────────────────── + const nodeOnly = ['fs', 'node:fs', 'fs/promises', 'node:path', 'pg-connection-string', + 'pg-connection-string/index.js']; + const browserSafe = ['zod', 'zod/v4', './chunk.mjs', 'ai']; + check( + 'recognises builtins (bare, node:-prefixed, subpaths) and server-only packages', + nodeOnly.every(isNodeOnlySpecifier), + JSON.stringify(nodeOnly.filter((s) => !isNodeOnlySpecifier(s))), + ); + check( + 'does not flag zod, relative hops or the optional ai peer', + browserSafe.every((s) => !isNodeOnlySpecifier(s)), + JSON.stringify(browserSafe.filter(isNodeOnlySpecifier)), + ); + + // ── #11072: the node-only verdict over a walked graph, BOTH directions ─── + const nodeOnlyTmp = mkdtempSync(join(tmpdir(), 'os-browser-reachable-nodeonly-')); + try { + const dist = join(nodeOnlyTmp, 'dist'); + mkdirSync(join(dist, 'poisoned'), { recursive: true }); + mkdirSync(join(dist, 'swapped'), { recursive: true }); + + // The #11072 defect two relative hops down: invisible to a one-file scan, + // and spelled through BOTH statement forms the scanner knows. + writeFileSync( + join(dist, 'poisoned', 'index.mjs'), + `export { p } from '../chunk-parser.mjs';\n`, + ); + writeFileSync( + join(dist, 'chunk-parser.mjs'), + `import { parse } from 'pg-connection-string';\nconst fs = require('node:fs');\nexport const p = [parse, fs];\n`, + ); + const poisoned = walkBundle(nodeOnlyTmp, join(dist, 'poisoned', 'index.mjs')); + const poisonedLinks = nodeOnlyLinksOf(poisoned).map(([spec]) => spec).sort(); + check( + 'finds a server-only package AND a node: builtin reached through a relative hop', + JSON.stringify(poisonedLinks) === JSON.stringify(['node:fs', 'pg-connection-string']), + JSON.stringify(poisonedLinks), + ); + + // A correctly swapped browser bundle: zod stays, nothing Node-only. + writeFileSync( + join(dist, 'swapped', 'index.mjs'), + `import { z } from 'zod';\nexport const s = z;\n`, + ); + const swapped = walkBundle(nodeOnlyTmp, join(dist, 'swapped', 'index.mjs')); + check( + 'reports a swapped (zod-only) browser bundle clean on the node-only axis', + nodeOnlyLinksOf(swapped).length === 0, + JSON.stringify(nodeOnlyLinksOf(swapped).map(([spec]) => spec)), + ); + } finally { + rmSync(nodeOnlyTmp, { recursive: true, force: true }); + } + if (failures.length) { console.error(`\n✗ self-test: ${failures.length} case(s) failed.`); process.exit(1); diff --git a/packages/spec/src/data/driver/pg-url-grammar.browser.ts b/packages/spec/src/data/driver/pg-url-grammar.browser.ts new file mode 100644 index 0000000000..1266f1e573 --- /dev/null +++ b/packages/spec/src/data/driver/pg-url-grammar.browser.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The browser twin of `pg-url-grammar.server.ts` (#11072). + * + * The browser tsup pass (`tsup.config.ts`, `swapServerOnlyGrammarArm`) + * resolves every import of `./pg-url-grammar.server` to this module, so the + * `browser`-conditioned bundles carry no `pg-connection-string` — the + * dependency whose `parse` statically resolves `require('fs')` and breaks + * every browser bundler that reaches it (measured on objectui's docs site). + * + * Per the maintainer's 2026-08-22 ruling on #11072 (Option A), the postgres + * `url` refinement DEGRADES here to the shape-only checks it already + * performs before `parse` — the unix-socket short-circuit and the fs-reading + * query-parameter refusal, both of which live in `postgres.zod.ts` and run + * in every build. The pg-grammar arm answers "no findings" because + * publish-time validation never legitimately runs in a browser: a datasource + * publish is a server-side act, and every byte of the grammar check in a + * client bundle is dead weight by construction. Node consumers keep the full + * DSN refusal — Option B (moving the refinement to a server-only entry) was + * REJECTED because it would widen `./data`'s accept set for Node too. + * + * Keep this module dependency-free. `check:browser-reachable-entries` scans + * the emitted browser bundles and refuses `pg-connection-string` and every + * Node builtin, with a positive control on the Node-side bundles. + */ + +/** + * Browser build: no pg-grammar findings, for every value — see the module + * doc. The signature is the contract shared with the node twin; the + * parameters are deliberately unread. + */ +export function pgUrlGrammarFindings(_value: string, _key: string): string[] { + return []; +} diff --git a/packages/spec/src/data/driver/pg-url-grammar.server.ts b/packages/spec/src/data/driver/pg-url-grammar.server.ts new file mode 100644 index 0000000000..bb495f34c7 --- /dev/null +++ b/packages/spec/src/data/driver/pg-url-grammar.server.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The pg-grammar arm of the postgres `url` refinement — the ONLY module in + * this package that may import `pg-connection-string` (#11072). + * + * ## Why this lives in its own module + * + * `pg-connection-string@2.14.0` is Node-only by construction: its `parse` + * reaches `require('fs')` (runtime-guarded, bundler-static), it declares no + * `browser` field and no `browser` export condition, so ANY browser bundler + * that reaches it must resolve `fs` and fails (measured on objectui's docs + * site — Next.js/Turbopack, `Module not found: Can't resolve 'fs'`). + * + * The maintainer's 2026-08-22 ruling on #11072 (Option A) declares the + * boundary at this producer: the affected entries gain a `browser` export + * condition pointing at a build with the driver-config validators excluded — + * the postgres URL refinement degrades to the shape-only checks it already + * performs before `parse`. This module is the seam that makes the exclusion + * buildable: the browser tsup pass (`tsup.config.ts`, + * `swapServerOnlyGrammarArm`) resolves every import of `./pg-url-grammar.server` + * to `./pg-url-grammar.browser` instead, and + * `check:browser-reachable-entries` proves the swap happened by scanning the + * emitted browser bundles for `pg-connection-string` and Node builtins. + * + * Node-side behaviour is unchanged: the checks below are the exact #9091 + * checks `postgres.zod.ts` ran inline before #11072, in the same order, with + * the same messages (the existing pins in `postgres.test.ts` hold them). + * + * ⛔ Do NOT import `pg-connection-string` anywhere else in `src/` — a second + * import site lands in the browser bundles (the swap is keyed to THIS module + * specifier, on purpose: a silent blanket alias would degrade future call + * sites nobody audited) and `check:browser-reachable-entries` goes red. + */ + +import { parse as parsePostgresUrl } from 'pg-connection-string'; + +/** + * Refusal prescription for a `url` that `pg` itself cannot parse (#9091). + * + * The gap this closes: `postgres.zod.ts`'s describe text documents a grammar + * (`postgresql://[user@][host][:port][/dbname][?params]`) that nothing + * enforced. The shared `credentialFreeUrl` / `placeholderFree` checks are + * string-boundary scans by design — their refusal to parse is load-bearing + * for mongo's multi-host and `+srv` forms (#8696), so the parse question is + * asked HERE, per-driver, of the postgres client's own grammar: `parse` from + * `pg-connection-string@2.14.0`, the parser `pg@8.22.0` itself runs a + * connection string through (`ConnectionParameters`). What that parser + * throws on (measured: libpq's multi-host `h1:5432,h2:5433` form — + * `ERR_INVALID_URL`; a non-numeric port; a malformed percent-escape) used to + * parse green at publish and then fail at connect with a bare `Invalid URL` + * whose own `input` field `pg` redacts — an error naming neither the value + * nor the datasource. Same posture as #8873's runtime arm: ask `pg`'s + * grammar, never re-model it. + */ +const PG_UNPARSEABLE_URL_REFUSED = (key: string, detail: string): string => + `this \`${key}\` is not a connection URL \`pg\` can open — \`pg-connection-string\` (the ` + + `parser \`pg\` itself uses) refuses it: ${detail}. The datasource would publish green and ` + + 'then fail at connect time with an error that names neither the value nor the datasource. ' + + 'Expected format: `postgresql://[user@][host][:port][/dbname][?params]`. Note that `pg` ' + + "does not implement libpq's multi-host form (`host1:port1,host2:port2`) — a multi-host " + + 'DSN fails exactly this way; point the URL at a single host (or a proxy in front of the ' + + 'cluster) instead. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do not pass ' + + 'through this publish door and are unaffected.'; + +/** + * Refusal for a value `pg` "parses" only by resolving it against its + * placeholder base URL (#9091 — the structurally-unusable half). + * + * `pg-connection-string` parses via `new URL(str, 'postgres://base')`, so a + * value that is not an absolute URL at all (`not a url`, a libpq + * keyword/value string like `host=x dbname=y`) does not throw — it resolves + * RELATIVE to the base, and the client then connects to the literal host + * `base` with the whole authored value as the database name. That is a + * "successful" parse of a configuration the author never wrote, so it is + * refused as unusable rather than accepted as what `pg` happens to do. + */ +const PG_RELATIVE_URL_REFUSED = (key: string): string => + `this \`${key}\` is not a URL: it has no scheme, so \`pg\` would parse it only by resolving ` + + 'it against an internal placeholder base and then connect to the literal host `base` — a ' + + 'host that was never named — with the authored text as the database name. Write a real ' + + 'connection URL: `postgresql://[user@][host][:port][/dbname][?params]` (a unix-socket ' + + 'path starting with `/` is also accepted). Runtime-environment DSNs (`OS_DATABASE_URL` ' + + 'and friends) do not pass through this publish door and are unaffected.'; + +/** Can WHATWG `URL` parse this string as an ABSOLUTE URL (no base)? */ +function isAbsoluteUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * Did `parse` succeed only by resolving the value against its placeholder + * base? Mirrors the parser's own preprocessing (space/percent re-encoding, + * then the `@/` → `@___DUMMY___/` empty-host retry — the retry form, + * `postgresql://user@/db`, is libpq's real empty-host-with-userinfo spelling + * and stays accepted) so the two cannot disagree about which branch ran. + */ +function pgParsedRelativeToBase(value: string): boolean { + const str = / |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(value) + ? encodeURI(value).replace(/%25(\d\d)/g, '%$1') + : value; + return !isAbsoluteUrl(str) && !isAbsoluteUrl(str.replace('@/', '@___DUMMY___/')); +} + +/** + * The #9091 pg-grammar findings for one authored `url` value: at most one + * refusal message, `[]` when `pg`'s own parser genuinely opens the value. + * + * Called by `postgres.zod.ts` AFTER its shape-only pre-parse checks (the + * unix-socket short-circuit and the fs-reading `?sslcert=`/`?sslkey=`/ + * `?sslrootcert=` refusal), so `parse` is never handed a value that would + * make it read the validating machine's filesystem. + * + * The browser twin (`pg-url-grammar.browser.ts`) answers `[]` for every + * value: publish-time validation never legitimately runs in a browser, so + * the grammar arm is dead weight there by construction (#11072). + */ +export function pgUrlGrammarFindings(value: string, key: string): string[] { + try { + parsePostgresUrl(value); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return [PG_UNPARSEABLE_URL_REFUSED(key, detail)]; + } + if (pgParsedRelativeToBase(value)) { + return [PG_RELATIVE_URL_REFUSED(key)]; + } + return []; +} diff --git a/packages/spec/src/data/driver/pg-url-grammar.test.ts b/packages/spec/src/data/driver/pg-url-grammar.test.ts new file mode 100644 index 0000000000..46c78aa029 --- /dev/null +++ b/packages/spec/src/data/driver/pg-url-grammar.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the #11072 grammar-arm seam — the module pair the browser tsup + * pass swaps (`pg-url-grammar.server` → `pg-url-grammar.browser`). + * + * What each half holds: + * - the SERVER twin still asks `pg`'s own parser, so the #9091 refusals + * `postgres.test.ts` pins keep flowing from the schema (Node behaviour + * unchanged is the ruling's pinned requirement); + * - the BROWSER twin answers "no findings" for exactly the values the + * server twin refuses — the degradation the maintainer's 2026-08-22 + * ruling (Option A) prescribes. If someone "fixes" the browser twin by + * re-adding a parse, the bundle gate refuses the dependency; if someone + * makes it throw or refuse instead of degrade, THIS file refuses. + */ + +import { describe, expect, it } from 'vitest'; + +import { pgUrlGrammarFindings as browserFindings } from './pg-url-grammar.browser'; +import { pgUrlGrammarFindings as serverFindings } from './pg-url-grammar.server'; + +/** Values the server twin measurably refuses (#9091's own fixtures). */ +const REFUSED_ON_SERVER = [ + // libpq's multi-host form — `pg` throws ERR_INVALID_URL on it. + 'postgresql://h1:5432,h2:5433/mydb', + // A non-numeric port. + 'postgresql://host:notaport/mydb', + // A scheme-less non-URL — "parses" only against the placeholder base. + 'host=localhost dbname=mydb', +]; + +/** Values both twins accept (shapes `pg` genuinely opens). */ +const ACCEPTED_EVERYWHERE = [ + 'postgresql://localhost:5432/mydb', + 'postgres://user@db.example.com/app?sslmode=require', +]; + +describe('pg-url-grammar server twin (#9091 arm)', () => { + it('refuses each measured-bad DSN with the pg-grammar prescription', () => { + for (const value of REFUSED_ON_SERVER) { + const findings = serverFindings(value, 'url'); + expect(findings, value).toHaveLength(1); + expect(findings[0], value).toMatch(/not a (connection )?URL/); + } + }); + + it('accepts every shape `pg` genuinely opens', () => { + for (const value of ACCEPTED_EVERYWHERE) { + expect(serverFindings(value, 'url'), value).toEqual([]); + } + }); +}); + +describe('pg-url-grammar browser twin (#11072 degradation)', () => { + it('answers no findings for the very values the server twin refuses', () => { + for (const value of [...REFUSED_ON_SERVER, ...ACCEPTED_EVERYWHERE]) { + expect(browserFindings(value, 'url'), value).toEqual([]); + } + }); + + it('keeps the shared contract shape: string[] in, never a throw', () => { + expect(() => browserFindings('', 'url')).not.toThrow(); + expect(browserFindings('', 'url')).toEqual([]); + }); +}); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index fe283e6a9e..0d5f2e5638 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -13,7 +13,6 @@ * block, which the factory now honours for every SQL driver. */ -import { parse as parsePostgresUrl } from 'pg-connection-string'; import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; @@ -31,54 +30,14 @@ import { SqlAutoMigrateSchema, SSL_DETAIL_BELONGS_ON_DATASOURCE, } from './common.zod'; - -/** - * Refusal prescription for a `url` that `pg` itself cannot parse (#9091). - * - * The gap this closes: the describe text below documents a grammar - * (`postgresql://[user@][host][:port][/dbname][?params]`) that nothing - * enforced. The shared `credentialFreeUrl` / `placeholderFree` checks are - * string-boundary scans by design — their refusal to parse is load-bearing - * for mongo's multi-host and `+srv` forms (#8696), so the parse question is - * asked HERE, per-driver, of the postgres client's own grammar: `parse` from - * `pg-connection-string@2.14.0`, the parser `pg@8.22.0` itself runs a - * connection string through (`ConnectionParameters`). What that parser - * throws on (measured: libpq's multi-host `h1:5432,h2:5433` form — - * `ERR_INVALID_URL`; a non-numeric port; a malformed percent-escape) used to - * parse green at publish and then fail at connect with a bare `Invalid URL` - * whose own `input` field `pg` redacts — an error naming neither the value - * nor the datasource. Same posture as #8873's runtime arm: ask `pg`'s - * grammar, never re-model it. - */ -const PG_UNPARSEABLE_URL_REFUSED = (key: string, detail: string): string => - `this \`${key}\` is not a connection URL \`pg\` can open — \`pg-connection-string\` (the ` - + `parser \`pg\` itself uses) refuses it: ${detail}. The datasource would publish green and ` - + 'then fail at connect time with an error that names neither the value nor the datasource. ' - + 'Expected format: `postgresql://[user@][host][:port][/dbname][?params]`. Note that `pg` ' - + "does not implement libpq's multi-host form (`host1:port1,host2:port2`) — a multi-host " - + 'DSN fails exactly this way; point the URL at a single host (or a proxy in front of the ' - + 'cluster) instead. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do not pass ' - + 'through this publish door and are unaffected.'; - -/** - * Refusal for a value `pg` "parses" only by resolving it against its - * placeholder base URL (#9091 — the structurally-unusable half). - * - * `pg-connection-string` parses via `new URL(str, 'postgres://base')`, so a - * value that is not an absolute URL at all (`not a url`, a libpq - * keyword/value string like `host=x dbname=y`) does not throw — it resolves - * RELATIVE to the base, and the client then connects to the literal host - * `base` with the whole authored value as the database name. That is a - * "successful" parse of a configuration the author never wrote, so it is - * refused as unusable rather than accepted as what `pg` happens to do. - */ -const PG_RELATIVE_URL_REFUSED = (key: string): string => - `this \`${key}\` is not a URL: it has no scheme, so \`pg\` would parse it only by resolving ` - + 'it against an internal placeholder base and then connect to the literal host `base` — a ' - + 'host that was never named — with the authored text as the database name. Write a real ' - + 'connection URL: `postgresql://[user@][host][:port][/dbname][?params]` (a unix-socket ' - + 'path starting with `/` is also accepted). Runtime-environment DSNs (`OS_DATABASE_URL` ' - + 'and friends) do not pass through this publish door and are unaffected.'; +// The #9091 pg-grammar arm — the one import site of `pg-connection-string` +// (via the `.server` module; ⛔ never import that package anywhere else in +// `src/`). The browser tsup pass swaps this specifier for +// `./pg-url-grammar.browser`, whose findings are always empty, so the +// `browser`-conditioned bundles carry neither the parser nor its static +// `require('fs')` (#11072 — the module docs carry the ruling and the +// boundary; `check:browser-reachable-entries` enforces it on the bundles). +import { pgUrlGrammarFindings } from './pg-url-grammar.server'; /** * Refusal for the query parameters that make `pg`'s parser READ THE LOCAL @@ -131,37 +90,17 @@ function pgFileReadingQueryParams(value: string): string[] { return found; } -/** Can WHATWG `URL` parse this string as an ABSOLUTE URL (no base)? */ -function isAbsoluteUrl(value: string): boolean { - try { - new URL(value); - return true; - } catch { - return false; - } -} - -/** - * Did `parse` succeed only by resolving the value against its placeholder - * base? Mirrors the parser's own preprocessing (space/percent re-encoding, - * then the `@/` → `@___DUMMY___/` empty-host retry — the retry form, - * `postgresql://user@/db`, is libpq's real empty-host-with-userinfo spelling - * and stays accepted) so the two cannot disagree about which branch ran. - */ -function pgParsedRelativeToBase(value: string): boolean { - const str = / |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(value) - ? encodeURI(value).replace(/%25(\d\d)/g, '%$1') - : value; - return !isAbsoluteUrl(str) && !isAbsoluteUrl(str.replace('@/', '@___DUMMY___/')); -} - /** * Attach the #9091 pg-grammar refusal to the postgres `url` key — per-driver - * by design (see {@link PG_UNPARSEABLE_URL_REFUSED}; the shared helpers must + * by design (see `pg-url-grammar.server.ts`; the shared helpers must * keep refusing to parse for mongo's sake, #8696). Composes with * `credentialFreeUrl` (#8082/#8337) and `placeholderFree` (#8336) the same * way those compose with each other: independent `superRefine`s judging one * value, each reporting its own finding. + * + * The checks BEFORE the grammar arm are shape-only and run in every build; + * the grammar arm itself (`pgUrlGrammarFindings`) is swapped out of the + * `browser`-conditioned bundles (#11072 — see the import above). */ function pgParseableUrl(schema: S, key: string) { return schema.superRefine((value, ctx) => { @@ -177,15 +116,8 @@ function pgParseableUrl(schema: S, key: string) { } return; } - try { - parsePostgresUrl(value); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - ctx.addIssue({ code: 'custom', message: PG_UNPARSEABLE_URL_REFUSED(key, detail) }); - return; - } - if (pgParsedRelativeToBase(value)) { - ctx.addIssue({ code: 'custom', message: PG_RELATIVE_URL_REFUSED(key) }); + for (const message of pgUrlGrammarFindings(value, key)) { + ctx.addIssue({ code: 'custom', message }); } }); } diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 39e7218e8e..cccb2293c2 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { readFile } from 'node:fs/promises'; -import { defineConfig } from 'tsup'; +import { dirname, join } from 'node:path'; +import { defineConfig, type Options } from 'tsup'; import type { Plugin } from 'esbuild'; /** @@ -91,10 +92,56 @@ const entries = [ 'src/meta-spelling/index.ts' ]; +/** + * [#11072] The entries whose module graph reaches the driver-config + * validators, i.e. the entries whose Node bundles statically import + * `pg-connection-string` (measured per bundle head; `./shared` left the set + * when its graph stopped reaching the driver schemas). Each gets a SECOND, + * `browser`-conditioned output under `dist/browser/` in which the postgres + * URL refinement's pg-grammar arm is swapped for its dependency-free browser + * twin — `pg-connection-string`'s `parse` statically resolves `require('fs')` + * and breaks every browser bundler that reaches it (maintainer ruling + * 2026-08-22, Option A: declare the boundary in the exports map; Node-side + * behaviour and the Node outputs are untouched). + * + * Keep this list equal to the poisoned set: `check:browser-reachable-entries` + * refuses a `browser`-conditioned bundle that still links the parser or any + * Node builtin, refuses a NON-conditioned bundle that links either (a browser + * bundler resolves those very files), and carries a positive control on the + * Node side — so both drift directions go red at this producer. + */ +const browserConditionedEntries = [ + 'src/index.ts', + 'src/data/index.ts', + 'src/system/index.ts', + 'src/kernel/index.ts', + 'src/cloud/index.ts', +]; + +/** + * [#11072] Resolve the pg-grammar arm to its browser twin — the whole + * mechanism by which the `browser`-conditioned bundles exclude the + * driver-config validators' Node-only dependency. + * + * Keyed to the seam module's specifier, NOT to `pg-connection-string` + * itself, on purpose: a blanket alias of the package would silently degrade + * any FUTURE import site nobody audited, whereas this swap covers exactly + * the one audited seam and a new direct import lands in the browser bundles + * where `check:browser-reachable-entries` refuses it at this producer. + */ +const swapServerOnlyGrammarArm: Plugin = { + name: 'swap-server-only-grammar-arm', + setup(build) { + build.onResolve({ filter: /[\\/]pg-url-grammar\.server$/ }, (args) => ({ + path: join(dirname(args.importer), 'pg-url-grammar.browser.ts'), + })); + }, +}; + // Generate DTS separately to avoid memory issues const isDts = process.env.BUILD_DTS === 'true'; -export default defineConfig({ +const mainConfig: Options = { entry: entries, splitting: false, sourcemap: true, @@ -104,4 +151,29 @@ export default defineConfig({ target: 'es2020', treeshake: true, esbuildPlugins: [pureSchemaConstruction], -}); +}; + +/** + * The `browser`-conditioned outputs (#11072) — same entry shapes, same + * formats, same target as the main pass, differing ONLY in the grammar-arm + * swap and the `dist/browser/` outDir. No DTS pass of its own: the browser + * build's public API is identical by construction (the swapped module keeps + * the contract), so the exports map points its `browser.types` at the main + * build's declarations. + */ +const browserConfig: Options = { + entry: browserConditionedEntries, + outDir: 'dist/browser', + splitting: false, + sourcemap: true, + clean: false, // dist was cleaned (or preserved) by the main pass + dts: false, + format: ['esm', 'cjs'], + target: 'es2020', + treeshake: true, + esbuildPlugins: [pureSchemaConstruction, swapServerOnlyGrammarArm], +}; + +// The DTS pass re-runs only the main config (declarations once, per entry); +// the JS pass emits the Node outputs and then the browser-conditioned ones. +export default defineConfig(isDts ? mainConfig : [mainConfig, browserConfig]); From f975d0211a803cc919f3e2291090f559d01a2a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 16:46:01 +0000 Subject: [PATCH 2/2] docs(spec): README boundary rule + changeset for the browser condition (#11072) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MsbKEG4LtERSLaDrbehM3e --- .../spec-browser-condition-pg-grammar.md | 29 +++++++++++++++++++ packages/spec/README.md | 15 ++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .changeset/spec-browser-condition-pg-grammar.md diff --git a/.changeset/spec-browser-condition-pg-grammar.md b/.changeset/spec-browser-condition-pg-grammar.md new file mode 100644 index 0000000000..bec421ef52 --- /dev/null +++ b/.changeset/spec-browser-condition-pg-grammar.md @@ -0,0 +1,29 @@ +--- +"@objectstack/spec": patch +--- + +Declare the package's browser boundary in the `exports` map (#11072): the five +entries whose module graph reaches the driver-config validators (`.`, `./data`, +`./system`, `./kernel`, `./cloud`) now carry a `browser` export condition +pointing at bundles (`dist/browser/**`) in which the postgres `url` +refinement's pg-grammar arm is excluded. `pg-connection-string` — the parser +`pg` itself uses, and the one the #9091 refusal deliberately asks — statically +resolves `require('fs')`, so any browser bundler whose client graph reached one +of these entries failed on `Can't resolve 'fs'` (measured on objectui's docs +site, Next.js/Turbopack). + +Patch, not minor/major, because the change is additive resolution surface with +zero Node-side movement: Node's resolver never matches `browser`, every +existing `import`/`require` condition still points at the same files, and the +full #9091 DSN refusal (multi-host, non-numeric port, scheme-less non-URL) +still runs for every Node consumer — the existing `postgres.test.ts` pins hold +it. In the browser-conditioned bundles the refinement degrades to the +shape-only checks it already performs before `parse` (the unix-socket +short-circuit and the fs-reading `?sslcert=`/`?sslkey=`/`?sslrootcert=` +refusal); publish-time validation never legitimately runs in a browser. + +The boundary is enforced at this producer from now on: +`check:browser-reachable-entries` refuses any browser-resolvable bundle — +browser-conditioned or not — that links a Node builtin or a declared +server-only package, with a positive control on the Node side, so the next +Node-only import fails this package's own CI instead of a downstream bundler. diff --git a/packages/spec/README.md b/packages/spec/README.md index 2d6cec585b..7271cbe1d4 100644 --- a/packages/spec/README.md +++ b/packages/spec/README.md @@ -38,6 +38,21 @@ bundlers may drop what a consumer does not reach — module-scope side effects in any published module are therefore also a defect (measured, not assumed; see objectstack#10031). +**Node-only imports are refused everywhere a browser bundler resolves** +(#11072). Every entry is loadable from a browser build by construction — +schema-bearing entries included — so no published bundle may link a Node +builtin or a server-only package. The one sanctioned exception is the +pg-grammar arm of the postgres `url` refinement (`pg-connection-string`, whose +`parse` statically resolves `require('fs')`): the entries that reach it carry a +`browser` export condition pointing at `dist/browser/**` bundles in which that +arm is swapped for a dependency-free twin (`src/data/driver/pg-url-grammar.*`, +`tsup.config.ts` → `swapServerOnlyGrammarArm`), and Node consumers keep the +full DSN refusal unchanged. `check:browser-reachable-entries` enforces both +halves — a browser-conditioned bundle that still links the parser, and a +non-conditioned bundle that links anything Node-only, are each a red — with a +positive control on the Node side. Need Node-only work in a schema? Route it +through a seam module the browser pass swaps, never import it directly. + ## Usage **Recommended: Use `ObjectSchema.create()` with `Field.*` helpers for strict TypeScript validation:**