diff --git a/.agents/rules/no-bundling.mdc b/.agents/rules/no-bundling.mdc
new file mode 100644
index 00000000..782c3944
--- /dev/null
+++ b/.agents/rules/no-bundling.mdc
@@ -0,0 +1,33 @@
+---
+description: "We don't bundle the app's code, and we don't guess or launder. The user's build produces the runnable; the framework assembles the deploy artifact by DOCUMENTED, DETERMINISTIC steps only (validate, wrap, and each app-type's documented deploy step) — never by guessing filenames/depths or laundering trees. Settled by ADR-0005. Do not relitigate."
+alwaysApply: true
+---
+
+# We don't bundle the app's code — and we don't guess
+
+**The framework never bundles or transforms the application's code** — the
+user's build (`next build`, `tsdown`, …) produces the runnable. Downstream, the
+framework assembles the deploy artifact, but only by **documented, deterministic**
+steps: validate the built output, add the boot wrapper, and perform that
+app-type's documented deploy step (e.g. `nextjs()` does Next's documented
+`.next/static`+`public/` copy; `node()` ships the entry). Guiding principle:
+`docs/design/01-principles/architectural-principles.md`; settled by
+`docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md`.
+
+The real rule is **no guessing, no laundering** — do not "helpfully" violate it:
+
+- Never guess or discover a bundle filename — dictate names we own (`main.mjs`).
+- Never infer monorepo layout, workspace roots, or a fixed depth. When the app's
+ location in a standalone tree is needed, **find** it (locate `server.js`); do
+ not compute it.
+- Never derive identity from file paths, and never write an absolute
+ deploy-machine path into an artifact. Uniqueness comes from the graph address.
+- A symlink in a bundle is a hard error ("use a hoisted node_modules"), never
+ dereferenced or represented. Ship `node_modules` as the build produced it.
+- Never write deploy output into `node_modules` or the user's build output;
+ staging is deploy-owned, keyed by graph address; the user's tree goes under
+ `bundle/`.
+
+Doing an app-type's *documented* deploy step (the Next asset copy) is fine — it's
+deterministic. Guessing or laundering is not. If a change needs guessing/
+inference/dereferencing, the design is being misread — re-read ADR-0005.
diff --git a/.drive/projects/forcing-function-apps/bugs-deploy-assembly.md b/.drive/projects/forcing-function-apps/bugs-deploy-assembly.md
new file mode 100644
index 00000000..50ec99c8
--- /dev/null
+++ b/.drive/projects/forcing-function-apps/bugs-deploy-assembly.md
@@ -0,0 +1,114 @@
+# Deploy-assembly bugs surfaced by the datahub live deploy (2026-07-13)
+
+> **DESIGN SETTLED — [ADR-0005](../../../docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md). Do not relitigate.**
+> The contract: the user's build hands the framework a finished **flat** bundle;
+> the framework only wraps it in its bootstrap. No path-string arithmetic, no
+> filesystem-derived identity, no layout inference, no absolute paths in
+> artifacts. Uniqueness comes from the node's graph address. A symlink in a
+> bundle is a **hard error** — producing a flat tree is the user's job, and the
+> framework never launders trees. The per-bug fixes below are superseded where
+> they conflict; the ADR is authoritative.
+
+The first real out-of-repo deploy (`datahub`, `prisma-compose deploy module.ts`
+against the team workspace) surfaced three framework bugs in the deploy path.
+All three ship in the published `@prisma/compose@0.1.0` /
+`@prisma/compose-prisma-cloud@0.1.0`. None are datahub's fault. CI's
+"Deploy, verify, destroy" job never caught them because it deploys only
+`storefront-auth` — one layout, no cron, and its tree happens not to trip the
+packager.
+
+Each bug was hit live, patched locally in datahub's `node_modules`, and the
+deploy re-run to surface the next one. Evidence logs: session scratchpad
+`deploy2.log`–`deploy6.log`.
+
+## Bug 1 — node assembler hardcodes the bundle filename
+
+`packages/0-framework/2-authoring/node/src/control.ts` (assemble):
+
+```ts
+const built = fs.readdirSync(bundleDir).find((f) => /^service\.m?js$/.test(f));
+```
+
+tsdown names its output after the **module's basename**. Every example's
+service module is `service.ts` → `service.mjs`, so the regex holds — until the
+cron scheduler, whose `build.module` is `scheduler-service.mjs` → tsdown emits
+`scheduler-service.mjs` → no match → `tsdown produced no service.js`.
+
+**Impact:** any app using `cron()` cannot deploy — including the framework's
+own `examples/cron` (never deployed in CI).
+
+**Fix (ADR-0005):** dictate the output name instead of discovering it —
+tsdown object entry (`entry: { main: serviceModule }`) emits `main.mjs`
+directly; the readdir hunt, regex, and rename all delete. Stage the wrapper in
+a deploy-owned dir keyed by the node's graph address
+(`.prisma-compose/artifacts/
/`) — never inside `node_modules` (the
+scheduler's wrapper currently lands in the installed package's `dist/`) and
+never in the user's build output. (An earlier basename-arithmetic patch was
+verified live but is superseded: no filesystem-derived identity.)
+
+## Bug 2 — nextjs assembler hardcodes the monorepo depth
+
+`packages/0-framework/2-authoring/nextjs/src/control.ts`:
+
+```ts
+const workspaceRoot = path.resolve(resolvedApp, '../../../..'); // exactly 4 up
+return path.join(resolvedApp, '.next', 'standalone', rel);
+```
+
+Next mirrors the app's path **relative to `outputFileTracingRoot`** inside
+`.next/standalone/`. The framework guesses that root as exactly four levels
+above the app — true for the examples' `examples//modules//` layout,
+false for datahub's `apps/web/` (two deep). Observed: framework looks for
+`.next/standalone/prisma/datahub/apps/web/server.js`; Next actually wrote
+`.next/standalone/apps/web/server.js`.
+
+**Impact:** any Next.js app not exactly 4 directories below its tracing root
+cannot deploy. That's most real monorepos.
+
+**Fix (ADR-0005):** no inference of any kind. The user supplies the path to
+their standalone app dir on the `nextjs()` adapter (relative resolves against
+`dirname(module)`, absolute passes through). Keep the "run `next build` with
+output: standalone" error when the declared path has no entry. (Discovery via
+glob was considered and rejected — inference is the root cause, not the cure.)
+
+## Bug 3 — artifact packager reads symlinks as files
+
+`packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts`:
+
+```ts
+if (entry.isDirectory()) visit(rel);
+else out.push(rel); // a symlink lands here…
+…
+content: fs.readFileSync(path.join(dir, relPath)) // …EISDIR on dir symlinks
+```
+
+`Dirent.isDirectory()` is false for symlinks, so a symlink-to-directory is
+treated as a regular file and `readFileSync` throws `EISDIR`. Next standalone
+trees are full of them: datahub's has **118**, all relative, bun-store style
+(`node_modules/` → `.bun/@/node_modules/`), targets inside
+the walked tree. Module resolution goes **through** those links, so they can't
+be skipped; dereferencing would double the artifact (targets are also walked).
+
+**Impact:** any Next.js app (and anything else with symlinked node_modules)
+fails at `packageComputeArtifact`.
+
+**Fix (ADR-0005):** flat bundles are the contract; a symlink in a bundle is
+a **hard error** at package time, naming the offending path and the fix
+("materialize links in your build, e.g. `cp -RL`"). No dereferencing, no
+symlink representation, no cycle/containment machinery — producing a flat tree
+is the user's build's job. Consequence: datahub's bun-built standalone gains a
+flatten step in its own build (datahub PR, not framework).
+
+## Also observed (not framework bugs)
+
+- datahub lacked deploy-time deps (`alchemy`, `effect`, `@effect/platform-bun`,
+ `@effect/platform-node`, root `arktype`) — fixed on the datahub PR (`53db4cf`).
+- One real resource was created before bug 3 hit: state project
+ `proj_cmriwu1se219gyif8egd8qxdo` (empty ledger; reusable or deletable).
+
+## Coverage gap behind all three
+
+Unit tests never assemble or package a real tree. The fixes should land with:
+an assemble test for a non-`service.ts` module (cron scheduler shape), a
+standalone-layout test at ≠4 depth, and a packager test over a tree containing
+relative dir-symlinks.
diff --git a/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/plan.md b/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/plan.md
new file mode 100644
index 00000000..b2d53d30
--- /dev/null
+++ b/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/plan.md
@@ -0,0 +1,118 @@
+# Dispatch plan: flat-bundle-deploy-path
+
+Four dispatches, sandwich shape: substrate+node (D1) → nextjs (D2) →
+packager (D3, independent) → consumer migration (D4). Sequential loop; D3 is
+genuinely independent of D1/D2 (different package, no shared file) and could
+run parallel, but the loop keeps it in sequence.
+
+Contract source: [spec.md](spec.md). Do not re-derive the design; implement it.
+
+---
+
+## D1 — node adapter honors the flat-bundle contract (+ the assembly substrate)
+
+**Outcome:** a node service — including the cron scheduler, whose build module
+is `scheduler-service.mjs`, not `service.ts` — assembles its wrapper to
+`main.mjs` inside a deploy-owned, address-keyed dir
+(`/.prisma-compose/artifacts//`), with the user's built entry
+copied in beside it. Nothing is written into `node_modules` or the user's build
+output.
+
+**Includes the substrate** (delivered here because it has no observable value
+without a consumer): `AssembleInput` gains `address: string` and a deploy-cwd
+handle (`deploy.ts`); `assembleServices` threads the loop `id` as address and
+`cwd` through `RunAssembler`/`buildControlAssemble` (`assemble-services.ts`);
+the CLI passes its `cwd` (`main.ts:234`, cwd already at :192).
+
+**Implementation notes (from grounding, not prescriptions):** replace
+`entry: [serviceModule]` + the `readdirSync(...).find(/^service\.m?js$/)` +
+rename with tsdown object entry `entry: { main: serviceModule }` → emits
+`main.mjs` directly. Keep `config: false` and the reserved-`main` basename
+error. Stage under cwd, not `dirname(entryPath)/bundle`.
+
+**Builds on:** — (first).
+**Hands to:** `AssembleInput` carries `address` + `cwd`; the node adapter reads
+them; `main.mjs` staging is deploy-owned and address-keyed.
+
+**Completed when:**
+- `pnpm test:packages` covers a node assemble over a non-`service.ts` module
+ (cron-scheduler shape) producing `main.mjs`; asserts staging is under
+ `.prisma-compose/artifacts//`, not `node_modules`/user output.
+- Existing node-adapter + assemble tests green with the new `AssembleInput`.
+
+## D2 — nextjs adapter takes the standalone path; stops completing the tree
+
+**Outcome:** `nextjs()` takes a **user-supplied standalone directory path**
+(relative → `dirname(module)` per ADR-0004, absolute passthrough) instead of
+`appDir`; `assemble()` validates that dir has the entry and adds the `main.mjs`
+wrapper (address-keyed staging, as D1) — and does nothing else. No
+`nextStandaloneDir` derivation, no static/`public/` copy.
+
+**Implementation notes:** delete `nextStandaloneDir` + `standaloneEntryPath`'s
+derivation; the standalone dir is now an input. Rename the `NextjsBuildAdapter`
+field (`appDir` → e.g. `standalone`) and update `index.ts`'s doc + type. Drop
+the fs copy of `.next/static` and `public/`. Add the reserved-`main` basename
+assertion for parity with node.
+
+**Builds on:** D1 — the `AssembleInput` shape (`address` + `cwd`) and the
+`main.mjs`/address-staging convention.
+**Hands to:** `nextjs()` on the standalone-path API; nextjs assemble validates
++ wraps only.
+
+**Completed when:**
+- `pnpm test:packages` covers a nextjs assemble where the standalone dir sits
+ at a non-4-levels depth (the datahub `apps/web` shape) and resolves correctly
+ from a user-supplied path; asserts no static/public copy occurs.
+- A missing-entry standalone still errors with "run `next build`".
+
+## D3 — packager rejects symlinks (flat-only)
+
+**Outcome:** `packageComputeArtifact` fails fast on a bundle containing a
+symlink, naming the path and the fix; the deterministic tar stays regular-files
+only.
+
+**Implementation notes:** in `walkFiles` (`compute/artifact.ts:49`), branch on
+`entry.isSymbolicLink()` → throw
+(`bundle contains a symlink at ; deploy bundles must be flat — materialize
+links in your build, e.g. cp -RL`). No deref, no symlink tar entries.
+
+**Builds on:** — (independent of D1/D2; different package).
+**Hands to:** the packager enforces flat.
+
+**Completed when:**
+- `pnpm test:packages` covers `walkFiles`/`packageComputeArtifact` over a
+ fixture tree containing a relative dir-symlink → throws the actionable error.
+- Existing artifact/packager tests green.
+
+## D4 — storefront-auth (the one nextjs example) migrates to the contract
+
+**Outcome:** storefront-auth's storefront module builds a **complete flat
+standalone** (next build → copy `.next/static` + `public/` → no symlinks) and
+its `nextjs()` call uses the standalone-path API, so its deploy path works with
+the framework no longer completing the tree. `.prisma-compose/` is gitignored.
+
+**Implementation notes:** update
+`examples/storefront-auth/modules/storefront/src/service.ts`'s `nextjs({...})`
+to the new field; add the flatten/copy step to that module's build script.
+storefront-auth is pnpm+hoisted (no symlinks today) so D3's error stays dormant
+for it — don't regress the `.npmrc` hoist shim.
+
+**Builds on:** D2 (new nextjs API) + D3 (flat requirement).
+**Hands to:** the CI "Deploy, verify, destroy" job (storefront-auth) exercises
+the new contract end-to-end.
+
+**Completed when:**
+- Locally: storefront-auth's build produces a standalone with `static/` +
+ `public/` present and zero symlinks; `assembleServices` + `packageComputeArtifact`
+ over that tree succeed (binary, local — the live deploy is the PR's CI).
+- `examples/storefront-auth/.gitignore` ignores `.prisma-compose/`.
+
+---
+
+## Completeness check
+
+Final hand-offs cover the slice-DoD: cron-shaped assemble (D1) + symlink
+hard-error (D3) are the two named done-conditions; nextjs contract (D2) and the
+one consumer (D4) close the "honor the contract end-to-end" coherence claim.
+Every framework change ships with the unit test the spec's coverage-gap section
+demanded.
diff --git a/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/spec.md b/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/spec.md
new file mode 100644
index 00000000..bf8efdaa
--- /dev/null
+++ b/.drive/projects/forcing-function-apps/slices/flat-bundle-deploy-path/spec.md
@@ -0,0 +1,152 @@
+# Slice: Honor the flat-bundle contract in the deploy path
+
+## At a glance
+
+The first real out-of-repo deploy (datahub) hit three deploy-path bugs, all one
+root cause: the framework guessing facts that belong to the user or to
+configuration. This slice makes `assemble()` and `package()` honor
+[ADR-0005](../../../../docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md)
+(amended): the user's build produces a finished flat bundle; the framework only
+validates it and adds its boot wrapper. Evidence + per-bug detail:
+[`../../bugs-deploy-assembly.md`](../../bugs-deploy-assembly.md).
+
+## Amendment (2026-07-13, post-review) — nextjs() does the documented copy
+
+Will's PR review (CHANGES_REQUESTED) rejected the over-built assembly. After a
+long design discussion (research into the canonical Next standalone deploy;
+empirical spikes on `outputFileTracingIncludes` and a flat-boot test), the
+settled design, all on this PR:
+
+- **Two adapters, each earns its place.** `node({ module, entry })` — plain
+ service, ships the built entry under `bundle/`. `nextjs({ module, appDir })` —
+ Next app: `assemble()` performs the *documented* Next standalone deploy
+ (Next docs: `cp -r public .next/standalone/ && cp -r .next/static
+ .next/standalone/.next/`), run at deploy so the app's build is just
+ `next build`. `node()` drops the `dir` param it briefly grew.
+- **No guessing, no laundering.** The app's deep location in the standalone tree
+ (from `outputFileTracingRoot`) is *found* by locating `server.js`, never the
+ old `../../../..`. node_modules ships as `next build` produced it; a symlinked
+ (non-hoisted) install is the packager's hard error — the same misconfig
+ crashes the standalone server at boot, so it must be a flat install (the repo
+ already sets `node-linker=hoisted`).
+- **`bunfig.toml` moves to the Compute packager** — a universal Compute default,
+ not build-adapter logic.
+- **`import.meta.url` stays** — the wrapper *is* the service module bundled
+ (core-model.md:106).
+- Rewrite the `deploy.ts` `address`/`cwd` doc comments in plain English.
+- Rejected on the way here: a generic `node({dir})` (deletes the nextjs adapter —
+ Will wanted it kept); a `prisma-compose next-standalone` CLI subcommand (wrong
+ altitude on the deploy CLI); a hand-maintained flatten script (a smell); a
+ flat-bundle restructure (a novel invention off the trodden path).
+
+Verified: full typecheck + tests; the real storefront tree assembles, packages,
+and the assembled `server.js` boots and serves a static chunk (HTTP 200).
+
+The sections below are the pre-review design; where they conflict, this
+amendment wins.
+
+## Chosen design
+
+**Artifact layout (both adapters).** Assembly builds a per-service working dir
+`/.prisma-compose/artifacts//` containing:
+- `main.mjs` — our wrapper, at the working-dir root.
+- `bundle/` — the user's built output, copied in wholesale (already flat per
+ the contract; a plain recursive copy).
+The returned `Bundle.entry` is `bundle/`; the
+wrapper loads `./bundle/`. Our files sit at the root, the user's tree
+under `bundle/`, so nothing collides and we never write into their output. The
+packager is unchanged — it already finds `main.mjs` at the root and injects
+`bootstrap.js` + the manifest, and `bootstrap.js`'s `import("./")`
+resolves because `appEntry` is now the `bundle/…`-prefixed path.
+
+**1. Thread the graph address into assembly.** `AssembleInput` gains
+`address: string` (deploy.ts). `assembleServices` already has it as the loop
+`id` (assemble-services.ts:64) — pass it through `RunAssembler`/
+`buildControlAssemble`. This keys the working dir per service.
+
+**2. node adapter — dictate the wrapper name; use the `bundle/` layout.**
+(`node/src/control.ts`.) Replace `entry: [serviceModule]` + the
+readdir/regex/rename with a tsdown **object entry** `entry: { main: serviceModule }`,
+so tsdown emits `main.mjs` directly — no discovery. Emit `main.mjs` to the
+working-dir root; copy the user's already-built entry to `bundle/`;
+return `entry: "bundle/"`. Keep the "no built entry — run your build"
+and the reserved-`main` basename errors. (D1 landed an earlier flat variant with
+the entry at the working-dir root — revise to the `bundle/` layout for parity.)
+
+**3. nextjs adapter — take the standalone path; stop completing the tree.**
+(`nextjs/src/control.ts`, `nextjs/src/index.ts`.) Change the authoring API:
+`appDir` (from which the framework *derives* the standalone location via the
+`../../../..` math) → a user-supplied **standalone root dir path** (`standalone`;
+relative resolves against `dirname(module)` per ADR-0004, absolute passes
+through), plus `entry` = the server path relative to that root (e.g.
+`apps/web/server.js`). Delete `nextStandaloneDir` and its arithmetic. Delete the
+static/`public/` copy step — the user's build produces the complete flat tree.
+Copy the standalone root → `bundle/`, emit `main.mjs` at the working-dir root,
+return `entry: "bundle/"`. Keep the "no standalone entry — run
+`next build`" error.
+
+**4. packager — flat only; symlink is a hard error.**
+(`compute/artifact.ts`.) In `walkFiles`, a symlink (`entry.isSymbolicLink()`)
+throws, naming the path and the fix ("bundle contains a symlink at ``;
+deploy bundles must be flat — materialize links in your build, e.g. `cp -RL`").
+No dereferencing, no symlink tar entries. Regular files only.
+
+**5. storefront-auth — the one nextjs example — moves tree-completion into its
+build.** Update its `nextjs()` call to the standalone-path API, and its
+storefront module build to produce a complete flat standalone (`next build` →
+copy static + `public/` → ensure no symlinks). Required in this PR: without it
+the framework's dropped copy step breaks storefront-auth's CI deploy.
+
+## Coherence rationale
+
+One principle applied end-to-end across the two deploy stages that consume a
+built tree (assemble, package). The pieces are forced together: the framework
+change and the storefront-auth build change must land in one PR or CI's
+"Deploy, verify, destroy" goes red between them. A reviewer holds "does the
+deploy path now touch only what the user handed it, plus the wrapper?" in one
+sitting; it rolls back as one unit. Larger-but-cohesive, not splittable without
+a red intermediate state.
+
+## Scope
+
+**In:** `AssembleInput.address` + threading; node adapter object-entry +
+address-keyed staging; nextjs adapter API change + drop derivation + drop
+static/public copy; packager symlink hard-error; storefront-auth `nextjs()` call
++ build flatten step; unit tests for all four framework changes.
+
+**Deliberately out:**
+- datahub's own flatten step + re-deploy — datahub PR, not this one.
+- The `0.1.1` release that ships these fixes — a close-out step after merge.
+- `prismaTsDownConfig()` build-config helper family — deferred, separate session.
+- Any freshness/staleness checking of built output — ADR-0005 leaves it out.
+
+## Pre-investigated edge cases
+
+| Case | Handling |
+| --- | --- |
+| Wrapper build's tsdown still auto-loads a stray `tsdown.config.ts` | Keep `config: false` (node adapter comment explains why — it would rewrite the package's own `exports`). |
+| App entry named `main.js`/`main.mjs` collides with the wrapper | Keep the existing reserved-basename error in the node adapter; add the equivalent to nextjs (its entry is `server.js`, so latent, but assert). |
+| storefront-auth standalone has no symlinks today (pnpm + hoisted `.npmrc`) so bug 3 doesn't trip it | The symlink error stays dormant for it; datahub (bun) is what the error is for. Don't regress the hoist shim. |
+| `.prisma-compose/artifacts/` under deploy cwd needs gitignoring in examples | storefront-auth already needs `.prisma-compose/` ignored (datahub added the same). Verify/add. |
+
+## Done conditions (slice-specific)
+
+- `examples/cron` assembles (the cron scheduler's non-`service.ts` module no
+ longer breaks) and a bun-shaped standalone with symlinks fails the packager
+ with the actionable error — both covered by new unit tests.
+
+## Open questions
+
+- Root for `.prisma-compose/artifacts/`: deploy `cwd` (where the CLI already
+ writes `.prisma-compose/alchemy.run.ts` and state — main.ts:255) is the
+ consistent choice. Confirm the assembler receives cwd or resolves it the same
+ way the CLI does; if assembly has no cwd handle, thread it alongside `address`.
+
+## References
+
+- Contract: [ADR-0005](../../../../docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md),
+ [ADR-0004](../../../../docs/design/90-decisions/ADR-0004-paths-resolve-relative-to-the-authoring-file.md).
+- Evidence: [`../../bugs-deploy-assembly.md`](../../bugs-deploy-assembly.md).
+- Surfaces: `deploy.ts:108` (AssembleInput), `assemble-services.ts:44,64`,
+ `node/src/control.ts:71-99`, `nextjs/src/control.ts:70-80`,
+ `compute/artifact.ts` (walkFiles).
diff --git a/.gitignore b/.gitignore
index ee2ffff7..8b69a18f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,6 @@ skills-lock.json
# Next.js build output (any location — a stray copy under an unexpected path once got committed)
.next/
next-env.d.ts
+
+# Deploy working dir — assembled artifacts staged per graph address (ADR-0005)
+.prisma-compose/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..8a372f15
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,23 @@
+# Agent guidance — Prisma Compose
+
+**Before doing any design or implementation work in this repo, read the
+guiding principles: [`docs/design/01-principles/`](docs/design/01-principles/).**
+They are binding, not advisory. Proposals and code that contradict a recorded
+principle are wrong by definition — the principle wins until an ADR supersedes
+it. In particular: **we don't bundle the app's code, and we don't guess** — the
+framework never bundles/transforms your code, and assembles the deploy artifact
+only by documented, deterministic steps (no filename/depth guessing, no tree
+laundering)
+([ADR-0005](docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md)).
+
+For design work, also check:
+
+- [`docs/design/00-purpose/`](docs/design/00-purpose/) — what this framework is for.
+- [`docs/design/90-decisions/README.md`](docs/design/90-decisions/README.md) —
+ the ADR index. Settled decisions are not relitigated; ground proposals in
+ what is already decided.
+
+Operational rules (naming, casts, build isolation, test idioms) live in
+[`.agents/rules/`](.agents/rules/) and load automatically in harnesses that
+support `.mdc` rules; if yours doesn't, read that directory's README and the
+rules relevant to the files you touch.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 00000000..47dc3e3d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/docs/design/01-principles/architectural-principles.md b/docs/design/01-principles/architectural-principles.md
index da655c28..a35c2885 100644
--- a/docs/design/01-principles/architectural-principles.md
+++ b/docs/design/01-principles/architectural-principles.md
@@ -35,6 +35,22 @@ or through an adapter the app supplies. A deployment platform may fix a runtime
(Prisma Compute runs Bun); that is a hosting fact about the target, not a dependency
of the framework.
+## We don't bundle the app's code — and we don't guess
+
+The framework **never** bundles or transforms your application's code — your
+build (`next build`, `tsdown`, …) produces the runnable. Downstream, the
+framework assembles the deploy artifact, but only by **documented, deterministic**
+steps: it validates the built output, adds its boot wrapper, and performs the
+app-type's documented deploy step (a Next app gets its `.next/static`+`public/`
+copied in exactly as the Next docs prescribe). What it must **never** do is
+*guess* or *launder*: no filename guessing (the wrapper's name is dictated), no
+monorepo-depth inference (the app's location in a standalone tree is *found* by
+locating `server.js`, not computed), no baking absolute paths into artifacts, and
+a symlinked `node_modules` is a hard error, never dereferenced. See
+[ADR-0005](../90-decisions/ADR-0005-users-build-the-framework-assembles.md);
+every guessing/laundering violation has produced a real deploy failure. Do not
+relitigate.
+
## Code over configuration
Your topology is *inferred* from your application code — type-checked, and living in
diff --git a/docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md b/docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md
index 7556e3cc..e9b36307 100644
--- a/docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md
+++ b/docs/design/90-decisions/ADR-0005-users-build-the-framework-assembles.md
@@ -17,9 +17,33 @@ built:
next build # output: "standalone" → .next/standalone/…
```
+Assembly does only **documented, deterministic** steps, never heuristics. Three
+disciplines bound it — each was violated in the first real out-of-repo deploy:
+
+- **No guessing.** No path arithmetic, no monorepo-depth inference, no absolute
+ deploy-machine path baked into an artifact. When the framework needs the app's
+ (possibly deep) location in a standalone tree, it **finds** it — locating
+ `server.js` — it does not compute it from an assumed depth.
+- **No laundering.** `node_modules` ships exactly as the build produced it. A
+ symlinked (non-hoisted) `node_modules` is a **hard error** at package time,
+ never dereferenced — the user's to fix (a hoisted linker: npm, or pnpm/bun
+ `node-linker=hoisted`), because that same non-flat install also crashes a Next
+ standalone server at boot.
+- **Code boundary, not runtime.** A plain `node()` service relies on the Compute
+ runtime's `bun` auto-install for the dynamic requires its bundler missed (e.g.
+ `pg/lib/*`); a `nextjs()` artifact *disables* auto-install (its `sharp` /
+ `@next/swc` optional deps would otherwise fetch linux binaries at boot and fill
+ the disk). That opposite need is why the `bunfig.toml` toggle lives in the
+ `nextjs()` adapter, not as a packager default.
+
+Everything lands in a deploy-owned working dir keyed by the node's **graph
+address** (`.prisma-compose/artifacts//`), never inside `node_modules`
+or the user's build output: the user's tree under `bundle/`, the wrapper at the
+root.
+
## Reasoning
-The framework plays no part in that step — not the bundler options, not the
+The framework plays no part in that build step — not the bundler options, not the
framework version, not whether it ran via a package script or a monorepo
tool's task graph. What the framework does happens *after*: given the
standalone tree the build produced, assembly copies in the pieces Next leaves
@@ -52,6 +76,16 @@ normalizations: copying files to make a standalone tree self-contained is
deterministic file-shuffling that belongs to the artifact, not to any
user-visible build step.
+The discipline is not "do nothing to the tree" but "do only the documented,
+deterministic thing, find don't compute, and reject anything that isn't a plain
+file." Guessing and laundering are the hazards it rules out: inferring a
+monorepo depth breaks on the next layout, and walking-and-dereferencing trees
+inherits every package manager's pathology and, worse, opens a security hole —
+a symlink escaping the repo (a compromised postinstall, or accident) would
+silently package deploy-machine files (`~/.aws`, ssh keys) into the artifact,
+and an absolute path baked into an artifact encodes the build machine's
+filesystem into what ships.
+
Assembly's other job is validation. Built output missing at the descriptor's
declared location fails loudly — an error naming the resolved path and saying
"run your build" — before anything is provisioned.
@@ -66,6 +100,10 @@ declared location fails loudly — an error naming the resolved path and saying
same contract.
- The build adapter descriptor declares *where* the user's build puts its
output, never *how* to produce it.
+- Any monorepo layout deploys — the app's deep location is found, not assumed;
+ a non-hoisted (symlinked) `node_modules` fails fast with an actionable error.
+- Deploy never writes into `node_modules` or the user's build output; staging is
+ deploy-owned, keyed by graph address.
- The wrapper bundle resolves the user's own dependencies (the service module
imports their client factories), so assembly's bundler invocation resolves
from the authoring module's directory — an internal burden accepted to keep
@@ -78,6 +116,16 @@ declared location fails loudly — an error naming the resolved path and saying
outputs is strictly simpler, and monorepo tools already own orchestration,
ordering, and caching. Nothing in the contract prevents adding an opt-in
invocation later; the boundary would not move.
+- **The framework does *no* tree completion; the user's build produces a fully
+ flat bundle and the adapter only wraps it** (a mid-design over-correction) —
+ rejected: it pushes Next's documented `cp` into every app as a hand-maintained
+ build script, worse ergonomics for zero safety gain. The real rule is no
+ *guessing*, not no *copying*.
+- **Infer the bundle location** (fixed monorepo depth, or glob-and-hope) —
+ rejected: inference is the root cause of the deploy failures; find `server.js`
+ deterministically instead.
+- **A packager-wide `bunfig` disabling auto-install** — rejected: node and Next
+ services have opposite auto-install needs; the toggle is adapter-specific.
- **The user's build produces the wrapper too** — honest about who bundles
what, but it leaks the boot protocol into every app's build config, and
framework-built apps (Next) would need a second build step bolted on.
@@ -89,7 +137,9 @@ declared location fails loudly — an error naming the resolved path and saying
## Related
- [`ADR-0004`](ADR-0004-paths-resolve-relative-to-the-authoring-file.md) —
- how assembly resolves the descriptor's paths.
+ how assembly resolves the descriptor's authoring-relative paths.
+- [`../01-principles/architectural-principles.md`](../01-principles/architectural-principles.md)
+ — "We don't bundle the app's code — and we don't guess" as a guiding principle.
- [`../10-domains/core-model.md`](../10-domains/core-model.md) — the
wrapper's role in boot (`run`/`load`).
- [`../10-domains/deploy-cli.md`](../10-domains/deploy-cli.md) — assembly's
diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md
index b6f64951..5e3ff02e 100644
--- a/docs/design/90-decisions/README.md
+++ b/docs/design/90-decisions/README.md
@@ -26,7 +26,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0003](ADR-0003-deploy-derives-everything-from-the-root-node.md) — `prisma-compose deploy` derives everything from the root node; there is no deploy config file.
- [ADR-0004](ADR-0004-paths-resolve-relative-to-the-authoring-file.md) — Paths resolve relative to the file that writes them; the build adapter carries the authoring module.
-- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — Users build their app; the framework assembles deploy artifacts from built output.
+- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — Users build the app's code; the framework assembles the artifact by documented, deterministic steps (validate, wrap, each app-type's documented deploy step — e.g. Next's static/public copy). No guessing (arithmetic/depth-inference/discovery), no laundering (symlink = hard error); find don't compute.
- [ADR-0006](ADR-0006-every-node-is-named.md) — Every node is named; the root's name names the application.
- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file.
- [ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md) — The boot wrapper inlines everything except runtime built-ins.
diff --git a/examples/cron/package.json b/examples/cron/package.json
index a2176a99..90bdc9ca 100644
--- a/examples/cron/package.json
+++ b/examples/cron/package.json
@@ -9,8 +9,8 @@
"test": "bun test tests",
"dev:worker": "bun run src/worker/server.ts",
"dev:runner": "bun run src/runner/server.ts",
- "deploy": "pnpm turbo run build --filter @prisma/example-cron... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${CRON_STACK_NAME:+--name \"$CRON_STACK_NAME\"} )",
- "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts ${CRON_STACK_NAME:+--name \"$CRON_STACK_NAME\"} )"
+ "deploy": "pnpm turbo run build --filter @prisma/example-cron... && ( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${CRON_STACK_NAME:+--name \"$CRON_STACK_NAME\"} )",
+ "destroy": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose destroy module.ts ${CRON_STACK_NAME:+--name \"$CRON_STACK_NAME\"} )"
},
"dependencies": {
"@effect/platform-bun": "4.0.0-beta.92",
diff --git a/examples/cron/tsdown.config.ts b/examples/cron/tsdown.config.ts
index 697efa6c..988dca63 100644
--- a/examples/cron/tsdown.config.ts
+++ b/examples/cron/tsdown.config.ts
@@ -1,33 +1,11 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
// The app's own build (ADR-0005): two SEPARATE builds, one per service, each
-// into its own dist/ subdir — not one multi-entry build, which would split
-// the code the two entries share (workerContract) into a chunk neither
-// entry's own subdir contains, and @prisma/compose/node's assemble() copies only
-// the entry file into the deployed bundle (ADR-0004). Each build here is
-// fully self-contained. `@prisma/*` and `arktype` are inlined (node_modules
-// isn't shipped); `bun` is a Compute runtime built-in.
-export default defineConfig([
- {
- entry: { server: 'src/worker/server.ts' },
- outDir: 'dist/worker',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^arktype/],
- dts: false,
- sourcemap: false,
- clean: true,
- },
- {
- entry: { server: 'src/runner/server.ts' },
- outDir: 'dist/runner',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^arktype/],
- dts: false,
- sourcemap: false,
- clean: true,
- },
-]);
+// into its own dist/ subdir — not one multi-entry build, which would split the
+// code the two entries share (workerContract) into a chunk neither entry's own
+// dist contains. `prismaTsDownConfig` makes each build self-contained (inline
+// everything except runtime built-ins).
+export default [
+ prismaTsDownConfig({ entry: { server: 'src/worker/server.ts' }, outDir: 'dist/worker' }),
+ prismaTsDownConfig({ entry: { server: 'src/runner/server.ts' }, outDir: 'dist/runner' }),
+];
diff --git a/examples/pn-widgets/package.json b/examples/pn-widgets/package.json
index ee339e03..15db4a55 100644
--- a/examples/pn-widgets/package.json
+++ b/examples/pn-widgets/package.json
@@ -6,8 +6,8 @@
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
- "deploy": "pnpm turbo run build --filter @prisma/example-pn-widgets... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${PN_WIDGETS_STACK_NAME:+--name \"$PN_WIDGETS_STACK_NAME\"} )",
- "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${PN_WIDGETS_STACK_NAME:+--name \"$PN_WIDGETS_STACK_NAME\"} )"
+ "deploy": "pnpm turbo run build --filter @prisma/example-pn-widgets... && ( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${PN_WIDGETS_STACK_NAME:+--name \"$PN_WIDGETS_STACK_NAME\"} )",
+ "destroy": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${PN_WIDGETS_STACK_NAME:+--name \"$PN_WIDGETS_STACK_NAME\"} )"
},
"dependencies": {
"@prisma-next/postgres": "0.14.0",
diff --git a/examples/pn-widgets/tsdown.config.ts b/examples/pn-widgets/tsdown.config.ts
index fd4c25e0..99c96206 100644
--- a/examples/pn-widgets/tsdown.config.ts
+++ b/examples/pn-widgets/tsdown.config.ts
@@ -1,20 +1,6 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
-// The app's own build (ADR-0005): only its runnable, src/server.ts, built to
-// dist/server.mjs. `prisma-compose deploy` assembles the wrapper (bootstrap +
-// main.js from service.ts) via @prisma/compose/node/control. node_modules isn't
-// shipped, so everything the server touches at runtime must be inlined:
-// @prisma/*, and — because the Prisma Next typed client rides node-postgres,
-// not a Compute built-in — @prisma-next/* and `pg` too. `bun` (Bun.serve) is a
-// Compute runtime built-in, left external.
-export default defineConfig({
- entry: { server: 'src/server.ts' },
- outDir: 'dist',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^@prisma-next\//, /^pg$/, /^pg-/, /^pathe$/],
- dts: false,
- sourcemap: false,
- clean: true,
-});
+// The app's own build (ADR-0005): a self-contained ESM bundle of its runnable.
+// `prismaTsDownConfig` inlines everything except runtime built-ins, so
+// node_modules is never shipped and boot never leans on bun auto-install.
+export default prismaTsDownConfig({ entry: { server: 'src/server.ts' } });
diff --git a/examples/scripts/cold-connect-canary-classify.test.ts b/examples/scripts/cold-connect-canary-classify.test.ts
index 69f51bde..2db1e110 100644
--- a/examples/scripts/cold-connect-canary-classify.test.ts
+++ b/examples/scripts/cold-connect-canary-classify.test.ts
@@ -1,61 +1,92 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
-import { classifyColdConnectResult } from './cold-connect-canary-classify.ts';
+import {
+ type ColdConnectSample,
+ classifyColdConnectRun,
+ classifyColdConnectSample,
+} from './cold-connect-canary-classify.ts';
-describe('classifyColdConnectResult', () => {
- it('fails when the connect succeeded (no error) — the platform bug looks fixed', () => {
- const result = classifyColdConnectResult(undefined);
- assert.equal(result.pass, false);
- assert.match(result.message, /FT-5226 fixed/);
+describe('classifyColdConnectSample', () => {
+ it('a successful connect (no error) → success', () => {
+ assert.equal(classifyColdConnectSample(undefined), 'success');
});
- it('passes for the PPG cold-start upstream reject message', () => {
- const result = classifyColdConnectResult(
- new Error('Failed to connect to upstream database. Please contact Prisma support'),
+ it('the PPG cold-start upstream reject message → rejected', () => {
+ assert.equal(
+ classifyColdConnectSample(
+ new Error('Failed to connect to upstream database. Please contact Prisma support'),
+ ),
+ 'rejected',
);
- assert.equal(result.pass, true);
});
- it('passes for active-rejection socket codes', () => {
+ it('active-rejection socket codes → rejected', () => {
for (const code of ['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ENOTFOUND', 'EAI_AGAIN']) {
- const result = classifyColdConnectResult(Object.assign(new Error('x'), { code }));
- assert.equal(result.pass, true, `expected ${code} to pass`);
+ assert.equal(
+ classifyColdConnectSample(Object.assign(new Error('x'), { code })),
+ 'rejected',
+ code,
+ );
}
});
- it('passes for pool/server-close rejection messages', () => {
+ it('pool/server-close rejection messages → rejected', () => {
for (const message of [
'Connection terminated unexpectedly',
'connection refused',
'terminating connection due to administrator command',
'server closed the connection unexpectedly',
]) {
- const result = classifyColdConnectResult(new Error(message));
- assert.equal(result.pass, true, `expected "${message}" to pass`);
+ assert.equal(classifyColdConnectSample(new Error(message)), 'rejected', message);
}
});
- it('fails as inconclusive on connect timeouts — a timeout is not an active rejection', () => {
+ it('connect timeouts → timeout (not an active rejection)', () => {
for (const error of [
new Error('timeout expired'),
new Error('Connection timeout'),
Object.assign(new Error('x'), { code: 'ETIMEDOUT' }),
]) {
- const result = classifyColdConnectResult(error);
- assert.equal(result.pass, false);
- assert.match(result.message, /Inconclusive/);
+ assert.equal(classifyColdConnectSample(error), 'timeout');
}
});
- it('fails on a non-transient error, with a message distinguishing it from a fixed platform', () => {
- const result = classifyColdConnectResult(new Error('password authentication failed for user'));
+ it('auth/quota errors → other (not assumed transient)', () => {
+ assert.equal(
+ classifyColdConnectSample(new Error('password authentication failed for user')),
+ 'other',
+ );
+ assert.equal(classifyColdConnectSample(new Error('quota exceeded')), 'other');
+ });
+});
+
+describe('classifyColdConnectRun (unanimity)', () => {
+ const run = (...s: ColdConnectSample[]) => classifyColdConnectRun(s);
+
+ it('ANY rejection → PASS, even amid successes (a single rejection proves the bug)', () => {
+ const result = run('success', 'success', 'rejected', 'success', 'success');
+ assert.equal(result.pass, true);
+ assert.match(result.message, /still present \(1\/5 rejected\)/);
+ });
+
+ it('ALL successes → FAIL with the remove-the-workaround signal', () => {
+ const result = run('success', 'success', 'success', 'success', 'success');
assert.equal(result.pass, false);
- assert.match(result.message, /not the known cold-start rejection/);
+ assert.match(result.message, /FT-5226 fixed/);
});
- it('fails on an unrecognized error rather than assuming it is transient', () => {
- const result = classifyColdConnectResult(new Error('quota exceeded'));
+ it('no rejections but not all-success (timeouts) → FAIL inconclusive, not "fixed"', () => {
+ const result = run('success', 'timeout', 'success', 'timeout', 'success');
assert.equal(result.pass, false);
+ assert.match(result.message, /Inconclusive/);
+ });
+
+ it('a lone success does not flip a rejecting run to "fixed"', () => {
+ assert.equal(run('rejected', 'rejected', 'success').pass, true);
+ });
+
+ it('zero samples → FAIL (broken canary)', () => {
+ assert.equal(classifyColdConnectRun([]).pass, false);
});
});
diff --git a/examples/scripts/cold-connect-canary-classify.ts b/examples/scripts/cold-connect-canary-classify.ts
index 3cfca774..19b0a487 100644
--- a/examples/scripts/cold-connect-canary-classify.ts
+++ b/examples/scripts/cold-connect-canary-classify.ts
@@ -3,6 +3,11 @@
* testing. Duplicates the transient-error signatures from
* packages/compose-cloud/src/pg-connection.ts (not exported from that package's
* public entry points) — keep in sync if that list changes.
+ *
+ * FT-5226 (PPg cold-connect rejection) is INTERMITTENT — the edge proxy rejects
+ * a cold DB's first connect while its upstream warms, but a fast-enough connect
+ * occasionally slips through. So one connect can't tell "fixed" from "got lucky
+ * once": the canary samples N fresh cold DBs and only trusts a UNANIMOUS result.
*/
const TRANSIENT_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ENOTFOUND', 'EAI_AGAIN']);
@@ -43,38 +48,51 @@ function isInconclusive(error: unknown): boolean {
return INCONCLUSIVE_MESSAGE_FRAGMENTS.some((fragment) => lower.includes(fragment));
}
+/** One cold-connect attempt's outcome. `rejected` is the FT-5226 signal; `success` means the connect went through; `timeout`/`other` are inconclusive. */
+export type ColdConnectSample = 'rejected' | 'success' | 'timeout' | 'other';
+
+/** Classifies a single bare-connect result: no error → success; active transient reject → rejected (FT-5226); connect timeout → timeout; anything else (auth, quota) → other. */
+export function classifyColdConnectSample(error: unknown): ColdConnectSample {
+ if (error === undefined) return 'success';
+ if (isInconclusive(error)) return 'timeout';
+ if (isTransient(error)) return 'rejected';
+ return 'other';
+}
+
export interface ColdConnectResult {
readonly pass: boolean;
readonly message: string;
}
/**
- * Classifies the canary's single bare connect attempt: success → FAIL (bug looks
- * fixed), active rejection → PASS (bug present), timeout → FAIL (inconclusive),
- * anything else (auth, quota) → FAIL (broken canary, not a fixed platform).
+ * Aggregates N cold-connect samples with UNANIMITY, so one flaky connect can't
+ * flip the verdict:
+ * - any active rejection → PASS (bug present — a single rejection proves it),
+ * - all N succeeded → FAIL (FT-5226 looks gone; remove the workaround),
+ * - otherwise (timeouts / odd errors, no rejection but not all-success) → FAIL
+ * inconclusive (slow cold start, or a broken canary — a human should look).
*/
-export function classifyColdConnectResult(error: unknown): ColdConnectResult {
- if (error === undefined) {
+export function classifyColdConnectRun(samples: readonly ColdConnectSample[]): ColdConnectResult {
+ const n = samples.length;
+ if (n === 0) return { pass: false, message: 'Canary took no samples — broken.' };
+ const count = (s: ColdConnectSample) => samples.filter((x) => x === s).length;
+ const rejected = count('rejected');
+ const success = count('success');
+
+ if (rejected > 0) {
return {
- pass: false,
- message:
- 'PPG cold-connect rejection is gone (FT-5226 fixed?) — remove withConnectionRetry and this canary.',
+ pass: true,
+ message: `Cold-connect rejection still present (${rejected}/${n} rejected) — FT-5226 not fixed; keep withConnectionRetry.`,
};
}
- if (isInconclusive(error)) {
- const { message } = errorInfo(error);
+ if (success === n) {
return {
pass: false,
- message: `Inconclusive: the connect timed out instead of being actively rejected — FT-5226 may be fixed (slow cold start), or the canary is broken: ${message}`,
+ message: `All ${n} cold connects succeeded — PPG cold-connect rejection is gone (FT-5226 fixed?). Remove withConnectionRetry and this canary.`,
};
}
- if (isTransient(error)) {
- const { message } = errorInfo(error);
- return { pass: true, message: `Cold-connect rejection still present: ${message}` };
- }
- const { message } = errorInfo(error);
return {
pass: false,
- message: `Canary got a non-transient error, not the known cold-start rejection — canary or credentials are broken, not FT-5226: ${message}`,
+ message: `Inconclusive across ${n} samples (${success} ok, ${count('timeout')} timeout, ${count('other')} other, 0 active rejections) — FT-5226 may be fixed via a slow cold start, or the canary/credentials are broken. Investigate before removing the workaround.`,
};
}
diff --git a/examples/scripts/cold-connect-canary.ts b/examples/scripts/cold-connect-canary.ts
index cf401b38..83b9e31e 100644
--- a/examples/scripts/cold-connect-canary.ts
+++ b/examples/scripts/cold-connect-canary.ts
@@ -1,18 +1,26 @@
#!/usr/bin/env bun
/**
- * Canary for FT-5226 (PPg cold-connect rejection). Provisions a fresh
- * project/database/connection, makes ONE bare `pg` connect with no retry,
- * and asserts it fails the known transient way. Passes only while the
- * platform bug is still present — the point is for this to start FAILING
- * the day FT-5226 is fixed, so `withConnectionRetry` (packages/compose-cloud/src
- * /pg-connection.ts) can be removed.
+ * Canary for FT-5226 (PPg cold-connect rejection). Provisions a fresh project
+ * and SAMPLES several fresh cold databases: each makes ONE bare `pg` connect
+ * with no retry. FT-5226 is intermittent (the edge proxy rejects a cold DB's
+ * first connect while its upstream warms, but a fast connect occasionally slips
+ * through), so a single connect can't tell "fixed" from "got lucky once". The
+ * run is judged unanimously (see classifyColdConnectRun): any active rejection
+ * → PASS (bug still present); ALL samples succeeding → FAIL, the signal to
+ * remove `withConnectionRetry` (packages/compose-cloud/src/pg-connection.ts) and
+ * this canary.
*/
import pg from 'pg';
import { deleteProjectDeep, type HttpCall, type ProjectRef } from './ci-cleanup-utils.ts';
-import { classifyColdConnectResult } from './cold-connect-canary-classify.ts';
+import {
+ type ColdConnectSample,
+ classifyColdConnectRun,
+ classifyColdConnectSample,
+} from './cold-connect-canary-classify.ts';
const API = 'https://api.prisma.io/v1';
const REGION = 'us-east-1';
+const SAMPLES = Number(process.env['COLD_CONNECT_SAMPLES'] ?? '5');
const token = process.env['PRISMA_SERVICE_TOKEN'];
const workspaceId = process.env['PRISMA_WORKSPACE_ID'];
@@ -68,46 +76,31 @@ const http: HttpCall = async (method, path) => {
return { status: res.status, ok: res.ok, body: await res.text() };
};
-let project: ProjectRef | undefined;
-
function connectionStringOf(endpoint: unknown): string | undefined {
if (!isRecord(endpoint)) return undefined;
const value = endpoint['connectionString'];
return typeof value === 'string' ? value : undefined;
}
-try {
- const createdProject = await apiData('POST', '/projects', {
- name: projectName,
- workspaceId,
- });
- project = {
- id: requireString(createdProject, 'id'),
- name: requireString(createdProject, 'name'),
- };
- console.log(`Created project "${project.name}" (${project.id})`);
-
- const createdDb = await apiData('POST', `/projects/${project.id}/databases`, {
- name: 'canary',
+/** Provisions one fresh cold database under `projectId` and returns its first-connect outcome. */
+async function sampleColdConnect(projectId: string, index: number): Promise {
+ const createdDb = await apiData('POST', `/projects/${projectId}/databases`, {
+ name: `probe${index}`,
region: REGION,
});
const databaseId = requireString(createdDb, 'id');
- console.log(`Created database ${databaseId}`);
-
const createdConn = await apiData('POST', `/databases/${databaseId}/connections`, {
name: 'canary',
});
- const connectionId = requireString(createdConn, 'id');
const endpoints = createdConn['endpoints'];
const dsn =
connectionStringOf(isRecord(endpoints) ? endpoints['direct'] : undefined) ??
connectionStringOf(isRecord(endpoints) ? endpoints['pooled'] : undefined);
- if (!dsn) {
- throw new Error(`connection ${connectionId} returned no direct/pooled connection string`);
- }
+ if (!dsn) throw new Error('connection returned no direct/pooled connection string');
const client = new pg.Client({ connectionString: dsn, connectionTimeoutMillis: 10_000 });
let connectError: unknown;
+ const started = Date.now();
try {
await client.connect();
await client.query('select 1');
@@ -120,8 +113,31 @@ try {
// already dead
}
}
+ const sample = classifyColdConnectSample(connectError);
+ const detail = connectError instanceof Error ? ` — ${connectError.message}` : '';
+ console.log(` sample #${index}: ${sample} (${Date.now() - started}ms)${detail}`);
+ return sample;
+}
+
+let project: ProjectRef | undefined;
+
+try {
+ const createdProject = await apiData('POST', '/projects', {
+ name: projectName,
+ workspaceId,
+ });
+ project = {
+ id: requireString(createdProject, 'id'),
+ name: requireString(createdProject, 'name'),
+ };
+ console.log(`Created project "${project.name}" (${project.id}); sampling ${SAMPLES} cold DBs…`);
+
+ const samples: ColdConnectSample[] = [];
+ for (let i = 0; i < SAMPLES; i++) {
+ samples.push(await sampleColdConnect(project.id, i));
+ }
- const result = classifyColdConnectResult(connectError);
+ const result = classifyColdConnectRun(samples);
console.log(result.message);
process.exitCode = result.pass ? 0 : 1;
} finally {
diff --git a/examples/store/modules/catalog/tsdown.config.ts b/examples/store/modules/catalog/tsdown.config.ts
index 2e429471..99c96206 100644
--- a/examples/store/modules/catalog/tsdown.config.ts
+++ b/examples/store/modules/catalog/tsdown.config.ts
@@ -1,15 +1,6 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
-// Build only the runnable (src/server.ts). @prisma/* and arktype are inlined —
-// node_modules isn't shipped; `bun` is a Compute runtime built-in.
-export default defineConfig({
- entry: { server: 'src/server.ts' },
- outDir: 'dist',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^@prisma-next\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/],
- dts: false,
- sourcemap: false,
- clean: true,
-});
+// The app's own build (ADR-0005): a self-contained ESM bundle of its runnable.
+// `prismaTsDownConfig` inlines everything except runtime built-ins, so
+// node_modules is never shipped and boot never leans on bun auto-install.
+export default prismaTsDownConfig({ entry: { server: 'src/server.ts' } });
diff --git a/examples/store/modules/orders/tsdown.config.ts b/examples/store/modules/orders/tsdown.config.ts
index aa55554f..99c96206 100644
--- a/examples/store/modules/orders/tsdown.config.ts
+++ b/examples/store/modules/orders/tsdown.config.ts
@@ -1,15 +1,6 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
-// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are
-// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in.
-export default defineConfig({
- entry: { server: 'src/server.ts' },
- outDir: 'dist',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^@prisma-next\//, /^@store\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/],
- dts: false,
- sourcemap: false,
- clean: true,
-});
+// The app's own build (ADR-0005): a self-contained ESM bundle of its runnable.
+// `prismaTsDownConfig` inlines everything except runtime built-ins, so
+// node_modules is never shipped and boot never leans on bun auto-install.
+export default prismaTsDownConfig({ entry: { server: 'src/server.ts' } });
diff --git a/examples/store/modules/promotions/tsdown.config.ts b/examples/store/modules/promotions/tsdown.config.ts
index c0325ced..99c96206 100644
--- a/examples/store/modules/promotions/tsdown.config.ts
+++ b/examples/store/modules/promotions/tsdown.config.ts
@@ -1,15 +1,6 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
-// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are
-// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in.
-export default defineConfig({
- entry: { server: 'src/server.ts' },
- outDir: 'dist',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^@store\//, /^arktype/],
- dts: false,
- sourcemap: false,
- clean: true,
-});
+// The app's own build (ADR-0005): a self-contained ESM bundle of its runnable.
+// `prismaTsDownConfig` inlines everything except runtime built-ins, so
+// node_modules is never shipped and boot never leans on bun auto-install.
+export default prismaTsDownConfig({ entry: { server: 'src/server.ts' } });
diff --git a/examples/store/modules/storefront/src/service.ts b/examples/store/modules/storefront/src/service.ts
index 50428cc3..a1dbe106 100644
--- a/examples/store/modules/storefront/src/service.ts
+++ b/examples/store/modules/storefront/src/service.ts
@@ -13,5 +13,5 @@ export default compute({
catalog: rpc(catalogContract),
orders: rpc(ordersContract),
},
- build: nextjs({ module: import.meta.url, appDir: '..', entry: 'server.js' }),
+ build: nextjs({ module: import.meta.url, appDir: '..' }),
});
diff --git a/examples/storefront-auth/modules/auth/tsdown.config.ts b/examples/storefront-auth/modules/auth/tsdown.config.ts
index 6af38fa2..99c96206 100644
--- a/examples/storefront-auth/modules/auth/tsdown.config.ts
+++ b/examples/storefront-auth/modules/auth/tsdown.config.ts
@@ -1,19 +1,6 @@
-import { defineConfig } from 'tsdown';
+import { prismaTsDownConfig } from '@prisma/compose/tsdown';
-// The app's own build (ADR-0005): only its runnable, src/server.ts, built to
-// dist/server.mjs. The Prisma App wrapper (bundling src/service.ts to main.js,
-// as an independent module instance) is no longer built here — `prisma-compose
-// deploy` assembles it via `@prisma/compose/node/control`. @prisma/* and arktype
-// (the contract evaluates type() at import time) are inlined (node_modules
-// isn't shipped); `bun` is a Compute runtime built-in.
-export default defineConfig({
- entry: { server: 'src/server.ts' },
- outDir: 'dist',
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- noExternal: [/^@prisma\//, /^arktype/],
- dts: false,
- sourcemap: false,
- clean: true,
-});
+// The app's own build (ADR-0005): a self-contained ESM bundle of its runnable.
+// `prismaTsDownConfig` inlines everything except runtime built-ins, so
+// node_modules is never shipped and boot never leans on bun auto-install.
+export default prismaTsDownConfig({ entry: { server: 'src/server.ts' } });
diff --git a/examples/storefront-auth/modules/storefront/app/page.integration.test.ts b/examples/storefront-auth/modules/storefront/app/page.integration.test.ts
index 71bd4193..7ad4e5e4 100644
--- a/examples/storefront-auth/modules/storefront/app/page.integration.test.ts
+++ b/examples/storefront-auth/modules/storefront/app/page.integration.test.ts
@@ -8,20 +8,19 @@
* the loopback fake, and the H3 teardown decision (no `close()`) rests on
* bun-test's per-file process isolation.
*
- * storefront's build adapter is `nextjs()`: its `entry` is a bare filename
- * inside Next's standalone OUTPUT directory, not a path relative to
- * `build.module` — `bootstrapService`'s default derivation fits the `node`
- * adapter (see auth), not this one, so this test supplies its own boot
- * thunk, reusing the same standalone-path math
- * `@prisma/compose/nextjs/control`'s deploy assembly uses (`nextStandaloneDir`).
- * Requires `next build` to have already produced `.next/standalone` (turbo's
- * `test` task depends on `build`, so `pnpm -w test` always has it).
+ * storefront's build is `nextjs({ appDir })`: the deploy assembler locates the
+ * built `server.js` inside the standalone tree, so this test boots it via the
+ * same seam (`standaloneServerPath`) rather than re-deriving the path. The
+ * deploy chain is bootstrap.js -> main.mjs -> server.js; here
+ * `bootstrapService`'s `stash` stands in for the wrapper's env write. Requires
+ * `next build` to have produced `.next/standalone` (turbo's `test` task depends
+ * on `build`, so `pnpm -w test` always has it).
*/
import { describe, expect, it } from 'bun:test';
import { pathToFileURL } from 'node:url';
import type { BuildAdapter } from '@prisma/compose';
import type { NextjsBuildAdapter } from '@prisma/compose/nextjs';
-import { standaloneEntryPath } from '@prisma/compose/nextjs/control';
+import { standaloneServerPath } from '@prisma/compose/nextjs/control';
import { bootstrapService } from '@prisma/compose-prisma-cloud/testing';
import fakeAuthHandler from '@storefront-auth/auth/fake';
import storefrontService from '../src/service.ts';
@@ -32,9 +31,9 @@ function isNextjsBuild(build: BuildAdapter): build is NextjsBuildAdapter {
return build.type === 'nextjs' && 'appDir' in build && typeof build.appDir === 'string';
}
-/** Boots the built standalone Next entry — its own `server.js`, unmodified — via the same path `assemble()` resolves for deploy (not the same bootstrap chain: deploy goes bootstrap.js -> main.mjs -> server.js; here `bootstrapService`'s `stash` stands in for the wrapper's env write). */
+/** Boots the built standalone Next entry — its own `server.js`, unmodified — via the same seam `assemble()` uses to locate it. The deploy chain is bootstrap.js -> main.mjs -> server.js; here `bootstrapService`'s `stash` stands in for the wrapper's env write. */
function bootStandaloneNext(build: NextjsBuildAdapter, port: number): () => Promise {
- const entryPath = standaloneEntryPath(build);
+ const entryPath = standaloneServerPath(build);
return async () => {
// Next's standalone server binds `process.env.PORT` directly (its own
// convention) — it does NOT read the service's config(). The framework's
diff --git a/examples/storefront-auth/modules/storefront/src/service.ts b/examples/storefront-auth/modules/storefront/src/service.ts
index ecc6767c..9707c68b 100644
--- a/examples/storefront-auth/modules/storefront/src/service.ts
+++ b/examples/storefront-auth/modules/storefront/src/service.ts
@@ -6,5 +6,8 @@ import { authContract } from '@storefront-auth/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
- build: nextjs({ module: import.meta.url, appDir: '..', entry: 'server.js' }),
+ // `appDir` is the Next app root; `next build` (output: standalone) is all the
+ // app does — deploy assembly copies the standalone tree and the static/public
+ // assets Next omits, and locates server.js itself.
+ build: nextjs({ module: import.meta.url, appDir: '..' }),
});
diff --git a/examples/storefront-auth/package.json b/examples/storefront-auth/package.json
index de2b0c9c..fe461561 100644
--- a/examples/storefront-auth/package.json
+++ b/examples/storefront-auth/package.json
@@ -6,8 +6,8 @@
"scripts": {
"build": "echo 'nothing to build for this package itself \u2014 @storefront-auth/auth and @storefront-auth/storefront build via turbo ^build, from the workspace deps below'",
"typecheck": "tsc --noEmit",
- "deploy": "pnpm turbo run build --filter @prisma/example-storefront-auth... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )",
- "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )",
+ "deploy": "pnpm turbo run build --filter @prisma/example-storefront-auth... && ( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )",
+ "destroy": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STOREFRONT_STACK_NAME:+--name \"$STOREFRONT_STACK_NAME\"} )",
"test": "bun test module.test.ts"
},
"dependencies": {
diff --git a/packages/0-framework/1-core/core/src/deploy.ts b/packages/0-framework/1-core/core/src/deploy.ts
index d73ed716..f5ce3b1c 100644
--- a/packages/0-framework/1-core/core/src/deploy.ts
+++ b/packages/0-framework/1-core/core/src/deploy.ts
@@ -109,6 +109,10 @@ export interface AssembleInput {
readonly build: BuildAdapter;
/** Extra patterns to inline into the wrapper besides `@prisma/compose*` (e.g. the app's own workspace packages). */
readonly wrapperNoExternal?: readonly RegExp[];
+ /** The service's graph address (e.g. "storefront.web"). Unique per service, so the assembler uses it to name this service's own working directory: `/.prisma-compose/artifacts//`. */
+ readonly address: string;
+ /** The directory the deploy command was run from. The assembler puts its working directory under it (`/.prisma-compose/`), the same place the CLI writes its other generated files. */
+ readonly cwd: string;
}
/** package()'s product. */
diff --git a/packages/0-framework/1-core/core/src/node.ts b/packages/0-framework/1-core/core/src/node.ts
index cd3dd558..084ffb08 100644
--- a/packages/0-framework/1-core/core/src/node.ts
+++ b/packages/0-framework/1-core/core/src/node.ts
@@ -79,11 +79,7 @@ export interface BuildAdapter {
readonly type: string;
/** The authoring module's `import.meta.url` — every other path on this descriptor resolves relative to `dirname(module)`. */
readonly module: string;
- /**
- * The app's built runnable, resolved relative to `dirname(module)` and
- * interpreted by the type's build descriptor (e.g. "node": a server file path;
- * "nextjs": a filename inside the standalone output dir).
- */
+ /** The app's built runnable, resolved relative to `dirname(module)` and interpreted by the type's build descriptor (e.g. "node": a server file; "nextjs": located in the standalone tree). */
readonly entry: string;
}
diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts
index 97d13038..70555e9a 100644
--- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts
+++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts
@@ -3,24 +3,46 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
-import { assemble, nextStandaloneDir } from '../control.ts';
+import { assemble, standaloneServerPath } from '../control.ts';
import nextjs from '../index.ts';
const tmpDirs: string[] = [];
-/** A 4-levels-deep app dir under a fresh tmp root — mirrors the monorepo
- * layout nextStandaloneDir assumes (workspaceRoot = 4 levels up). */
-function makeAppDir(): string {
+/** A fresh tmp root standing in for a Next app: src/service.ts, .next/standalone/, .next/static, public. */
+function makeAppRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-compose-nextjs-assemble-'));
tmpDirs.push(root);
- const appDir = path.join(root, 'a', 'b', 'c', 'app');
- fs.mkdirSync(path.join(appDir, 'src'), { recursive: true });
- return appDir;
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
+ return root;
}
-/** The authoring module's import.meta.url for an app dir's src/service.ts. */
-function moduleUrl(appDir: string): string {
- return pathToFileURL(path.join(appDir, 'src', 'service.ts')).href;
+function moduleUrl(root: string): string {
+ return pathToFileURL(path.join(root, 'src', 'service.ts')).href;
+}
+
+/**
+ * Writes a `next build` standalone tree with the app nested at `apps/web` (the
+ * monorepo shape — `outputFileTracingRoot` above the app) plus the client assets
+ * Next omits: `.next/static` and `public/` live at the app root, NOT in
+ * standalone. Returns the deep app-relative path.
+ */
+function writeNextBuild(root: string): { appRel: string } {
+ const standalone = path.join(root, '.next', 'standalone');
+ const appOut = path.join(standalone, 'apps', 'web');
+ fs.mkdirSync(appOut, { recursive: true });
+ fs.writeFileSync(path.join(appOut, 'server.js'), '// standalone server\n');
+ fs.mkdirSync(path.join(standalone, 'node_modules', 'next'), { recursive: true });
+ fs.writeFileSync(path.join(standalone, 'node_modules', 'next', 'marker.txt'), 'next\n');
+ // Client assets — omitted from standalone by Next, at the app root.
+ fs.mkdirSync(path.join(root, '.next', 'static'), { recursive: true });
+ fs.writeFileSync(path.join(root, '.next', 'static', 'chunk.js'), '// static asset\n');
+ fs.mkdirSync(path.join(root, 'public'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'public', 'favicon.ico'), 'icon\n');
+ fs.writeFileSync(
+ path.join(root, 'src', 'service.ts'),
+ 'export default { hello: "wrap" as const };\n',
+ );
+ return { appRel: path.join('apps', 'web') };
}
afterEach(() => {
@@ -32,75 +54,66 @@ afterEach(() => {
describe('assemble()', () => {
test('rejects a non-nextjs build adapter', async () => {
- const appDir = makeAppDir();
+ const root = makeAppRoot();
await expect(
assemble({
- // A "node" build reaching here at all would only happen through the
- // untyped registry seam (the config routes by (extension, type)
- // before calling in) — forced here to exercise the runtime guard.
+ address: 'web',
+ cwd: root,
build: {
extension: '@prisma/compose/node',
type: 'node',
- module: moduleUrl(appDir),
+ module: moduleUrl(root),
entry: 'server.js',
},
}),
).rejects.toThrow(/expected a "nextjs" build adapter/);
});
- test('rejects when the standalone server.js is missing — names the expected path', async () => {
- const appDir = makeAppDir();
+ test('rejects when there is no standalone build — says run next build', async () => {
+ const root = makeAppRoot();
await expect(
assemble({
- build: nextjs({ module: moduleUrl(appDir), appDir: '..', entry: 'server.js' }),
+ address: 'web',
+ cwd: root,
+ build: nextjs({ module: moduleUrl(root), appDir: '..' }),
}),
- ).rejects.toThrow(/no standalone server\.js at .* run `next build`/);
+ ).rejects.toThrow(/no .*standalone under .* run `next build`/);
});
- test('copies static/public/node_modules, writes bunfig.toml, and bundles the wrapper', async () => {
- const appDir = makeAppDir();
- const standaloneDir = nextStandaloneDir(appDir);
- fs.mkdirSync(standaloneDir, { recursive: true });
- fs.writeFileSync(path.join(standaloneDir, 'server.js'), '// standalone server\n');
-
- // Next's hoisted root node_modules, shared by every app in the standalone tree.
- const standaloneRoot = path.join(appDir, '.next', 'standalone');
- fs.mkdirSync(path.join(standaloneRoot, 'node_modules', 'next'), { recursive: true });
- fs.writeFileSync(path.join(standaloneRoot, 'node_modules', 'next', 'marker.txt'), 'next\n');
-
- fs.mkdirSync(path.join(appDir, '.next', 'static'), { recursive: true });
- fs.writeFileSync(path.join(appDir, '.next', 'static', 'chunk.js'), '// static asset\n');
- fs.mkdirSync(path.join(appDir, 'public'), { recursive: true });
- fs.writeFileSync(path.join(appDir, 'public', 'favicon.ico'), 'icon\n');
-
- fs.writeFileSync(
- path.join(appDir, 'src', 'service.ts'),
- 'export default { hello: "wrapper" as const };\n',
- );
+ test('ships the standalone tree, copies static/public to the located app dir, main.mjs at root', async () => {
+ const root = makeAppRoot();
+ const { appRel } = writeNextBuild(root);
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-compose-nextjs-cwd-'));
+ tmpDirs.push(cwd);
const result = await assemble({
- build: nextjs({ module: moduleUrl(appDir), appDir: '..', entry: 'server.js' }),
+ address: 'storefront.web',
+ cwd,
+ build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});
- expect(result.dir).toBe(standaloneDir);
- expect(result.entry).toBe('server.js');
- expect(fs.existsSync(path.join(standaloneDir, 'main.mjs'))).toBe(true);
- expect(fs.existsSync(path.join(standaloneDir, 'node_modules', 'next', 'marker.txt'))).toBe(
+ const workDir = path.join(cwd, '.prisma-compose', 'artifacts', 'storefront.web');
+ const bundleApp = path.join(workDir, 'bundle', appRel);
+ expect(result.dir).toBe(workDir);
+ // The deep server path was FOUND, not authored, and prefixed with bundle/.
+ expect(result.entry).toBe('bundle/apps/web/server.js');
+ expect(fs.existsSync(path.join(workDir, 'main.mjs'))).toBe(true);
+ // Standalone tree shipped (incl. the hoisted node_modules at its root).
+ expect(fs.existsSync(path.join(bundleApp, 'server.js'))).toBe(true);
+ expect(fs.existsSync(path.join(workDir, 'bundle', 'node_modules', 'next', 'marker.txt'))).toBe(
true,
);
- expect(fs.existsSync(path.join(standaloneDir, '.next', 'static', 'chunk.js'))).toBe(true);
- expect(fs.existsSync(path.join(standaloneDir, 'public', 'favicon.ico'))).toBe(true);
- expect(fs.readFileSync(path.join(standaloneDir, 'bunfig.toml'), 'utf8')).toContain(
- 'auto = "disable"',
- );
+ // The documented copy: static + public placed beside the app's server.js.
+ expect(fs.existsSync(path.join(bundleApp, '.next', 'static', 'chunk.js'))).toBe(true);
+ expect(fs.existsSync(path.join(bundleApp, 'public', 'favicon.ico'))).toBe(true);
+ // We never wrote into the user's build output.
+ expect(fs.existsSync(path.join(root, '.next', 'standalone', 'main.mjs'))).toBe(false);
}, 20_000);
-});
-describe('nextStandaloneDir()', () => {
- test('nests the app dir path under .next/standalone, pinned to the 4-levels-up workspace root', () => {
- const appDir = makeAppDir();
- const dir = nextStandaloneDir(appDir);
- expect(dir.startsWith(path.join(appDir, '.next', 'standalone'))).toBe(true);
- expect(dir.endsWith(path.join('a', 'b', 'c', 'app'))).toBe(true);
+ test('standaloneServerPath locates the app server.js (the integration-test seam)', () => {
+ const root = makeAppRoot();
+ writeNextBuild(root);
+ const server = standaloneServerPath(nextjs({ module: moduleUrl(root), appDir: '..' }));
+ expect(server).toBe(path.join(root, '.next', 'standalone', 'apps', 'web', 'server.js'));
});
});
diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/nextjs.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/nextjs.test.ts
index e659e332..705e4911 100644
--- a/packages/0-framework/2-authoring/nextjs/src/__tests__/nextjs.test.ts
+++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/nextjs.test.ts
@@ -1,11 +1,9 @@
import { describe, expect, test } from 'bun:test';
import nextjs from '../index.ts';
-describe('nextjs({ module, appDir, entry })', () => {
+describe('nextjs({ module, appDir })', () => {
test('returns a plain { extension, type, module, appDir, entry } build adapter descriptor', () => {
- expect(
- nextjs({ module: 'file:///app/src/service.ts', appDir: '..', entry: 'server.js' }),
- ).toEqual({
+ expect(nextjs({ module: 'file:///app/src/service.ts', appDir: '..' })).toEqual({
extension: '@prisma/compose/nextjs',
type: 'nextjs',
module: 'file:///app/src/service.ts',
@@ -14,19 +12,9 @@ describe('nextjs({ module, appDir, entry })', () => {
});
});
- test("carries the entry through unmodified — Next's standalone server.js, resolved inside the assembled standalone dir", () => {
- expect(
- nextjs({
- module: 'file:///app/src/service.ts',
- appDir: '..',
- entry: '.next/standalone/server.js',
- }).entry,
- ).toBe('.next/standalone/server.js');
- });
-
test('is pure data — calling it twice with the same input yields equal, independent objects', () => {
- const a = nextjs({ module: 'file:///app/src/service.ts', appDir: '..', entry: 'server.js' });
- const b = nextjs({ module: 'file:///app/src/service.ts', appDir: '..', entry: 'server.js' });
+ const a = nextjs({ module: 'file:///app/src/service.ts', appDir: '..' });
+ const b = nextjs({ module: 'file:///app/src/service.ts', appDir: '..' });
expect(a).toEqual(b);
expect(a).not.toBe(b);
diff --git a/packages/0-framework/2-authoring/nextjs/src/control.ts b/packages/0-framework/2-authoring/nextjs/src/control.ts
index 8317fcda..69b24922 100644
--- a/packages/0-framework/2-authoring/nextjs/src/control.ts
+++ b/packages/0-framework/2-authoring/nextjs/src/control.ts
@@ -1,33 +1,29 @@
/**
- * The extension's control entry (ADR-0017): `nextjsBuild()` returns the
- * descriptor `prisma-compose.config.ts` lists — one build control, node ID
- * "nextjs". Deploy-only (ADR-0005): assembles a Next.js `output:
- * "standalone"` build into the bundle dir packaged at deploy. Run `next
- * build` first. The heavy tsdown import lives only here, never in the
- * authoring entry.
+ * The extension's control entry (ADR-0017): `nextjsBuild()` returns the build
+ * descriptor `prisma-compose.config.ts` lists. Deploy-only (ADR-0005): the user
+ * runs `next build` (`output: "standalone"`); `assemble` then performs the
+ * *documented* Next standalone deploy — it ships the standalone tree and copies
+ * in the client assets Next deliberately omits (`.next/static`, `public/`) — and
+ * adds the framework's boot wrapper. This is the canonical `cp` step from the
+ * Next docs, run at deploy so no app needs a build-script for it.
*
- * Next's standalone tree omits the static assets and `public/`, so this
- * copies them in. The bundle entry is NOT server.js directly: the app's
- * Prisma App wrapper (the service module — declarations only, whose node
- * carries its own run()) is bundled to `main.mjs` next to server.js, so the
- * relative import resolves inside the artifact and the Prisma App boot loop
- * runs first (bootstrap.js imports main.mjs, then dynamically imports
- * server.js — see @prisma/compose/deploy's PackageInput).
+ * It does not guess: the app's location inside the standalone tree (deep, when
+ * `outputFileTracingRoot` is the monorepo root) is *found* by locating
+ * `server.js`, never computed from a hardcoded depth. It does not launder:
+ * node_modules is shipped exactly as `next build` produced it, so a symlinked
+ * (non-hoisted) node_modules is the packager's hard error — the same
+ * misconfiguration crashes the standalone server at boot, so it must be a flat
+ * install (npm, or pnpm/bun with a hoisted node-linker).
*
- * Prisma App ships no build step, but it does own the artifact envelope —
- * bootstrap.js + compute.manifest.json + the deterministic tar are printed
- * by the platform extension's `package()` at deploy, not here.
+ * Artifact layout: `/main.mjs` (our wrapper) + `/bundle/`
+ * (the standalone tree, with static/public copied in). The packager adds
+ * `bootstrap.js` + the manifest at the root; bootstrap imports main.mjs, whose
+ * run() dynamically imports `./bundle/`.
*
- * Requires a hoisted node_modules (see the repo `.npmrc`): pnpm's default
- * isolated layout hides Next's peers (e.g. styled-jsx) under `.pnpm`, and the
- * flattened standalone `next` copy can't resolve them at boot.
- *
- * All paths are file-relative (ADR-0004): the build adapter's `appDir`
- * resolves against `dirname(build.module)`, never against a discovered
- * package directory.
+ * Paths are file-relative (ADR-0004): `appDir` resolves against
+ * `dirname(build.module)`.
*/
import * as fs from 'node:fs';
-import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { BuildAdapter } from '@internal/core';
@@ -45,18 +41,33 @@ function isNextjsBuild(descriptor: BuildAdapter): descriptor is NextjsBuildAdapt
);
}
-/** Where Next's standalone build places this app, given `outputFileTracingRoot` pins the monorepo root. */
-export function nextStandaloneDir(appDir: string): string {
- const resolvedApp = path.resolve(appDir);
- const workspaceRoot = path.resolve(resolvedApp, '../../../..');
- const rel = path.relative(workspaceRoot, resolvedApp);
- return path.join(resolvedApp, '.next', 'standalone', rel);
+/** The app's own server.js inside the standalone tree — the shallowest one that isn't a dependency's. Found, not computed: this is the app's deep location under `outputFileTracingRoot`. */
+function findAppServer(standaloneRoot: string): string | undefined {
+ const found: string[] = [];
+ const visit = (dir: string): void => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === 'node_modules') continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) visit(full);
+ else if (entry.name === 'server.js') found.push(full);
+ }
+ };
+ visit(standaloneRoot);
+ found.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
+ return found[0];
}
-/** The bootable standalone entry for a nextjs build — the output dir joined with the adapter's `entry`. Single-sourced so `assemble()` (deploy) and the integration-test seam can't drift. */
-export function standaloneEntryPath(build: NextjsBuildAdapter): string {
- const resolvedApp = path.resolve(path.dirname(fileURLToPath(build.module)), build.appDir);
- return path.join(nextStandaloneDir(resolvedApp), build.entry);
+/** The built standalone server.js for a nextjs build — `appDir`'s standalone root plus the located server. Single-sourced so `assemble()` (deploy) and the integration-test seam can't drift. */
+export function standaloneServerPath(build: NextjsBuildAdapter): string {
+ const appDir = path.resolve(path.dirname(fileURLToPath(build.module)), build.appDir);
+ const standaloneRoot = path.join(appDir, '.next', 'standalone');
+ const server = findAppServer(standaloneRoot);
+ if (server === undefined) {
+ throw new Error(
+ `no server.js under ${standaloneRoot} — run \`next build\` with output: "standalone"`,
+ );
+ }
+ return server;
}
export async function assemble(input: AssembleInput): Promise {
@@ -67,89 +78,71 @@ export async function assemble(input: AssembleInput): Promise {
}
const buildDescriptor = input.build;
- const serviceModule = fileURLToPath(buildDescriptor.module);
- const moduleDir = path.dirname(serviceModule);
- const resolvedApp = path.resolve(moduleDir, buildDescriptor.appDir);
- const appOut = nextStandaloneDir(resolvedApp);
- const entryPath = standaloneEntryPath(buildDescriptor);
- if (!fs.existsSync(entryPath)) {
+ const appDir = path.resolve(
+ path.dirname(fileURLToPath(buildDescriptor.module)),
+ buildDescriptor.appDir,
+ );
+ const standaloneRoot = path.join(appDir, '.next', 'standalone');
+ if (!fs.existsSync(standaloneRoot)) {
throw new Error(
- `no standalone ${buildDescriptor.entry} at ${appOut} — run \`next build\` with output: "standalone" first`,
+ `no ${path.join('.next', 'standalone')} under ${appDir} — run \`next build\` with output: "standalone" first.`,
);
}
-
- // Next hoists the traced node_modules to the STANDALONE ROOT, resolved from
- // the nested server.js by walking up. But the artifact tars only this app
- // subdir (the bundle dir), so those deps (`next`, `react`, …) are left out —
- // the VM then can't resolve `next` from server.js. Copy the hoisted tree in
- // so the bundle dir is self-contained.
- const standaloneRoot = path.join(resolvedApp, '.next', 'standalone');
- const rootModules = path.join(standaloneRoot, 'node_modules');
- if (
- path.resolve(rootModules) !== path.resolve(appOut, 'node_modules') &&
- fs.existsSync(rootModules)
- ) {
- await fs.promises.cp(rootModules, path.join(appOut, 'node_modules'), { recursive: true });
+ const serverPath = findAppServer(standaloneRoot);
+ if (serverPath === undefined) {
+ throw new Error(`no server.js found under ${standaloneRoot} — is this a standalone build?`);
}
+ // The app's deep location, relative to the standalone root — found, not computed.
+ const appRel = path.relative(standaloneRoot, path.dirname(serverPath));
- // The standalone build ships server.js + traced node_modules but not the
- // client assets; copy them where server.js serves them from.
- await fs.promises.cp(
- path.join(resolvedApp, '.next', 'static'),
- path.join(appOut, '.next', 'static'),
- { recursive: true },
- );
- const publicDir = path.join(resolvedApp, 'public');
- if (fs.existsSync(publicDir)) {
- await fs.promises.cp(publicDir, path.join(appOut, 'public'), { recursive: true });
- }
+ const workDir = path.join(input.cwd, '.prisma-compose', 'artifacts', input.address);
+ await fs.promises.rm(workDir, { recursive: true, force: true });
+ await fs.promises.mkdir(workDir, { recursive: true });
+ const bundleDir = path.join(workDir, 'bundle');
- // Bundle the Prisma App wrapper to a temp dir, then place it next to
- // server.js as main.mjs (unambiguously ESM — the standalone tree's
- // package.json is CJS-default). Its run()'s `import("./server.js")`
- // resolves relative to this file inside the artifact.
- const bundleTmp = await fs.promises.mkdtemp(
- path.join(os.tmpdir(), 'prisma-compose-nextjs-main-'),
- );
- try {
- await build({
- entry: [serviceModule],
- outDir: bundleTmp,
- format: 'esm',
- platform: 'node',
- external: ['bun'],
- // Workspace packages must be inlined (this is .ts source, not requireable
- // JS); everything Next needs is already in the standalone tree and is NOT
- // imported by the entry. The caller adds the app's own import-time deps
- // via wrapperNoExternal — this build is separate from Next's, so it can't
- // rely on the standalone node_modules trace to cover them.
- noExternal: [/^@prisma\//, ...(input.wrapperNoExternal ?? [])],
- dts: false,
- sourcemap: false,
- clean: false,
- // Do NOT auto-load this package's tsdown.config.ts: its `exports`
- // management would rewrite this package's package.json to the throwaway
- // bundle dir, corrupting resolution of @prisma/compose/nextjs afterward.
- config: false,
- });
- const built = fs.readdirSync(bundleTmp).find((f) => /^service\.m?js$/.test(f));
- if (built === undefined) {
- throw new Error(`tsdown produced no service.js in ${bundleTmp}`);
- }
- await fs.promises.copyFile(path.join(bundleTmp, built), path.join(appOut, 'main.mjs'));
- } finally {
- await fs.promises.rm(bundleTmp, { recursive: true, force: true });
+ // Ship the standalone tree as `next build` produced it (a symlinked
+ // node_modules stays symlinked → the packager rejects it, correctly).
+ await fs.promises.cp(standaloneRoot, bundleDir, { recursive: true });
+
+ // The documented copy: Next omits the client assets from standalone; place
+ // them beside the app's server.js so it serves them (docs: `cp -r public
+ // .next/standalone/ && cp -r .next/static .next/standalone/.next/`).
+ const appOut = path.join(bundleDir, appRel);
+ const staticSrc = path.join(appDir, '.next', 'static');
+ if (fs.existsSync(staticSrc)) {
+ await fs.promises.cp(staticSrc, path.join(appOut, '.next', 'static'), { recursive: true });
+ }
+ const publicSrc = path.join(appDir, 'public');
+ if (fs.existsSync(publicSrc)) {
+ await fs.promises.cp(publicSrc, path.join(appOut, 'public'), { recursive: true });
}
- // Disable bun's runtime auto-install. Next's server.js references `sharp` /
- // `@next/swc` (optional native deps this app never uses); on Compute, bun
- // tries to fetch their linux binaries at that `require`, filling the tiny
- // disk (ENOSPC -> reboot loop). With auto-install off, the require fails
- // gracefully and Next boots — exactly as it does locally. bun reads bunfig
- // from the process CWD, which is the artifact root at boot.
- await fs.promises.writeFile(path.join(appOut, 'bunfig.toml'), '[install]\nauto = "disable"\n');
+ // Our wrapper, bundled to main.mjs at the working-dir root (unambiguously
+ // ESM). run()'s `import("./bundle/")` resolves from here.
+ const serviceModule = fileURLToPath(buildDescriptor.module);
+ await build({
+ entry: { main: serviceModule },
+ outDir: workDir,
+ format: 'esm',
+ platform: 'node',
+ external: ['bun'],
+ noExternal: [/^@prisma\//, ...(input.wrapperNoExternal ?? [])],
+ dts: false,
+ sourcemap: false,
+ clean: false,
+ // Do NOT auto-load this package's tsdown.config.ts: its `exports`
+ // management would rewrite this package's package.json to the throwaway
+ // bundle dir, corrupting resolution of @prisma/compose/nextjs afterward.
+ config: false,
+ });
+ if (!fs.existsSync(path.join(workDir, 'main.mjs'))) {
+ throw new Error(`tsdown produced no main.mjs in ${workDir}`);
+ }
- return { dir: appOut, entry: buildDescriptor.entry };
+ return {
+ dir: workDir,
+ entry: path.posix.join('bundle', appRel.split(path.sep).join('/'), 'server.js'),
+ };
}
/** The nextjs build extension descriptor — `prisma-compose.config.ts` lists it under `extensions`. */
diff --git a/packages/0-framework/2-authoring/nextjs/src/index.ts b/packages/0-framework/2-authoring/nextjs/src/index.ts
index 8035b19f..dda73be0 100644
--- a/packages/0-framework/2-authoring/nextjs/src/index.ts
+++ b/packages/0-framework/2-authoring/nextjs/src/index.ts
@@ -1,32 +1,30 @@
/**
- * Marks a service as a Next.js app for deployment. `nextjs({ module, appDir,
- * entry })`: `module` is the authoring module's `import.meta.url`; `appDir`
- * is the Next app's root (the standalone layout root), resolved relative to
- * `dirname(module)` — exactly like an import specifier (ADR-0004); `entry` is
- * the built standalone server's filename, relative to `appDir`'s standalone
- * output. Returns plain data — nothing runs on import. `extension` + `type`
- * are the control-plane registry key: deploy tooling routes assembly through
- * the app's `prisma-compose.config.ts` to this package's `/control` descriptor
- * (ADR-0017) — no module loading here.
+ * Marks a service as a Next.js app for deployment. `nextjs({ module, appDir })`:
+ * `module` is the authoring module's `import.meta.url`; `appDir` is your Next
+ * app's root (the folder with `next.config`, `.next/`, `public/`), resolved
+ * relative to `dirname(module)` — like an import specifier (ADR-0004). Build it
+ * with `next build` (`output: "standalone"`); the deploy assembler then does the
+ * documented standalone deploy — ships the standalone tree and copies in the
+ * client assets (`.next/static`, `public/`) it omits — so there is no
+ * build-script step and no path to spell out. Returns plain data; nothing runs
+ * on import. `extension` + `type` are the control-plane registry key: deploy
+ * tooling routes assembly through the app's `prisma-compose.config.ts` to this
+ * package's `/control` descriptor (ADR-0017).
*/
import type { BuildAdapter } from '@internal/core';
-/** The nextjs build adapter's descriptor — `appDir` is this kind's own extra path input, beyond the shared `{ extension, type, module, entry }`. */
+/** The nextjs build adapter's descriptor — `appDir` is this kind's own extra path input (the Next app root), beyond the shared `{ extension, type, module, entry }`. `entry` is a placeholder; the assembler locates `server.js` in the standalone tree. */
export interface NextjsBuildAdapter extends BuildAdapter {
readonly type: 'nextjs';
readonly appDir: string;
}
-const nextjsBuild = (opts: {
- module: string;
- appDir: string;
- entry: string;
-}): NextjsBuildAdapter => ({
+const nextjsBuild = (opts: { module: string; appDir: string }): NextjsBuildAdapter => ({
extension: '@prisma/compose/nextjs',
type: 'nextjs',
module: opts.module,
appDir: opts.appDir,
- entry: opts.entry,
+ entry: 'server.js',
});
export default nextjsBuild;
diff --git a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts
index 71d7fa78..570a34d0 100644
--- a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts
+++ b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts
@@ -15,7 +15,14 @@ function makeServiceDir(): string {
return dir;
}
-/** The authoring module's import.meta.url for a service dir's src/service.ts (need not exist on disk unless the test writes it). */
+/** A tmp dir standing in for the deploy CLI's cwd — kept separate from the service package so staging-location assertions can't pass by accident. */
+function makeCwd(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-compose-node-assemble-cwd-'));
+ tmpDirs.push(dir);
+ return dir;
+}
+
+/** The authoring module's import.meta.url for a service dir's src/service.ts. */
function moduleUrl(serviceDir: string): string {
return pathToFileURL(path.join(serviceDir, 'src', 'service.ts')).href;
}
@@ -33,11 +40,13 @@ describe('assemble()', () => {
await expect(
assemble({
build: {
- extension: '@prisma/compose/nextjs',
- type: 'nextjs',
+ extension: '@prisma/compose/other',
+ type: 'other',
module: moduleUrl(serviceDir),
entry: 'server.js',
},
+ address: 'svc',
+ cwd: makeCwd(),
}),
).rejects.toThrow(/expected a "node" build adapter/);
});
@@ -52,49 +61,36 @@ describe('assemble()', () => {
module: moduleUrl(serviceDir),
entry: '../dist/server.js',
},
+ address: 'svc',
+ cwd: makeCwd(),
}),
).rejects.toThrow(/no built entry at .*dist\/server\.js/);
});
- test('rejects an app entry named main.js — reserved for the wrapper', async () => {
- const serviceDir = makeServiceDir();
- fs.mkdirSync(path.join(serviceDir, 'dist'), { recursive: true });
- fs.writeFileSync(path.join(serviceDir, 'dist', 'main.js'), 'export {};\n');
+ test('rejects an entry that resolves inside the deploy-owned working dir', async () => {
+ const cwd = makeCwd();
+ const address = 'svc';
+ const workDir = path.join(cwd, '.prisma-compose', 'artifacts', address);
+ fs.mkdirSync(path.join(workDir, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(workDir, 'server.js'), 'export default "app-entry";\n');
await expect(
assemble({
build: {
extension: '@prisma/compose/node',
type: 'node',
- module: moduleUrl(serviceDir),
- entry: '../dist/main.js',
- },
- }),
- ).rejects.toThrow(/reserved for the Prisma App wrapper/);
- });
-
- test('rejects an entry that resolves to the reserved bundle output dir itself (F04)', async () => {
- // `bundleDir` is computed as `dirname(entryPath)/bundle` — an entry whose
- // own resolved basename is "bundle" makes bundleDir fold back onto
- // entryPath exactly (e.g. a build tool that names its output file/dir
- // "bundle"), so the guard must catch entryPath === bundleDir before the
- // rm-then-copy sequence would delete the entry out from under itself.
- const serviceDir = makeServiceDir();
- fs.mkdirSync(path.join(serviceDir, 'dist'), { recursive: true });
- fs.writeFileSync(path.join(serviceDir, 'dist', 'bundle'), 'export {};\n');
- await expect(
- assemble({
- build: {
- extension: '@prisma/compose/node',
- type: 'node',
- module: moduleUrl(serviceDir),
- entry: '../dist/bundle',
+ module: pathToFileURL(path.join(workDir, 'src', 'service.ts')).href,
+ entry: '../server.js',
},
+ address,
+ cwd,
}),
- ).rejects.toThrow(/resolves inside its own output dir/);
+ ).rejects.toThrow(/resolves inside the deploy working dir/);
});
- test('produces a bundle dir (beside the built entry) containing the wrapper and a copy of the built entry', async () => {
+ test('copies the built entry under bundle/, with main.mjs at the working-dir root', async () => {
const serviceDir = makeServiceDir();
+ const cwd = makeCwd();
+ const address = 'shop.storefront';
fs.mkdirSync(path.join(serviceDir, 'dist'), { recursive: true });
fs.writeFileSync(path.join(serviceDir, 'dist', 'server.js'), 'export default "app-entry";\n');
fs.writeFileSync(
@@ -109,16 +105,54 @@ describe('assemble()', () => {
module: moduleUrl(serviceDir),
entry: '../dist/server.js',
},
+ address,
+ cwd,
+ });
+
+ expect(result.dir).toBe(path.join(cwd, '.prisma-compose', 'artifacts', address));
+ expect(result.entry).toBe('bundle/server.js');
+ expect(fs.existsSync(path.join(result.dir, 'bundle', 'server.js'))).toBe(true);
+ // The wrapper sits at the working-dir root, not under bundle/.
+ expect(fs.existsSync(path.join(result.dir, 'main.mjs'))).toBe(true);
+ expect(fs.existsSync(path.join(result.dir, 'bundle', 'main.mjs'))).toBe(false);
+ expect(fs.readFileSync(path.join(result.dir, 'bundle', 'server.js'), 'utf8')).toContain(
+ 'app-entry',
+ );
+ // Deploy-owned working dir — never the user's build output, never node_modules.
+ expect(result.dir.startsWith(serviceDir)).toBe(false);
+ expect(result.dir.includes('node_modules')).toBe(false);
+ }, 20_000);
+
+ test('assembles a build whose module basename is not "service" (cron scheduler shape) to main.mjs', async () => {
+ // The cron scheduler's build.module is "scheduler-service.mjs", not
+ // "service.ts" — a filename-discovery approach (readdir + regex on
+ // "service.*") would miss it; the tsdown object entry doesn't care.
+ const serviceDir = makeServiceDir();
+ const cwd = makeCwd();
+ const address = 'jobs.scheduler';
+ fs.mkdirSync(path.join(serviceDir, 'dist'), { recursive: true });
+ fs.writeFileSync(
+ path.join(serviceDir, 'dist', 'scheduler-entrypoint.js'),
+ 'export default "app-entry";\n',
+ );
+ fs.writeFileSync(
+ path.join(serviceDir, 'src', 'scheduler-service.ts'),
+ 'export default { hello: "wrapper" as const };\n',
+ );
+
+ const result = await assemble({
+ build: {
+ extension: '@prisma/compose/node',
+ type: 'node',
+ module: pathToFileURL(path.join(serviceDir, 'src', 'scheduler-service.ts')).href,
+ entry: '../dist/scheduler-entrypoint.js',
+ },
+ address,
+ cwd,
});
- expect(result.dir).toBe(path.join(serviceDir, 'dist', 'bundle'));
- expect(result.entry).toBe('server.js');
- expect(fs.existsSync(path.join(result.dir, 'server.js'))).toBe(true);
- const hasWrapper =
- fs.existsSync(path.join(result.dir, 'main.js')) ||
- fs.existsSync(path.join(result.dir, 'main.mjs'));
- expect(hasWrapper).toBe(true);
- // The copied entry is untouched — same module instance as the user's build.
- expect(fs.readFileSync(path.join(result.dir, 'server.js'), 'utf8')).toContain('app-entry');
+ expect(result.entry).toBe('bundle/scheduler-entrypoint.js');
+ expect(fs.existsSync(path.join(result.dir, 'main.mjs'))).toBe(true);
+ expect(fs.existsSync(path.join(result.dir, 'bundle', 'scheduler-entrypoint.js'))).toBe(true);
}, 20_000);
});
diff --git a/packages/0-framework/2-authoring/node/src/control.ts b/packages/0-framework/2-authoring/node/src/control.ts
index 2f7ce8eb..c4696955 100644
--- a/packages/0-framework/2-authoring/node/src/control.ts
+++ b/packages/0-framework/2-authoring/node/src/control.ts
@@ -1,22 +1,20 @@
/**
- * The extension's control entry (ADR-0017): `nodeBuild()` returns the
- * descriptor `prisma-compose.config.ts` lists — one build control, node ID
- * "node". Deploy-only (ADR-0005): the user's own build produces the app's
- * runnable; `assemble` builds Prisma App's deploy artifact from it —
- * validates the built entry exists, bundles the service module (the Prisma
- * App wrapper) to its own output, then copies the app's entry in beside it.
- * The heavy tsdown import lives only here, never in the authoring entry.
+ * The extension's control entry (ADR-0017): `nodeBuild()` returns the build
+ * descriptor `prisma-compose.config.ts` lists. Deploy-only (ADR-0005): the user
+ * builds their own runnable; `assemble` copies that built entry under `bundle/`
+ * and adds the framework's boot wrapper — it never bundles or transforms the
+ * app's code.
*
- * Two SEPARATE builds, not one multi-entry build: a single build would
- * dedupe the shared service module into a chunk both entries import — one
- * module instance. run() (the wrapper) and load() (the app entry) must be
- * independent instances that hand off through process.env, so the wrapper
- * gets its own self-contained build and the app's already-built entry is
- * copied in untouched. @prisma/* is inlined (node_modules isn't shipped);
- * `bun` is a Compute runtime built-in.
+ * The wrapper is a SEPARATE tsdown build of the service module (declarations
+ * only, whose node carries run()/load()), emitted as `main.mjs` at the
+ * working-dir root — a dictated name (object entry `{ main }`), not a
+ * discovered one. run() and the app entry must be independent module instances
+ * that hand off through process.env, so the wrapper is its own self-contained
+ * build; `@prisma/*` is inlined, `bun` is a Compute built-in.
*
- * All paths are file-relative (ADR-0004): `build.entry` resolves against
- * `dirname(build.module)`, never against a discovered package directory.
+ * Artifact layout: `/.prisma-compose/artifacts//` (deploy-owned,
+ * ADR-0005) holds `main.mjs` at the root and the app's entry under `bundle/`.
+ * `entry` is file-relative to `dirname(build.module)` (ADR-0004).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -39,42 +37,26 @@ export async function assemble(input: AssembleInput): Promise {
const entryPath = path.resolve(moduleDir, input.build.entry);
if (!fs.existsSync(entryPath)) {
throw new Error(
- `no built entry at ${entryPath} — run this app's own build first (the build adapter's ` +
- `entry, "${input.build.entry}", is resolved against dirname(module)).`,
- );
- }
- // "main" is the wrapper's name inside the bundle; an app entry with the same
- // basename would silently overwrite it and run() would never execute.
- if (/^main\.m?js$/.test(path.basename(entryPath))) {
- throw new Error(
- `the build adapter's entry ("${input.build.entry}") may not be named main.js/main.mjs — ` +
- 'that name is reserved for the Prisma App wrapper in the assembled bundle.',
+ `no built entry at ${entryPath} — run your build first (the build adapter's ` +
+ `entry, "${input.build.entry}", resolves against dirname(module)).`,
);
}
- // Beside the built entry — file-relative to the resolved entry, not a
- // discovered package dir (ADR-0004).
- const bundleDir = path.join(path.dirname(entryPath), 'bundle');
- // The entry must survive the `rm` below to reach the `copyFile` after it —
- // if it resolved inside the reserved output dir, the rm would delete it
- // first and the copy would fail with a bare ENOENT instead of a named error.
- if (entryPath === bundleDir || entryPath.startsWith(bundleDir + path.sep)) {
+ const workDir = path.join(input.cwd, '.prisma-compose', 'artifacts', input.address);
+ // The entry must sit outside the working dir cleared on every assemble, or
+ // the rm would delete it before the copy.
+ if (entryPath === workDir || entryPath.startsWith(workDir + path.sep)) {
throw new Error(
- `the build adapter's entry ("${entryPath}") resolves inside its own output dir ` +
- `("${bundleDir}") — Prisma App reserves that directory for the assembled bundle and ` +
- 'clears it before every assemble; point entry at your build output elsewhere.',
+ `the build adapter's entry ("${entryPath}") resolves inside the deploy working dir ` +
+ `("${workDir}"), which is cleared on every assemble — point entry at your build output elsewhere.`,
);
}
- await fs.promises.rm(bundleDir, { recursive: true, force: true });
- await fs.promises.mkdir(bundleDir, { recursive: true });
+ await fs.promises.rm(workDir, { recursive: true, force: true });
+ await fs.promises.mkdir(workDir, { recursive: true });
await build({
- // Named entry: tsdown derives the output filename from the entry key, so
- // the bundle is service.mjs regardless of the module's own basename (a
- // shared module's service file may not be named service.ts — the cron
- // scheduler's is scheduler-service.mjs).
- entry: { service: serviceModule },
- outDir: bundleDir,
+ entry: { main: serviceModule },
+ outDir: workDir,
format: 'esm',
platform: 'node',
external: ['bun'],
@@ -89,18 +71,16 @@ export async function assemble(input: AssembleInput): Promise {
// of `@prisma/compose/node` for everything that imports it afterward.
config: false,
});
-
- const built = fs.readdirSync(bundleDir).find((f) => /^service\.m?js$/.test(f));
- if (built === undefined) {
- throw new Error(`tsdown produced no service.js in ${bundleDir}`);
+ if (!fs.existsSync(path.join(workDir, 'main.mjs'))) {
+ throw new Error(`tsdown produced no main.mjs in ${workDir}`);
}
- const wrapperFile = built.endsWith('.mjs') ? 'main.mjs' : 'main.js';
- await fs.promises.rename(path.join(bundleDir, built), path.join(bundleDir, wrapperFile));
const entryFile = path.basename(entryPath);
+ const bundleDir = path.join(workDir, 'bundle');
+ await fs.promises.mkdir(bundleDir, { recursive: true });
await fs.promises.copyFile(entryPath, path.join(bundleDir, entryFile));
- return { dir: bundleDir, entry: entryFile };
+ return { dir: workDir, entry: path.posix.join('bundle', entryFile) };
}
/** The node build extension descriptor — `prisma-compose.config.ts` lists it under `extensions`. */
diff --git a/packages/0-framework/2-authoring/node/src/index.ts b/packages/0-framework/2-authoring/node/src/index.ts
index 79732da3..61789456 100644
--- a/packages/0-framework/2-authoring/node/src/index.ts
+++ b/packages/0-framework/2-authoring/node/src/index.ts
@@ -1,11 +1,11 @@
/**
* Marks a service as a plain server for deployment. `node({ module, entry })`
* says the app's built server lives at `entry`, resolved relative to
- * `dirname(module)` — exactly like an import specifier (ADR-0004). `module`
- * is the authoring module's `import.meta.url`. Returns plain data — nothing
- * runs on import. `extension` + `type` are the control-plane registry key:
- * deploy tooling routes assembly through the app's `prisma-compose.config.ts` to
- * this package's `/control` descriptor (ADR-0017) — no module loading here.
+ * `dirname(module)` — like an import specifier (ADR-0004). `module` is the
+ * authoring module's `import.meta.url`. Returns plain data — nothing runs on
+ * import. `extension` + `type` are the control-plane registry key: deploy
+ * tooling routes assembly through the app's `prisma-compose.config.ts` to this
+ * package's `/control` descriptor (ADR-0017).
*/
import type { BuildAdapter } from '@internal/core';
diff --git a/packages/0-framework/3-tooling/assemble/src/__tests__/assemble-services.test.ts b/packages/0-framework/3-tooling/assemble/src/__tests__/assemble-services.test.ts
index 9fbf25f4..25f51489 100644
--- a/packages/0-framework/3-tooling/assemble/src/__tests__/assemble-services.test.ts
+++ b/packages/0-framework/3-tooling/assemble/src/__tests__/assemble-services.test.ts
@@ -5,6 +5,8 @@ import type { PrismaAppConfig } from '@internal/core/config';
import { AssembleError } from '../assemble-error.ts';
import { assembleServices } from '../assemble-services.ts';
+const CWD = '/deploy-cwd';
+
const fakeRun = async (node: ServiceNode) => ({
dir: `/bundles/${node.name}`,
entry: 'server.js',
@@ -42,7 +44,7 @@ describe('assembleServices()', () => {
});
const graph = Load(root);
- const assembled = await assembleServices(graph, emptyConfig, fakeRun);
+ const assembled = await assembleServices(graph, emptyConfig, CWD, fakeRun);
expect(assembled.bundles).toEqual({
auth: { dir: '/bundles/auth', entry: 'server.js' },
@@ -61,7 +63,7 @@ describe('assembleServices()', () => {
});
const graph = Load(root);
- const assembled = await assembleServices(graph, emptyConfig, fakeRun);
+ const assembled = await assembleServices(graph, emptyConfig, CWD, fakeRun);
expect(Object.keys(assembled.bundles)).toEqual(['auth.api']);
});
@@ -70,14 +72,31 @@ describe('assembleServices()', () => {
const root = module('empty-module', {}, () => ({}));
const graph = Load(root);
- await expect(assembleServices(graph, emptyConfig, fakeRun)).rejects.toThrow(AssembleError);
+ await expect(assembleServices(graph, emptyConfig, CWD, fakeRun)).rejects.toThrow(AssembleError);
+ });
+
+ test('the RunAssembler seam receives each service’s graph address and the deploy cwd', async () => {
+ const seen: Array<{ address: string; cwd: string }> = [];
+ const run = async (node: ServiceNode, address: string, cwd: string) => {
+ seen.push({ address, cwd });
+ return { dir: `/bundles/${node.name}`, entry: 'server.js' };
+ };
+ const root = module('fixture-module', {}, ({ provision }) => {
+ provision(makeService('auth'), { id: 'auth' });
+ return {};
+ });
+ const graph = Load(root);
+
+ await assembleServices(graph, emptyConfig, CWD, run);
+
+ expect(seen).toEqual([{ address: 'auth', cwd: CWD }]);
});
- test("the default RunAssembler routes through the config's build control — (build.extension, build.type), any community id", async () => {
+ test("the default RunAssembler routes through the config's build control — (build.extension, build.type), any community id — and forwards the address + cwd", async () => {
// A made-up extension + type a community build adapter could use —
// nothing in this package recognizes either specially; the registry the
// config carries is the whole routing table.
- const seen: string[] = [];
+ const seen: Array<{ type: string; address: string; cwd: string }> = [];
const config: PrismaAppConfig = {
extensions: [
{
@@ -86,7 +105,7 @@ describe('assembleServices()', () => {
cron: {
kind: 'build',
assemble: async (input) => {
- seen.push(input.build.type);
+ seen.push({ type: input.build.type, address: input.address, cwd: input.cwd });
return { dir: '/bundles/cron', entry: input.build.entry };
},
},
@@ -104,9 +123,9 @@ describe('assembleServices()', () => {
});
const graph = Load(root);
- const assembled = await assembleServices(graph, config);
+ const assembled = await assembleServices(graph, config, CWD);
- expect(seen).toEqual(['cron']);
+ expect(seen).toEqual([{ type: 'cron', address: 'svc', cwd: CWD }]);
expect(assembled.bundles['svc']).toEqual({ dir: '/bundles/cron', entry: 'x' });
});
@@ -117,7 +136,7 @@ describe('assembleServices()', () => {
});
const graph = Load(root);
- await expect(assembleServices(graph, emptyConfig)).rejects.toThrow(
+ await expect(assembleServices(graph, emptyConfig, CWD)).rejects.toThrow(
/No extension "@fixture\/node-adapter" is configured .*prisma-compose\.config\.ts/,
);
});
@@ -139,7 +158,7 @@ describe('assembleServices()', () => {
});
const graph = Load(root);
- await expect(assembleServices(graph, config)).rejects.toThrow(
+ await expect(assembleServices(graph, config, CWD)).rejects.toThrow(
/is a "resource" control — a service build descriptor needs a "build" control/,
);
});
diff --git a/packages/0-framework/3-tooling/assemble/src/assemble-services.ts b/packages/0-framework/3-tooling/assemble/src/assemble-services.ts
index a758651b..f4a9a0a3 100644
--- a/packages/0-framework/3-tooling/assemble/src/assemble-services.ts
+++ b/packages/0-framework/3-tooling/assemble/src/assemble-services.ts
@@ -11,7 +11,7 @@ export interface AssembledServices {
}
/** Assembles one service node — the seam tests substitute to avoid a real build. */
-export type RunAssembler = (node: ServiceNode) => Promise;
+export type RunAssembler = (node: ServiceNode, address: string, cwd: string) => Promise;
/**
* The registry route for one service's build: descriptor by
@@ -19,7 +19,12 @@ export type RunAssembler = (node: ServiceNode) => Promise;
* coverage validation reports the same misses earlier with the config fix;
* these errors are the backstop for programmatic callers.
*/
-function buildControlAssemble(config: PrismaAppConfig, node: ServiceNode): Promise {
+function buildControlAssemble(
+ config: PrismaAppConfig,
+ node: ServiceNode,
+ address: string,
+ cwd: string,
+): Promise {
const { extension, type } = node.build;
const descriptor = config.extensions.find((candidate) => candidate.id === extension);
if (descriptor === undefined) {
@@ -44,15 +49,19 @@ function buildControlAssemble(config: PrismaAppConfig, node: ServiceNode): Promi
return control.assemble({
build: node.build,
wrapperNoExternal: INLINE_EVERYTHING_EXCEPT_RUNTIME_BUILTINS,
+ address,
+ cwd,
});
}
export async function assembleServices(
graph: Graph,
config: PrismaAppConfig,
+ cwd: string,
run?: RunAssembler,
): Promise {
- const runAssembler: RunAssembler = run ?? ((node) => buildControlAssemble(config, node));
+ const runAssembler: RunAssembler =
+ run ?? ((node, address, nodeCwd) => buildControlAssemble(config, node, address, nodeCwd));
const serviceNodes = graph.nodes.filter(
(n): n is GraphNode & { node: ServiceNode } => n.node.kind === 'service',
);
@@ -62,7 +71,7 @@ export async function assembleServices(
const bundles: Record = {};
for (const { id, node } of serviceNodes) {
- bundles[id] = await runAssembler(node);
+ bundles[id] = await runAssembler(node, id, cwd);
}
return { bundles };
}
diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts
index 458179f9..82eae1cd 100644
--- a/packages/0-framework/3-tooling/cli/src/main.ts
+++ b/packages/0-framework/3-tooling/cli/src/main.ts
@@ -231,7 +231,7 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise<
// 6. Assemble each service through the config's registries.
let assembled: Awaited>;
try {
- assembled = await assembleServices(graph, config, deps.runAssembler);
+ assembled = await assembleServices(graph, config, cwd, deps.runAssembler);
} catch (error) {
if (args.command === 'destroy' && error instanceof Error) {
throw new CliError(
diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts
index 44ef7988..cd183901 100644
--- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts
+++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts
@@ -198,7 +198,40 @@ describe('packageComputeArtifact', () => {
});
const { names } = readTar(fs.readFileSync(artifact.path));
- expect(names).toEqual(['a.txt', 'b.txt', 'bootstrap.js', 'compute.manifest.json', 'main.js']);
+ expect(names).toEqual([
+ 'a.txt',
+ 'b.txt',
+ 'bootstrap.js',
+ 'bunfig.toml',
+ 'compute.manifest.json',
+ 'main.js',
+ ]);
+ });
+
+ test('injects bunfig.toml disabling bun auto-install into every artifact', () => {
+ const bundleDir = makeBundle({ 'main.js': 'export default {};' });
+ const artifact = packageComputeArtifact({
+ id: 'auth',
+ bundleDir,
+ appEntry: 'server.js',
+ address: 'auth',
+ });
+ const { read } = readTar(fs.readFileSync(artifact.path));
+ expect(read('bunfig.toml')).toContain('auto = "disable"');
+ });
+
+ test('a symlink in the bundle is a hard error naming the path and the fix (flat bundles only)', () => {
+ const bundleDir = makeBundle({
+ 'main.js': 'export default {};',
+ 'node_modules/real/index.js': '// real',
+ });
+ // A bun/pnpm-shaped relative dir-symlink, the kind a Next standalone tree
+ // is full of — the framework must reject it, not dereference it.
+ fs.symlinkSync('real', path.join(bundleDir, 'node_modules', 'link'));
+
+ expect(() =>
+ packageComputeArtifact({ id: 'auth', bundleDir, appEntry: 'server.js', address: 'auth' }),
+ ).toThrow(/symlink at node_modules\/link .* deploy bundles must be flat/);
});
test('a missing bundle dir (destroy before any build) returns a placeholder instead of throwing', () => {
diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts
index 45a1a0e8..203fc56f 100644
--- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts
+++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts
@@ -45,12 +45,21 @@ function resolveEntry(bundleDir: string, entry: string | undefined): string {
return found;
}
-/** All files under `dir`, as dir-relative POSIX paths, in sorted order. */
+/** All files under `dir`, as dir-relative POSIX paths, in sorted order. A
+ * symlink is a hard error: deploy bundles must be flat (ADR-0005), and the
+ * user's build owns flattening — dereferencing here would relink the tree and
+ * risk packaging files from outside it. */
function walkFiles(dir: string): string[] {
const out: string[] = [];
const visit = (sub: string): void => {
for (const entry of fs.readdirSync(path.join(dir, sub), { withFileTypes: true })) {
const rel = sub.length > 0 ? `${sub}/${entry.name}` : entry.name;
+ if (entry.isSymbolicLink()) {
+ throw new Error(
+ `bundle contains a symlink at ${rel} — deploy bundles must be flat; ` +
+ 'materialize links in your build (e.g. cp -RL) so the tree is self-contained.',
+ );
+ }
if (entry.isDirectory()) visit(rel);
else out.push(rel);
}
@@ -144,6 +153,16 @@ export function packageComputeArtifact(opts: PackageComputeArtifactOptions): Com
}));
files.push({ relPath: 'bootstrap.js', content: Buffer.from(bootstrap, 'utf8') });
files.push({ relPath: 'compute.manifest.json', content: Buffer.from(manifest, 'utf8') });
+ // Disable bun's runtime auto-install for every Compute artifact. App builds
+ // are self-contained (prismaTsDownConfig inlines all deps), so nothing needs
+ // fetching at boot; this guards against a stray optional `require` (e.g. a
+ // Next standalone's `sharp`/`@next/swc`) making bun fetch a linux binary at
+ // boot and fill the tiny disk (ENOSPC -> reboot loop). bun reads bunfig from
+ // the process CWD, which is the artifact root at boot.
+ files.push({
+ relPath: 'bunfig.toml',
+ content: Buffer.from('[install]\nauto = "disable"\n', 'utf8'),
+ });
const gz = createDeterministicTarGz(files);
const sha256 = crypto.createHash('sha256').update(gz).digest('hex');
diff --git a/packages/9-public/compose/package.json b/packages/9-public/compose/package.json
index cff7666f..63bf94dc 100644
--- a/packages/9-public/compose/package.json
+++ b/packages/9-public/compose/package.json
@@ -18,6 +18,7 @@
"./node/control": "./dist/node-control.mjs",
"./nextjs": "./dist/nextjs.mjs",
"./nextjs/control": "./dist/nextjs-control.mjs",
+ "./tsdown": "./dist/tsdown.mjs",
"./package.json": "./package.json"
},
"files": [
diff --git a/packages/9-public/compose/src/tsdown.ts b/packages/9-public/compose/src/tsdown.ts
new file mode 100644
index 00000000..5bc2fff4
--- /dev/null
+++ b/packages/9-public/compose/src/tsdown.ts
@@ -0,0 +1,36 @@
+import type { UserConfig } from 'tsdown';
+
+/**
+ * `tsdown` config for a Prisma App's own runnable — the build ADR-0005 expects
+ * an app to produce before deploy. Its one job is a **self-contained** ESM
+ * bundle: `node_modules` is never shipped, so everything the entry touches at
+ * runtime must be inlined, and the artifact must not lean on bun's runtime
+ * auto-install to fill gaps. So it inlines EVERYTHING except the runtime's own
+ * built-ins (`bun`, `bun:*`, `node:*`) — a denylist, not a per-package
+ * allowlist. Allowlists are the trap: `noExternal: [/^pg$/]` inlines `pg` but
+ * misses its subpath imports (`pg/lib/*`), which then vanish from the bundle and
+ * crash the service at boot. This mirrors the deploy wrapper's own inline
+ * policy, so app and wrapper are self-contained the same way.
+ *
+ * Pass your `entry` (and any override); everything else is dictated.
+ */
+const appBaseConfig: UserConfig = {
+ outDir: 'dist',
+ format: 'esm',
+ platform: 'node',
+ external: ['bun'],
+ // Inline everything except runtime built-ins (bun/bun:/node:).
+ noExternal: [/^(?!bun$)(?!bun:)(?!node:).+/],
+ dts: false,
+ sourcemap: false,
+ clean: true,
+};
+
+export function prismaTsDownConfig(
+ config: UserConfig & { entry: NonNullable },
+): UserConfig {
+ return {
+ ...appBaseConfig,
+ ...config,
+ };
+}
diff --git a/packages/9-public/compose/tsdown.config.ts b/packages/9-public/compose/tsdown.config.ts
index 29a81b53..3869dd49 100644
--- a/packages/9-public/compose/tsdown.config.ts
+++ b/packages/9-public/compose/tsdown.config.ts
@@ -20,6 +20,7 @@ export default defineConfig([
'node-control': 'src/node-control.ts',
nextjs: 'src/nextjs.ts',
'nextjs-control': 'src/nextjs-control.ts',
+ tsdown: 'src/tsdown.ts',
},
exports: false,
clean: true,
diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts
index f8338cda..b1f21caa 100644
--- a/test/integration/test/cli.extension-config.test.ts
+++ b/test/integration/test/cli.extension-config.test.ts
@@ -37,7 +37,7 @@ describe('prisma-compose deploy — real extension-config resolution of prisma-c
expect(result.stderr).not.toContain('Cannot resolve');
expect(result.stderr).not.toContain('environment variable PRISMA_WORKSPACE_ID is required');
expect(result.stderr).toContain('no built entry at');
- expect(result.stderr).toContain("run this app's own build first");
+ expect(result.stderr).toContain('run your build first');
});
test('without PRISMA_WORKSPACE_ID, fails at the real prismaCloud() env check during config evaluation — proving the /control entry actually resolved and ran', () => {