Skip to content

refactor: src/exports holds only re-exports; implementation is placed by plane#136

Merged
wmadden-electric merged 15 commits into
mainfrom
claude/exports-thin-surfaces
Jul 21, 2026
Merged

refactor: src/exports holds only re-exports; implementation is placed by plane#136
wmadden-electric merged 15 commits into
mainfrom
claude/exports-thin-surfaces

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

What

src/exports/*.ts now holds nothing but re-exports, and no implementation
module imports from exports/
. All implementation moved out — 21 files across
10 packages.

This reverses a characteristic ADR-0035
had recorded as accepted: that an exports file could be the implementation home
and internal modules could import "up" into it. That shipped in #130 and is
rejected here. The ADR is amended in this PR — a permanent decision record
documenting the opposite of what the code does is worse than no record.

Why it matters

An exports file that can hold implementation eventually does, and then internal
modules import it — at which point the published surface becomes something the
package's own internals depend on. That had already happened: pg-warm-resource.ts
and prisma-next-migrate.ts imported ./exports/pg-connection.ts, and
descriptors/prisma-next.ts imported ../exports/prisma-next.ts. Those are gone.

Where implementation lives, and why the layout is plane-shaped

src/exports/<name>.ts   # thin re-export only — the surface
src/<name>.ts           # shared-plane implementation
src/control/**          # control-plane implementation
src/execution/**        # execution-plane implementation

This is forced, not stylistic. The plane globs must not overlap (the cruiser has
no glob precedence), and core, cron and streams classify their roots with
src/*.ts → shared. A control- or execution-plane module at the root would be
classified shared, and a per-file override would overlap that glob and put the
file in two plane groups. Subdirectories are the only non-overlapping option.
target and storage keep one glob per root file instead, because their roots
are genuinely mixed-plane.

The directories are named after the planes the cruiser already enforces
(ADR-0017/0028) rather than importing Prisma Next's core/ name — one
vocabulary, not two.

This is stricter than the pattern it adapts: Prisma Next's own
adapter-postgres/src/exports/column-types.ts carries ~185 lines of
implementation. The stricter rule was taken deliberately.

Proof

Two workspace sweeps, both run at the tip:

  • 56 exports files checked — zero contain a function/class/const/let/
    var/enum/interface/type declaration.
  • 112 non-test modules scanned — zero import from exports/.

Tests are the one exemption: a test or fixture importing the public surface is
consuming it as a consumer would, which is what test-import-patterns.mdc
prescribes — and core's probe-core-authoring.ts exists precisely to bundle the
public barrel and assert it stays lean.

Invariants held

Every published subpath key is unchanged, for every package. The multi-pass
packages (cron/storage/streams) keep exports:false, their hand-maintained
maps, and their multi-pass configs; their package.json#exports and dist ENTRY
filenames are byte-identical, which is what their runtime import.meta.url
sibling resolution depends on. (Internal content-hashed chunks may rehash — the
accepted invariant from #130. Amusingly, storage's reverted to their pre-#130
hashes, since moving implementation back to the root restored the module IDs.)

Full suite green: build 28/28 · typecheck 58/58 · lint · lint:deps
(no violations, 366 modules / 642 deps; architecture-coverage: 167 files
classified, 213 specifiers — every edge visible
) · test 48/48 ·
lint:rules:symlinks consistent.

Two traps worth knowing (now in the rule)

  • export * does not carry a default. Three *-service.ts files have one —
    the runnable node build.module points at. A bare export * would have
    silently dropped it.
  • A program has nothing to re-export. The *-entrypoint.ts files are
    side-effect programs with zero exports; their shims import for effect, which
    is safe only while no package declares sideEffects: false.

Both are documented in .agents/rules/exports-entrypoints.mdc.

Also here

The Drive records for this slice and #130 (.drive/projects/exports-entrypoint/)
.drive/projects/ is tracked in this repo and six other project records are on
main; this one was missing. Its learnings.md is corrected where this slice
falsified it.

🤖 Generated with Claude Code

Implementation moves out of src/exports/ to the package root, where the
0-foundation/** directory glob already classifies it as shared, and each
exports file becomes a re-export of the same symbol set. The within-package
secret test now imports the implementation module directly.

The published subpath keys are unchanged (./assertions, ./casts, ./secret) and
the generated exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Implementation moves out of src/exports/: the shared `testing.ts` to the
package root (covered by src/*.ts -> shared) and the control-plane `deploy.ts`
and `app-config.ts` to a new src/control/ directory. Each exports file becomes
a re-export of the same symbol set; exports/index.ts was already thin and is
unchanged.

Control-plane implementation cannot sit at the root: core collapsed its root to
src/*.ts -> shared, and a per-file override would overlap that glob, which the
cruiser resolves by putting the file in both planes. A subdirectory keeps the
globs non-overlapping, so architecture.config.json gains
core/src/control/** -> control. deploy and app-config are mutually dependent and
stay siblings, so their ./ imports of each other are unchanged.

Within-package tests now import the implementation modules rather than the
exports shims. The published subpath keys are unchanged (., ./config, ./deploy,
./testing) and the generated exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The `node()` build adapter moves to src/node.ts (shared) and the control-plane
descriptor and assembler to src/control/build.ts, each exports file becoming a
re-export of the same symbol set — including the default export the adapter
ships. control/build.ts took the sibling type import with it, now reading the
implementation at ../node.ts rather than the exports shim; its deploy-time
esbuild build() call is untouched.

architecture.config.json gains node/src/*.ts -> shared and
node/src/control/** -> control; the two existing per-file exports globs are
unchanged and nothing overlaps.

The firewall test still walks from the authoring entry, so it now follows the
shim into the implementation; its offending-import pattern is widened to catch
the new src/control/ directory form as well as a /control entry.

The published subpath keys are unchanged (., ./control) and the generated
exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Mirrors the node package: the `nextjs()` build adapter moves to src/nextjs.ts
(shared) and the control-plane descriptor and assembler to src/control/build.ts,
each exports file becoming a re-export of the same symbol set including the
default export. control/build.ts reads its sibling type from ../nextjs.ts rather
than the exports shim; its deploy-time esbuild build() call is untouched.

architecture.config.json gains nextjs/src/*.ts -> shared and
nextjs/src/control/** -> control, non-overlapping with the existing per-file
exports globs. The firewall test's offending-import pattern is widened to catch
the src/control/ directory form.

The published subpath keys are unchanged (., ./control) and the generated
exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The deployment report renderer moves to src/render-deployment.ts and the
exports file becomes a re-export. cli is classified by a single
src/** -> control glob, which already covers both the package root and
src/exports/, so architecture.config.json needs no change.

The within-package test now imports the implementation module it exercises.
The published subpath keys are unchanged (. and ./report) and the generated
exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The Providers collection and the providers() bundle move to src/providers.ts;
exports/index.ts keeps only re-exports — the implementation, the client,
container and credential helpers, and the sibling compute and postgres
entrypoints. An exports file re-exporting another exports file is fine, both
being public surface; providers.ts imports only internal modules, never
src/exports/.

lowering is classified by a single src/** -> control glob, which already covers
the package root, so architecture.config.json needs no change.

The published subpath keys are unchanged (., ./compute, ./postgres, ./state)
and the generated exports map is byte-identical.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The control-plane extension entry moves to src/control/extension.ts — named for
what it holds, the prismaCloud() ExtensionDescriptor factory — and the three
shared surfaces (pg-connection, prisma-next, testing) move to the package root.
Each exports file becomes a re-export; exports/index.ts was already thin and
re-exports only non-moving root modules, so it is unchanged.

This removes the inversion that motivated the rule: pg-warm-resource.ts and
prisma-next-migrate.ts imported ./exports/pg-connection.ts and
descriptors/prisma-next.ts imported ../exports/prisma-next.ts. All three now
read the root implementation, so no implementation depends on the public
surface. Tests may still import the surface, which is what they are for.

target stays per-file in architecture.config.json — its root is mixed-plane, so
src/*.ts would misclassify the control files there. It gains
src/control/** -> control plus shared globs for the three new root modules;
every existing glob is untouched and nothing overlaps.

The invariants test's process-env inventory follows the implementation from
exports/control.ts to control/extension.ts. The published subpath keys are
unchanged (., ./connection, ./control, ./prisma-next, ./testing).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The bare scheduler node and the scheduler boot program move to src/execution/,
the plane their globs already named, and each exports file becomes a re-export.
scheduler-service ships a default export, so its shim re-exports `default`
explicitly rather than using `export *`, which would not carry it.
scheduler-entrypoint exports nothing — it is a program — so its shim imports it
for effect; the package declares no `sideEffects: false`, so that import is not
dropped.

src/execution/ is the same depth as src/exports/, so both moved files keep their
../scheduler.ts imports unchanged. architecture.config.json gains
cron/src/execution/** -> execution; the existing globs are untouched.

The exports map and every dist entry filename are byte-identical, which is what
the runtime import.meta.url sibling resolution depends on.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The storage service node moves to the package root as shared-plane
implementation and the boot program to src/execution/, matching the planes
their globs already named. Each exports file becomes a re-export;
exports/index.ts and exports/testing.ts were already thin and are unchanged.
storage-service ships a default export, so its shim re-exports both the named
factory and `default`; storage-entrypoint exports nothing and is imported for
effect.

This removes the inversion: storage-module.ts imported
./exports/storage-service.ts and now reads the root implementation, so no
implementation depends on the public surface.

storage stays per-file in architecture.config.json — its root is mixed-plane,
so src/*.ts would misclassify the runtime engine there. It gains
src/execution/** -> execution and src/storage-service.ts -> shared.

The exports map and every dist entry filename are byte-identical. The internal
content-hashed chunks changed name, which is expected and allowed: moving the
implementation back to the package root restored the module ids those hashes
derive from. Runtime resolution targets the entry files, which did not move.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The streams service node moves to the package root, where the collapsed
src/*.ts -> shared glob already classifies it, and the boot program and the
local stand-in move to src/execution/. testing.ts goes to src/execution/ rather
than the root deliberately: it is execution-plane, and the root is collapsed to
shared, which would misclassify it.

Each exports file becomes a re-export. streams-service ships a default export,
so its shim re-exports both the named factory and `default`;
streams-entrypoint exports nothing and is imported for effect.

This removes the inversion: streams-module.ts imported
./exports/streams-service.ts and now reads the root implementation.

architecture.config.json gains streams/src/execution/** -> execution; the
existing globs are untouched and nothing overlaps.

The exports map and every dist entry filename are byte-identical; only the
internal content-hashed chunks changed name, which is allowed. The entrypoint
integration test, which boots the entry as a child process, still passes.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…placed by plane

ADR-0035 recorded the opposite of what the workspace now does. It documented as
an accepted characteristic that implementation may live in src/exports/ and that
internal code may import "up" into an exports/ sibling, naming the stricter form
an optional future refactor. The operator rejected that, and the workspace has
been converted, so the ADR is corrected rather than left contradicting the code.

It now records: an exports file holds nothing but re-exports; no implementation
module may import from exports/, with tests exempt because a test importing the
public surface is consuming it as a consumer would; and implementation is placed
by plane — shared at the package root, control in src/control/**, execution in
src/execution/**. That placement is forced rather than stylistic: packages whose
root is collapsed to src/*.ts -> shared would misclassify a control or execution
module placed there, and a per-file override would overlap the collapsed glob,
which with no glob precedence puts the file in two plane groups.

The ADR also now says plainly that this is stricter than the pattern it adapts,
whose own adapter-postgres exports file carries implementation, and why we chose
the stricter rule. The generated-map decision, the two hand-maintained
exceptions, the entry-filename invariant, and the non-overlapping-globs
constraint are unchanged.

The rule gains the thin-surface requirement, the tests exemption, the plane
placement, and the two shim traps this slice hit: `export *` does not carry a
`default` export, and an entrypoint program with no exports is imported for
effect.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Adds the project record for the slice that moved every package's public
entrypoints into src/exports/ (merged as PR #130) and for the follow-up that
made those files thin: the investigation, the slice specs, the dispatch briefs,
and the learnings. Matches how the six existing project records are stored;
reviews/ stays untracked per .gitignore.

Two learnings entries are corrected because later work falsified them. The
"internal code imports up into src/exports/" entry recorded an accepted
characteristic that the follow-up slice reversed — it is kept, marked as
reversed, with the lesson that an exports file which can hold implementation
eventually does, and that being green under the current rules is not evidence
the shape is right. The "D6 collapse dropped" entry now records that D9 fixed
the glob normalizer and took the collapse, with the lesson that a separate
concern blocking a cleanup is worth pricing before dropping the cleanup.

A new entry records this slice's traps: `export *` silently drops a `default`
export, entrypoint programs have nothing to re-export so their shim imports for
effect, implementation placement is forced by the plane globs, and a test that
identifies a file by path breaks silently when the file moves.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@136
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@136

commit: f04ddfc

Brings the branch up to date with main (postgres Branch naming in create, the
cast-reading rules doc, the extension-registry model refresh, and the
SPI-inversion project close-out). No conflicts: main touched no file this branch
changed.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Brings in main's bucket() resource (#132). The additive bucket files — new
lowering providers (buckets/Bucket.ts, buckets/BucketKey.ts, exports/buckets.ts),
the target descriptor and node module, the example app, and the CI job — merge
cleanly.

Two files conflicted because bucket added exports to what are, on this branch,
thin re-export shims. Resolved on the slice's principle — main's content, our
placement:

- lowering/src/exports/index.ts: main folded the Bucket/BucketKey providers into
  providers() and re-exported ./buckets.ts. The providers() additions go to the
  implementation in src/providers.ts; the shim keeps only the new re-export.
- target/src/exports/control.ts: main registered the bucket descriptor under the
  's3' node kind inside prismaCloud(). That goes to the implementation in
  src/control/extension.ts; the shim stays `export * from '../control/extension.ts'`.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@wmadden-electric, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f3e67db-5070-44ca-847a-bbb00272fbe9

📥 Commits

Reviewing files that changed from the base of the PR and between 842c125 and f04ddfc.

📒 Files selected for processing (73)
  • .agents/rules/exports-entrypoints.mdc
  • .drive/projects/exports-entrypoint/investigation.md
  • .drive/projects/exports-entrypoint/learnings.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d3-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d4a-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d4b-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d5-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d6-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d7-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/d8-brief.md
  • .drive/projects/exports-entrypoint/slices/exports-entrypoint/spec.md
  • .drive/projects/exports-entrypoint/slices/thin-exports/spec.md
  • architecture.config.json
  • docs/design/90-decisions/ADR-0035-public-entrypoints-live-in-src-exports.md
  • packages/0-framework/0-foundation/foundation/src/assertions.ts
  • packages/0-framework/0-foundation/foundation/src/casts.ts
  • packages/0-framework/0-foundation/foundation/src/exports/assertions.ts
  • packages/0-framework/0-foundation/foundation/src/exports/casts.ts
  • packages/0-framework/0-foundation/foundation/src/exports/secret.ts
  • packages/0-framework/0-foundation/foundation/src/secret.test.ts
  • packages/0-framework/0-foundation/foundation/src/secret.ts
  • packages/0-framework/1-core/core/src/__tests__/lowering.test.ts
  • packages/0-framework/1-core/core/src/__tests__/testing.test-d.ts
  • packages/0-framework/1-core/core/src/__tests__/testing.test.ts
  • packages/0-framework/1-core/core/src/control/app-config.ts
  • packages/0-framework/1-core/core/src/control/deploy.ts
  • packages/0-framework/1-core/core/src/exports/app-config.ts
  • packages/0-framework/1-core/core/src/exports/deploy.ts
  • packages/0-framework/1-core/core/src/exports/testing.ts
  • packages/0-framework/1-core/core/src/testing.ts
  • packages/0-framework/2-authoring/nextjs/src/__tests__/no-control-import.test.ts
  • packages/0-framework/2-authoring/nextjs/src/control/build.ts
  • packages/0-framework/2-authoring/nextjs/src/exports/control.ts
  • packages/0-framework/2-authoring/nextjs/src/exports/index.ts
  • packages/0-framework/2-authoring/nextjs/src/nextjs.ts
  • packages/0-framework/2-authoring/node/src/__tests__/no-control-import.test.ts
  • packages/0-framework/2-authoring/node/src/control/build.ts
  • packages/0-framework/2-authoring/node/src/exports/control.ts
  • packages/0-framework/2-authoring/node/src/exports/index.ts
  • packages/0-framework/2-authoring/node/src/node.ts
  • packages/0-framework/3-tooling/cli/src/__tests__/render-deployment.test.ts
  • packages/0-framework/3-tooling/cli/src/exports/render-deployment.ts
  • packages/0-framework/3-tooling/cli/src/render-deployment.ts
  • packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts
  • packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts
  • packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts
  • packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts
  • packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts
  • packages/1-prisma-cloud/1-extensions/target/src/exports/control.ts
  • packages/1-prisma-cloud/1-extensions/target/src/exports/pg-connection.ts
  • packages/1-prisma-cloud/1-extensions/target/src/exports/prisma-next.ts
  • packages/1-prisma-cloud/1-extensions/target/src/exports/testing.ts
  • packages/1-prisma-cloud/1-extensions/target/src/pg-connection.ts
  • packages/1-prisma-cloud/1-extensions/target/src/pg-warm-resource.ts
  • packages/1-prisma-cloud/1-extensions/target/src/prisma-next-migrate.ts
  • packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts
  • packages/1-prisma-cloud/1-extensions/target/src/testing.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/execution/scheduler-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/execution/scheduler-service.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/exports/scheduler-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/cron/src/exports/scheduler-service.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/execution/storage-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/exports/storage-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/exports/storage-service.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/storage-module.ts
  • packages/1-prisma-cloud/2-shared-modules/storage/src/storage-service.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/execution/streams-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/execution/testing.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/exports/streams-entrypoint.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/exports/streams-service.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/exports/testing.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/streams-module.ts
  • packages/1-prisma-cloud/2-shared-modules/streams/src/streams-service.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/exports-thin-surfaces
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/exports-thin-surfaces

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wmadden-electric
wmadden-electric enabled auto-merge (squash) July 21, 2026 09:09
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric
wmadden-electric merged commit 621149f into main Jul 21, 2026
12 of 13 checks passed
@wmadden-electric
wmadden-electric deleted the claude/exports-thin-surfaces branch July 21, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants