Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
59f12a4
chore(examples): let PRISMA_DEPLOY_ENV point deploy at a creds file o…
wmadden-electric Jul 13, 2026
eb56d7b
docs: ADR-0029 β€” deploy bundles are user-produced flat trees; we don'…
wmadden-electric Jul 13, 2026
b905014
docs: fold 'we don't bundle' into ADR-0005; drop redundant ADR-0029
wmadden-electric Jul 13, 2026
98ae13c
fix(deploy): dictate wrapper name main.mjs; stage assembly by graph a…
wmadden-electric Jul 13, 2026
7d57b04
refactor(deploy): nest user output under bundle/ in the artifact
wmadden-electric Jul 13, 2026
d958c4c
fix(deploy): nextjs adapter takes the standalone path; no tree comple…
wmadden-electric Jul 13, 2026
244ef69
fix(deploy): reject symlinks in the compute artifact (flat bundles only)
wmadden-electric Jul 13, 2026
548e751
fix(storefront-auth): produce a complete flat standalone; adopt the s…
wmadden-electric Jul 13, 2026
aa5cb03
refactor(deploy): one generic build adapter + a Next flatten utility …
wmadden-electric Jul 13, 2026
7a2ed26
refactor(deploy): restore nextjs() adapter; its assemble does the doc…
wmadden-electric Jul 13, 2026
fb930aa
Merge remote-tracking branch 'origin/main' into chore/deploy-env-over…
wmadden-electric Jul 13, 2026
0434915
fix(deploy): bunfig is nextjs-only (node needs bun auto-install); rec…
wmadden-electric Jul 15, 2026
1a34323
feat(build): ship prismaTsDownConfig; self-contained app builds, univ…
wmadden-electric Jul 15, 2026
50ab11e
fix(ci): make the FT-5226 cold-connect canary reliable (sample N, jud…
wmadden-electric Jul 15, 2026
bd5afee
Merge branch 'main' into chore/deploy-env-override
wmadden-electric Jul 15, 2026
e25ba00
Merge branch 'main' (#76) into chore/deploy-env-override
wmadden-electric Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .agents/rules/no-bundling.mdc
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions .drive/projects/forcing-function-apps/bugs-deploy-assembly.md
Original file line number Diff line number Diff line change
@@ -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/<address>/`) β€” 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/<x>/modules/<app>/` 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/<pkg>` β†’ `.bun/<pkg>@<ver>/node_modules/<pkg>`), 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.
Original file line number Diff line number Diff line change
@@ -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
(`<cwd>/.prisma-compose/artifacts/<address>/`), 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/<address>/`, 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 <rel>; 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.
Loading
Loading