Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
accfba1
feat(cli): project a deployment result into a serializable summary
wmadden-electric Aug 6, 2026
97bdc1b
refactor(cli): extract deploy/destroy into typed operations
wmadden-electric Aug 6, 2026
e082e7f
refactor(cli): extract dev and log into typed operations
wmadden-electric Aug 6, 2026
e138a24
feat(composer): publish the operations as @prisma/composer/control
wmadden-electric Aug 6, 2026
8bf201a
test(cli): pin the programmatic operations, in-repo and against the p…
wmadden-electric Aug 6, 2026
cb5f73d
docs(composer): document the programmatic control API
wmadden-electric Aug 6, 2026
72530f8
fix(cli): return a structured failure when dev() throws after attach
wmadden-electric Aug 6, 2026
f8243e7
fix(cli): restore the shipped dev output order for unwatchable notices
wmadden-electric Aug 6, 2026
6025239
docs(adr): rewrite ADR-0043 for fresh-eyes readers
wmadden-electric Aug 6, 2026
79c8be5
refactor(cli): shrink the control API effect defenses to a lazy-load …
wmadden-electric Aug 6, 2026
8a1261a
docs(composer): demote import-safety of the control entry to a genera…
wmadden-electric Aug 6, 2026
0e6a112
refactor(cli): group the control surface by operation, one module each
wmadden-electric Aug 6, 2026
ce53ad0
refactor(cli): speak the caller's language in the control types
wmadden-electric Aug 6, 2026
628428d
refactor(cli): keep the execution mechanism out of the published cont…
wmadden-electric Aug 6, 2026
f9a2244
fix(cli): stack generation failures are results, not rejections
wmadden-electric Aug 6, 2026
218bab4
fix(cli): make the merged log stream safe to stop, bounded, and quiet…
wmadden-electric Aug 6, 2026
ea56d3c
fix(cli): harden the dev session's event and teardown paths
wmadden-electric Aug 6, 2026
7458963
refactor(cli): strip the injection seam from the published operation …
wmadden-electric Aug 6, 2026
f7fec8c
fix(cli): give each deploy run its own result file, and prove the rou…
wmadden-electric Aug 6, 2026
d79813f
test(cli): pin the DevSession contract and the control entry's static…
wmadden-electric Aug 6, 2026
7234f36
docs(composer): align ADR-0043, the deploying guide, and the SKILL wi…
wmadden-electric Aug 6, 2026
a06e2e0
fix(cli): report a service that refuses to stop in dev
wmadden-electric Aug 7, 2026
9919825
fix(cli): report dropped log lines in the log command
wmadden-electric Aug 7, 2026
3f362ef
test(cli): derive the flood test size from the exported queue bound
wmadden-electric Aug 7, 2026
677e46e
test(cli): prove the late-failure path is armed before firing it
wmadden-electric Aug 7, 2026
8478953
fix(cli): shield the log stream from a throwing host callback
wmadden-electric Aug 7, 2026
39a6655
fix(cli): remove the per-run result file on failure paths too
wmadden-electric Aug 7, 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
6 changes: 6 additions & 0 deletions architecture.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,12 @@
"layer": "public",
"plane": "control"
},
{
"glob": "packages/9-public/composer/src/exports/control.ts",
"domain": "public",
"layer": "public",
"plane": "control"
},
{
"glob": "packages/9-public/composer/src/exports/config.ts",
"domain": "public",
Expand Down
8 changes: 6 additions & 2 deletions docs/design/10-domains/deploy-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,12 @@ every node already carries:
- **`@internal/assemble`** owns the orchestration this seam drives: routing
every service node in the loaded graph to its registry's assemble entry
(one bundle per full address — the root is always a Module). The CLI is
its first consumer; the future
programmatic deploy API is its second — so its public surface carries no CLI
its first consumer; the programmatic control API is its second —
`@prisma/composer/control`'s typed `deploy`/`destroy`/`dev`/`log`
operations, implemented in `@internal/cli`'s `src/operations/` with the CLI
as a thin renderer over them
([ADR-0043](../90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md)).
So assemble's public surface carries no CLI
concepts (no `CliError`, no argv/usage anything). It throws its own
`AssembleError`; the CLI's `main.ts` maps it (the existing destroy-path
wrapping already does, since `AssembleError extends Error`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR-0043: `@prisma/composer/control` is the programmatic deploy surface

## Decision

Composer's deploy pipeline is drivable in-process through one published subpath, **`@prisma/composer/control`**. It exposes four typed operations — `deploy`, `destroy`, `dev`, `log` — that take structured inputs and return structured results. They never parse argv and never call `process.exit`; the operations print nothing — the spawned alchemy child streams its own output to the terminal. The `prisma-composer` CLI is a thin renderer over these same operations, so the command-line surface and the programmatic surface cannot drift apart.

A host — another CLI embedding Composer, a CI tool, a test — uses it like this:

```ts
import { deploy } from '@prisma/composer/control';

const result = await deploy({ entry: 'module.ts', stage: 'feat-auth' });

if (result.outcome === 'deployed') {
for (const node of result.summary?.nodes ?? []) {
console.log(node.address, node.entities);
}
} else {
switch (result.failure.kind) {
case 'invalid-input': // e.g. a stage name git would reject
case 'pipeline': // loading the deploy stack through assembly failed
case 'execution': // alchemy ran and exited nonzero
report(result.failure.message);
}
}
```

Failures are values, not exceptions: every way a deploy can go wrong comes back as a discriminated `failure` the caller can branch on, carrying the same human-readable message the CLI prints plus the original error as `cause`. An `execution` failure may also carry an optional `diagnostics` object (exit code, generated stack-file path, reproduce command, cwd) — details of the current execution mechanism, useful for printing a hint but deliberately outside the durable contract.

## Why a programmatic surface

`@internal/assemble` is deliberately CLI-free — its design names a programmatic deploy API as its second consumer, alongside the CLI ([deploy-cli.md § Contracts](../10-domains/deploy-cli.md)). A program embedding Composer needs results it can branch on, not stdout to scrape: subprocess invocation couples the caller to output formatting, loses error types, and turns every failure into string parsing. `@prisma/composer/control` is that second consumer surface.

Because the CLI's commands are renderers over the same operations, there is exactly one implementation of deploy orchestration. A fix or feature in the operation is a fix or feature in both surfaces; neither can gain behavior the other lacks.

## Importing the subpath executes nothing

The `./control` entry's static import graph is import-light: types, the result definitions, and two small helpers. Each operation lazily `import()`s the executor that reaches the pipeline and alchemy, so importing the subpath executes nothing — consistent with the repo's no-import-side-effects stance — and a host pays for the deploy stack only when it calls an operation. The property is pinned structurally: a test imports the entry in a fresh process with every heavy module poisoned and fails if the static graph ever reaches one.

A dependency tree that cannot load that stack — for example, a mismatched `effect` version that makes alchemy's modules throw at import time — surfaces when an operation runs, as a structured `pipeline` failure whose message names the problem (the operation diagnoses the failed load with the same check the CLI's `bin.ts` runs at start-up). The host stays alive and gets a result it can branch on, never an import-time crash.

## The deploy result crosses a process boundary

Deploy execution happens in a **spawned alchemy child process** driving a generated stack file (ADR-0007). The full `DeploymentResult` only materializes inside that child, in the stack file's `report:` hook — and its `DeployedNode` entries hold live references to the graph nodes themselves, which cannot be serialized across processes (ADR-0033 defines the type for in-process readers).

The operation therefore uses the narrowest channel that works:

- `deployment-summary.ts` owns the protocol whole: **`DeploymentSummary`** — the serializable projection of a result (the app name and, per node, its `address` and deployed `entities`) — plus the env var, the writer, and the reader.
- When the environment variable `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` names a file, the report hook writes the summary there as JSON (best-effort — a write failure never fails a converged deploy), in addition to its normal console rendering. The variable is internal to the two halves; it is not exported from `./control`.
- The deploy operation points that variable at a file whose name is unique per run — concurrent deploys sharing a working directory cannot read or delete each other's summary — reads it back after a zero exit, and deletes it.

The child's own stdout/stderr still stream to the host's terminal (`stdio: 'inherit'`) — the user watches alchemy work exactly as they would from the CLI, and the result file rides alongside rather than being scraped out of that stream. The file lives under the tool-owned `.prisma-composer/` directory (ADR-0004).

The summary is **best-effort by contract**: an absent or unparseable file yields `summary: undefined` on a deploy that still reports `outcome: 'deployed'`. A deploy that converged is never reported as failed for lacking a convenience payload. Writer and reader share the one `DeploymentSummary` shape; changing it is a cross-process protocol change and must stay backward-tolerant — the reader treats anything unrecognizable as absent.

## Where the code lives, and the name

The operations live in `@internal/cli` (`src/operations/`), re-exported through `src/exports/control.ts` shims on both `@internal/cli` and `@prisma/composer` (the ADR-0035 entrypoint pattern). There is no new workspace package: the operations orchestrate the same pipeline modules the CLI uses, and only `packages/9-public/` publishes (ADR-0027/ADR-0028), so a separate internal package would add a boundary with nothing on the other side.

The subpath is named `control` because that is the architecture plane these sources occupy in `architecture.config.json`, matching the existing control-plane subpaths (`@prisma/composer/node/control`, `/nextjs/control`, `@prisma/composer-prisma-cloud/control`). Note the distinct consumer classes: an *extension's* `/control` entry is a control-plane descriptor importable only from `prisma-composer.config.ts` (ADR-0017), while `@prisma/composer/control` is for external hosts. The shim's doc comment records the distinction.

## Consequences

- **The failure taxonomy is deliberately coarse at the pipeline stage.** One `pipeline` kind spans everything from loading the deploy stack and config discovery through assembly and container preparation; `invalid-input`, `unsupported-platform`, and `execution` are distinct. Callers needing to distinguish pipeline sub-failures must parse messages until a finer taxonomy exists.
- **`dev` returns a session handle** (`endpoints`, `stop()`, `closed`, an event callback) and **never touches process signal handlers**. Signal ownership — including evicting alchemy's import-time SIGINT/SIGTERM listeners — belongs to the host; the CLI adapter shows the pattern.
- **`log` returns the running services plus an `AsyncIterable` of lines** ended by a caller-owned `AbortSignal`; one stream failing surfaces as an event without ending the others. Zero running services is a valid, non-failure result with an already-finished iterable.
- **The alchemy child's output is not capturable through this API.** The current mechanism streams the deploy engine's output to the host's own stdio; capturing or redirecting it needs a new option on the operations, not a workaround. The mechanism itself (a spawned child, `stdio: 'inherit'`) is how composer deploys today, not a promise the surface makes — which is also why the spawn-shaped failure fields live in the optional `diagnostics` object rather than on the failure itself.
- **`@internal/cli` now contains a surface that is not a CLI.** The package name is narrower than its contents: the operations are control-plane orchestration that the CLI also happens to render. That is the accepted cost of not creating a package with nothing on the other side of its boundary.

## Alternatives considered

- **A new workspace package for the operations.** Everything the operations need already lives in `@internal/cli`, and only `packages/9-public/` publishes — a new internal package would add a boundary with nothing on the other side.
- **Naming the subpath `./operations` or `./pilot`.** The plane name the sources already carry won; the overlap with extension `/control` entries is a documentation concern, not a naming-precision one, and the entry's doc comment resolves it.
- **Parsing the summary from the child's stdout.** Stdout is the user's live view of alchemy working; scraping it would couple the cross-process protocol to presentation strings.
- **Failing the deploy when the result file is missing after exit 0.** Whether the report hook runs on a no-op converge is not guaranteed, and the summary is a convenience — a converged deploy must not be reported as failed for lacking one.

## Related

- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — the spawned child the result-file contract crosses into.
- [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) — the *other* `/control`: extension control-plane entries.
- [ADR-0027](ADR-0027-two-packages-compose-and-compose-prisma-cloud.md) / [ADR-0028](ADR-0028-numbered-domains-and-layers-enforced-by-dependency-cruiser.md) — why no new package, and where publishable code lives.
- [ADR-0033](ADR-0033-lowering-types-are-defined-by-their-readers.md) — the result/render split the summary projection extends across the process boundary.
- [ADR-0035](ADR-0035-public-entrypoints-live-in-src-exports.md) — the `src/exports/` shim pattern both new entries follow.
1 change: 1 addition & 0 deletions docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor.
- [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam).
- [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL.
- [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — `@prisma/composer/control` is the programmatic deploy surface: typed `deploy`/`destroy`/`dev`/`log` operations (structured inputs/results, no argv/console/exit) implemented in `@internal/cli`'s `src/operations/` and re-exported per ADR-0035; the CLI is a thin renderer over them. The entry's static graph stays import-light — each operation lazily imports its executor, so importing the subpath executes nothing, and a tree that cannot load the deploy stack surfaces as a structured `pipeline` failure — and `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` carries the deploy result across the process boundary: the alchemy child's report hook writes a serializable `DeploymentSummary` to the named file, the operation reads it back best-effort (absent/malformed = undefined summary, never a failure). Distinct from an extension's ADR-0017 `/control` entry.
57 changes: 57 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,63 @@ next deploy recreates everything under fresh state — either:
Recreated apps get new generated URLs; anything pointing at the old ones
needs updating.

## Driving deploys from code

Everything the CLI does is also callable in-process, from
`@prisma/composer/control`: typed `deploy`, `destroy`, `dev`, and `log`
operations that return structured results instead of printing and exiting.
The `prisma-composer` commands are thin renderers over these same operations,
so the two surfaces can't drift.

```ts
import { deploy } from '@prisma/composer/control';

const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
if (result.outcome === 'deployed') {
// result.summary — the deployed topology (app name + each node's
// address and entities), when the deploy engine reported one.
} else {
console.error(result.failure.message); // same fix-naming text the CLI prints
}
```

What to know before embedding it:

- **Inputs mirror the flags, but typed.** A bare `deploy` targets production,
exactly like the CLI. `destroy` takes a discriminated target —
`{ kind: 'production' }` or `{ kind: 'stage', stage }` — so there is no
silent default to production and no flag-combination footgun.
- **Failures are results, not throws.** Every operation resolves to either
its success shape or `{ outcome: 'failed', failure }`, where
`failure.kind` is one of `invalid-input`, `unsupported-platform`, `pipeline`
(anything between loading the deploy stack and the deploy engine — including
the [effect version conflict](#when-a-deploy-stops-on-an-effect-version-conflict),
reported with the same fix-naming message the CLI prints), or `execution`
(the engine ran and failed). An `execution` failure's optional
`diagnostics` object carries the exit code and an exact reproduce command —
details of the current execution mechanism, handy for printing a hint but
not something to build on; branch on `message`/`cause` for anything
durable. Importing the module executes nothing until you call an operation.
- **`summary` is best-effort.** It rides a result file the deploy engine's
child process writes; a deploy that converged without writing one still
succeeds, with `summary: undefined`.
- **The engine's own output still streams to your process's stdio.** That is
the current mechanism, not a promise: the operations return structured
results but don't capture the live deploy output; run them where that
output belongs, or with stdio redirected. Capturing it would be a new
option on the operations.
- **`dev` resolves to `{ outcome: 'started', session }` or a failure** —
never an exit code. The session is `{ endpoints, stop(), closed }`, with
progress (`ready`, `converge-failed`, `watch-error`, …) delivered through
`onEvent`. The operation never installs signal handlers; wiring Ctrl-C to
`session.stop()` is yours.
- **`log` resolves to `{ outcome: 'attached', appName, services, lines }` or
a failure.** `lines` is an `AsyncIterable` ended by an `AbortSignal` you
own (stopping early — `break`, `lines.return()` — also ends it cleanly).
Zero running services is a valid result (empty `services`, finished
stream), not an error. A consumer that falls behind loses oldest lines
past a bounded queue and is told via a `lines-dropped` event.

## The full picture

[`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md)
Expand Down
1 change: 1 addition & 0 deletions packages/0-framework/3-tooling/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "The `prisma-composer` deploy CLI.",
"exports": {
".": "./dist/index.mjs",
"./control": "./dist/control.mjs",
"./report": "./dist/report.mjs",
"./package.json": "./package.json"
},
Expand Down
Loading
Loading