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', () => {