Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ADR-0005: Users build their app; the framework assembles deploy artifacts from built output

Superseded in part by [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md): assembly may trace the declared entry's runtime files and preserve symlinks whose targets remain inside the final bundle; it still never dereferences a link or guesses an entry.

## Decision

The framework never initiates or configures a user's build. The contract is:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0047: Compute assembly preserves safe runtime topology and proves routing

## Decision

Composer's Compute path turns declared built output into a self-contained artifact without rewriting application code:

```text
declared entry ──trace imports──▶ staged runtime files
safe symlink ────────────────▶ archived as a symlink
missing traced target ─────────▶ staged from the declared trace root
escaping link ────────────────▶ hard error
promoted URL ──route probe───▶ deploy succeeds
```

The Node directory adapter traces the explicitly declared entry's static runtime file graph and stages those files beside the directory the author named. It does not choose an entry, run a build, or bundle the application.

The Compute archive preserves a symlink as a tar symlink only after resolving its real target and proving that target remains inside the assembled bundle. Long targets use a POSIX PAX `linkpath` rather than flattening the package tree. It never dereferences the link. Next.js assembly handles one framework-output gap first: if pnpm's standalone tree contains an in-root traced link but omits its virtual-store target, Composer stages the exact corresponding target from Next's declared `outputFileTracingRoot`. A target unavailable there remains dangling and fails, as do links that escape the bundle.

The generated bootstrap may install a narrowly scoped compatibility shim when Compute's JavaScript runtime differs from the Node behavior a framework relies on. Such a shim must be feature-gated to that runtime and must run before the application entry is imported.

Compute also supplies `HOST=0.0.0.0` when the author did not configure a host. Framework servers must listen on Compute's network interface rather than a loopback-only default; an explicit author value remains authoritative.

## Reasoning

"Users build; Composer assembles" is a boundary between owning a build and manufacturing a deployment artifact. It does not require Composer to ignore the runtime topology recorded by a build. Astro's Node adapter, for example, emits server files that deliberately retain bare package imports. Copying only `dist/` preserves the bytes but not the runnable program. Tracing from the author-declared entry follows package metadata and import edges deterministically; it is file assembly, not a second application build.

The same distinction applies to symlinks. Dereferencing a package-manager link can silently pull arbitrary deploy-machine files into an artifact, which remains forbidden. Preserving the link itself retains the build's topology. Resolving the target only for validation proves that the archived link cannot escape the artifact, including through a chain of links, without copying the target through the link. The pnpm/Next repair is narrower: its destination is the missing in-artifact target, its source is the same relative path in Next's trace root, and the source's real path must remain inside that root.

Frameworks also exercise details of the Compute runtime that a plain HTTP server may not. A compatibility shim belongs in Composer's generated bootstrap because it is part of the hosting envelope, not the user's framework build. The shim is deliberately narrow: the current URL custom-inspect setter restores Node-compatible assignment semantics for Bun without patching SvelteKit output or changing unrelated globals.

## Consequences

- Node directory artifacts can carry runtime packages that a framework intentionally leaves external.
- Safe package-manager and framework symlinks remain links in both cloud and local artifacts, including peer-context targets longer than USTAR's fixed field; escaping or unresolved dangling links fail before upload.
- Runtime compatibility code is isolated in the generated bootstrap and covered by framework deployment tests.
- Framework servers receive a listen-all host default without overriding an author-configured host.
- These mechanisms are compatibility ownership, not permanent duplication. When the upstream Alchemy Compute provider supplies an equivalent archive, build staging, or runtime bootstrap guarantee, Composer deletes the corresponding local mechanism rather than keeping two implementations.

## Alternatives considered

**Require every build directory to be flat and fully self-contained.** Rejected: standard framework outputs do not all have that shape, and forcing every application to maintain post-build copy scripts moves hosting assembly into userland.

**Dereference symlinks during packaging.** Rejected: it changes the build topology and can package files outside the declared artifact boundary.

**Bundle the application entry again.** Rejected: that crosses the user-build boundary and creates a second framework compatibility surface. Static file tracing preserves the application's emitted code.

## Related

- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — users own builds; Composer owns deterministic artifact assembly.
- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Alchemy is the provisioning engine behind deploy.
- [Architectural principles](../01-principles/architectural-principles.md) — Composer does not bundle application code or guess build output.
3 changes: 2 additions & 1 deletion docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-composer 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 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); read the build tool's own manifest (Next's `relativeAppDir`), don't walk or compute.
- [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); read the build tool's own manifest (Next's `relativeAppDir`), don't walk or compute. *(Superseded in part by ADR-0047: a symlink whose resolved target stays inside the bundle is preserved as a link; the no-guessing and no-dereferencing rules stand.)*
- [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.
Expand Down Expand Up @@ -68,3 +68,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — Errors are structural envelopes with dotted `NAMESPACE.SUBCODE` codes (the shared prisma/prisma foundation, duplicated pending extraction): structured at origin with why/fix splits, no catch-all codes, bugs carry no code (exit 1 + report hint), recognition is structural (`CliStructuredError.is()`), operation results ride the shared `Result` `ok` discriminator, expected failures exit 2 — with the alchemy child-status passthrough as the documented exception.
- [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted.
- [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma Next postgres facade (`@prisma/orm-postgres`) as a **peer** dependency at one exact version, not a regular dependency: Composer registers an extension pack against the application's copy of the target, and two copies of a shell in one tree means two codec/operation registries and two class identities — a value from one is rejected by the other, silently. As a peer, that combination fails at install instead. Every `@prisma/orm-*` spec in the workspace is one exact version and all name the same one (`scripts/lint-orm-pins.mjs`). `@prisma/orm-toolchain`, which Composer drives rather than extends, stays a regular dependency. Replaces ADR-0022's consequence bullet on how the ORM is installed.
- [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, and installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function writeNextBuild(root: string): { appRel: string } {
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');
fs.symlinkSync('next', path.join(standalone, 'node_modules', 'next-linked'));
// 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');
Expand All @@ -42,7 +43,10 @@ function writeNextBuild(root: string): { appRel: string } {
// Next's manifest — records the app's subpath within standalone (posix).
fs.writeFileSync(
path.join(root, '.next', 'required-server-files.json'),
JSON.stringify({ relativeAppDir: 'apps/web' }),
JSON.stringify({
relativeAppDir: 'apps/web',
config: { outputFileTracingRoot: root },
}),
);
fs.writeFileSync(
path.join(root, 'src', 'service.ts'),
Expand Down Expand Up @@ -109,6 +113,9 @@ describe('assemble()', () => {
expect(fs.existsSync(path.join(workDir, 'bundle', 'node_modules', 'next', 'marker.txt'))).toBe(
true,
);
expect(fs.readlinkSync(path.join(workDir, 'bundle', 'node_modules', 'next-linked'))).toBe(
'next',
);
// 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);
Expand All @@ -124,4 +131,68 @@ describe('assemble()', () => {
const server = standaloneServerPath(nextjs({ module: moduleUrl(root), appDir: '..' }));
expect(server).toBe(path.join(root, '.next', 'standalone', 'apps', 'web', 'server.js'));
});

test('stages a pnpm virtual-store target that Next omitted behind a traced link', async () => {
const root = makeAppRoot();
writeNextBuild(root);
const standalone = path.join(root, '.next', 'standalone');
const source = path.join(
root,
'node_modules',
'.pnpm',
'semver@6.3.1',
'node_modules',
'semver',
);
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "6.3.1";\n');
const linkDir = path.join(standalone, 'node_modules', '.pnpm', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver'));

const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-'));
tmpDirs.push(cwd);
const result = await assemble({
address: 'storefront.web',
cwd,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});

const bundleStore = path.join(
cwd,
'.prisma-composer',
'artifacts',
'storefront.web',
'bundle',
'node_modules',
'.pnpm',
);
expect(fs.readlinkSync(path.join(bundleStore, 'node_modules', 'semver'))).toBe(
'../semver@6.3.1/node_modules/semver',
);
expect(
fs.readFileSync(
path.join(bundleStore, 'semver@6.3.1', 'node_modules', 'semver', 'index.js'),
'utf8',
),
).toContain('6.3.1');
expect(result.watch).toContain(source);
}, 20_000);

test('assembles a complete standalone build when its recorded tracing root is absent', async () => {
const root = makeAppRoot();
writeNextBuild(root);
const manifestPath = path.join(root, '.next', 'required-server-files.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
manifest.config.outputFileTracingRoot = path.join(root, 'missing-build-machine-root');
fs.writeFileSync(manifestPath, JSON.stringify(manifest));

const result = await assemble({
address: 'storefront.web',
cwd: root,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});

expect(fs.existsSync(path.join(result.dir, result.entry))).toBe(true);
}, 20_000);
});
Loading
Loading