From ad0829592914939e8559910cae8030d7d58cbb33 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 31 Oct 2026 10:23:33 +0100 Subject: [PATCH 01/14] Run deploy through alchemy/Alchemist in the prisma process prisma deploy and the deploy operation now lower the app and call Alchemist.plan and Alchemist.apply in process. No alchemy.run.ts is written and no Alchemy child is spawned. destroy and prisma dev keep the stack file. - lower() takes containers and preflight payloads as values; extensions read their payload as LowerContext.preflight. The env transports stay for the stack file. - The report hook returns the summary as a value. - Alchemy events become apply-resource steps under deploy.apply; log lines become messages. - Failures are DEPLOY.ENGINE_FAILED with the cause; Ctrl-C is DEPLOY.INTERRUPTED and closes the scope, releasing the lease. - prisma deploy needs PRISMA_SERVICE_TOKEN in the environment (DEPLOY.SERVICE_TOKEN_REQUIRED): cli-engine 0.9.0 has no public way to hand a command the active access token. - convergeInProcess is the function slice 3 (dev) reuses. - scratch-stack test harness rebuilt on alchemy/Alchemist. - ADR-0064 supersedes ADR-0007 for deploy; amends ADR-0038/0043/0044. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/deploy-cli.md | 37 +- ...-alchemy-through-a-generated-stack-file.md | 2 + ...-containers-are-an-extension-descriptor.md | 2 + ...path-is-the-programmatic-deploy-surface.md | 2 + ...l-envelopes-with-dotted-namespace-codes.md | 10 +- ...ADR-0064-deploy-runs-alchemy-in-process.md | 51 ++ docs/design/90-decisions/README.md | 9 +- docs/guides/deploying.md | 31 +- docs/guides/getting-started.md | 11 +- .../core/src/__tests__/invariants.test.ts | 4 +- .../core/src/__tests__/lowering.test.ts | 44 ++ .../1-core/core/src/control/deploy.ts | 32 +- .../0-framework/3-tooling/cli/package.json | 1 + .../src/__tests__/converge-in-process.test.ts | 315 +++++++++ .../3-tooling/cli/src/converge-in-process.ts | 214 ++++++ .../3-tooling/cli/src/deployment-summary.ts | 16 + .../__tests__/converge-settlement.test.ts | 63 -- .../cli/src/family/__tests__/deploy.test.ts | 198 +++--- .../cli/src/family/commands/deploy.ts | 109 ++- .../3-tooling/cli/src/family/converge.ts | 84 +-- .../operations/__tests__/operations.test.ts | 632 ++++++------------ .../3-tooling/cli/src/operations/deploy.ts | 23 +- .../src/operations/execute-deploy-destroy.ts | 193 +++++- .../3-tooling/cli/src/operations/shared.ts | 15 +- .../cli/src/testing/operations-double.ts | 77 +-- .../__tests__/custom-domain-provider.test.ts | 8 +- .../deployment-environment-lifecycle.test.ts | 1 - .../0-lowering/lowering/src/scratch-stack.ts | 131 +--- .../src/__tests__/control-lowering.test.ts | 10 +- .../target/src/__tests__/invariants.test.ts | 5 +- .../__tests__/local-target-preflight.test.ts | 13 +- .../__tests__/orm-postgres-ordering.test.ts | 1 - .../src/__tests__/preflight-payload.test.ts | 32 +- .../src/__tests__/public-url-apply.test.ts | 17 +- .../target/src/control/extension.ts | 37 +- .../target/src/control/preflight-payload.ts | 33 +- .../target/src/descriptors/compute.ts | 7 +- .../target/src/descriptors/shared.ts | 7 - pnpm-lock.yaml | 4 + skills/prisma-composer-core-concepts/SKILL.md | 22 +- .../test/cli.extension-config.test.ts | 2 +- 41 files changed, 1440 insertions(+), 1065 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md create mode 100644 packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/converge-in-process.ts delete mode 100644 packages/0-framework/3-tooling/cli/src/family/__tests__/converge-settlement.test.ts diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index ab5e423fd..9ca6ff778 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -96,7 +96,9 @@ that relative imports inside the entry graph may use `./x.js` or extensionless bun APIs can only deploy under bun, since loading the graph imports that module — the app's choice, not a CLI limit. -**The Alchemy child's runtime.** Composer runs the `bin` of the `alchemy` +**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`), using the `alchemy` that `@prisma/composer-cli` depends on ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. + +**The Alchemy child's runtime (`destroy` and `prisma dev`).** These still start an Alchemy child. Composer runs the `bin` of the `alchemy` package that the app's `@prisma/composer` depends on, found from the app directory the way the generated stack file's own imports are, so the CLI and the stack code come from one install. It starts that bin with Node, as the @@ -182,20 +184,19 @@ user can rerun it by hand (ADR-0007). emulator instance is touched. On `deploy`, an older extension's `ensure` may already have created the app's Project by then; the check stops everything after that. -9. **Lower and drive.** Write the pipeline's results as a runnable stack - module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against - it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one - environment variable per extension — content the CLI writes but never - reads (ADR-0038). Both `deploy` and `destroy` set it, since `alchemy - destroy` re-imports and re-evaluates the same stack, so its target - reconstruction needs the same containers. The generated file and Alchemy's - state live in the process's working directory — tool state lives where you - run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. +9. **Lower and apply.** `deploy` calls core's `lower()` with the pipeline's results as values: the name, the environment, the config pool, the assembled bundles, every resolved container, every extension's preflight payload, and the report hook. It passes the stack value `lower()` returns to `Alchemist.plan` and then `Alchemist.apply`, in the `prisma` process, with Alchemy's stage set as § CLI behavior notes describes ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). No stack file is written and no child is started. + - **Progress.** Alchemy's typed events become step events under the `deploy.apply` step: one step per resource, labelled with Alchemy's status word (`creating shop.api`, then `created shop.api`), and Alchemy's log lines become messages. Human and JSON output keep one format. Alchemy's console output is replaced, and `console.log` goes to stderr while Alchemy runs, so nothing but the engine writes to stdout. + - **Result.** The report hook runs inside the apply, in the same process, so the deployment summary comes back as a value. + - **Failure.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`. Its message carries the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's (`--adopt`) and every credential and pool secret removed. + - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI exits 130. + - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them after. + - **Credentials.** The service token must be in the environment as `PRISMA_SERVICE_TOKEN`. The engine gives a command no way to read a `prisma login` session's token, so a signed-in session without it fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before anything runs. + + `destroy` (and `prisma dev`) write the pipeline's results as a runnable stack module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one environment variable per extension — content the CLI writes but never reads (ADR-0038). `alchemy destroy` re-imports and re-evaluates the same stack, so its target reconstruction needs the containers. The generated file and Alchemy's state live in the process's working directory — tool state lives where you run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. The pass that assembles a service is the same pass that lowers it, so the correlation between services and their built bundles never exists as -user-facing configuration — it is computed, written into the generated stack -file, and consumed in one motion. +user-facing configuration — it is computed and consumed in one motion. ## Environments and containers @@ -216,12 +217,7 @@ inputs carry the same `environment`. ([ADR-0038](../90-decisions/ADR-0038-containers-are-an-extension-descriptor.md)). The CLI resolves every configured extension's container before Alchemy runs (pipeline step 8) and holds each result as an opaque value. -- **The transport is content-blind.** Each resolved container crosses to the - alchemy child as one environment variable, named from the extension's id - and holding whatever that extension's own `serialize()` produced; the child - reads it back and calls the same extension's `deserialize`. The CLI never - reads the value, and an extension never touches `process.env` directly — it - receives its container as a parameter everywhere. +- **Containers reach the lowering as values.** `deploy` hands each resolved container to `lower()` directly. For `destroy` and `prisma dev`, which start an Alchemy child, each container crosses to the child as one environment variable, named from the extension's id and holding whatever that extension's own `serialize()` produced; the child reads it back and calls the same extension's `deserialize`. The transport is content-blind: the CLI never reads the value, and an extension never touches `process.env` directly — it receives its container as a parameter everywhere. Preflight payloads travel the same two ways and reach the lowering as `LowerContext.preflight`. - **State rides the container.** `PrismaAppConfig.state` names its owning extension; core hands that extension's own resolved container to `state.create()`, so the state layer is built from it rather than from the @@ -303,6 +299,9 @@ them: | Built output missing | the expected path, and "run your build" | | Root's input refused by its schema | the schema's messages, and that a root's input is the config pool for the environment | | A config adapter throws or returns something other than an object (`CONFIG.ADAPTER_FAILED`, `CONFIG.ADAPTER_INVALID`) | which list and position, and the fix | +| No `PRISMA_SERVICE_TOKEN` in the environment for `deploy` (`DEPLOY.SERVICE_TOKEN_REQUIRED`) | to set it to a workspace service token | +| Alchemy's plan or apply failed (`DEPLOY.ENGINE_FAILED`) | the errors Alchemy failed with | +| Ctrl-C during the plan or apply (`DEPLOY.INTERRUPTED`) | to deploy again; the lease is already released | ## Out of scope (designed around) @@ -328,7 +327,7 @@ them: `--stage` — the environment's Branch id, which for `production` is the project's default Branch id. This pins the deploy-state scope to the Branch instead of Alchemy's `dev_$USER` default, so it never varies by machine. - `alchemy` is ALWAYS invoked with an explicit `--stage`: the + Alchemy is ALWAYS given an explicit stage (`stage` on `Alchemist.plan`, `--stage` for the child): the container-supplied `alchemyStage` when there is one, else the environment name. Every environment has a name, so there is always a value. The generated stack file carries neither the environment nor any resolved container diff --git a/docs/design/90-decisions/ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md b/docs/design/90-decisions/ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md index 662b87a3a..e0c6da95a 100644 --- a/docs/design/90-decisions/ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md +++ b/docs/design/90-decisions/ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md @@ -1,5 +1,7 @@ # ADR-0007: Deploy drives Alchemy through a generated stack file +Superseded for `deploy` by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` runs Alchemy's plan and apply in the `prisma` process and writes no stack file. `destroy` and `prisma dev` still drive the Alchemy CLI through the generated file as described below. + Amended by [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md): the commands that generate and drive the stack file are `prisma deploy` and `prisma dev`; teardown is the `destroy` operation on `@prisma/composer/control`, which drives `alchemy destroy` against the same generated file. The `prisma-composer` binary is retired. The generated-file mechanism below is unchanged. ## Decision diff --git a/docs/design/90-decisions/ADR-0038-containers-are-an-extension-descriptor.md b/docs/design/90-decisions/ADR-0038-containers-are-an-extension-descriptor.md index 0895f2ebc..4741ef7cd 100644 --- a/docs/design/90-decisions/ADR-0038-containers-are-an-extension-descriptor.md +++ b/docs/design/90-decisions/ADR-0038-containers-are-an-extension-descriptor.md @@ -1,5 +1,7 @@ # ADR-0038: Containers are an extension descriptor +> Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` runs Alchemy in the CLI's own process and hands each resolved container to `lower()` as a value (`LowerOptions.containers`). The environment-variable transport below remains for `destroy` and `prisma dev`, which still start an Alchemy child. + > Amended (TML-3157): `ContainerInstance` gained an optional, framework-visible > `alchemyStage` — the deterministic Alchemy stage the container implies (the > Prisma Cloud extension supplies the resolved Branch id). The CLI reads it diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index fdfc1c409..66a38e74d 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -1,5 +1,7 @@ # ADR-0043: `@prisma/composer/control` is the programmatic deploy surface +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` runs Alchemy in the calling process, so the report hook returns the `DeploymentSummary` as a value; `deploy` sets no `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE`. `deploy` also takes an optional `signal` that interrupts the plan or apply. + Amended by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md): each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired. Amended by [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md): the `prisma-composer` binary is retired; the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`, and `destroy` and `log` are called from scripts. The surface below is otherwise unchanged. Amended by [ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md): two more operations, `devUrl` and `devStop`, act on the app `prisma dev` last ran from a directory. Amended 2026-10-28: they act on the app and environment named in their input instead, with `environment` defaulting to `dev` ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). `dev` and `log` take `environment` too, also defaulting to `dev`. diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index 08eec4926..d0a48206f 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -1,5 +1,7 @@ # ADR-0044: Errors are structural envelopes with dotted namespace codes +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED` are added. + Amended 2026-10-23: the `CONFIG` registry gains ENTRY_MISSING, for a command or operation that has no entry module from the config or an override. Amended 2026-10-27: the `DEPLOY` registry follows [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md), which calls a place the app runs an environment. It gains ENVIRONMENT_INVALID, ENVIRONMENT_UNVALIDATABLE and ENVIRONMENT_REQUIRED, and retires STAGE_INVALID, STAGE_UNVALIDATABLE and SCOPE_MISSING. The `DEPS` registry gains EXTENSION_INCOMPATIBLE. @@ -99,10 +101,14 @@ Closed subcode registry: ENVIRONMENT_INVALID, ENVIRONMENT_UNVALIDATABLE, ENVIRONMENT_REQUIRED, CONTAINER_FAILED, PREFLIGHT_FAILED, ENGINE_FAILED, TEARDOWN_FAILED, CONTAINER_REMOVE_FAILED, - BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED + BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED, + INTERRUPTED, SERVICE_TOKEN_REQUIRED *(STAGE_INVALID, STAGE_UNVALIDATABLE and SCOPE_MISSING retired by [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md))*. ENVIRONMENT_REQUIRED: a `destroy` call names no environment. + INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. + SERVICE_TOKEN_REQUIRED: `prisma deploy` has no `PRISMA_SERVICE_TOKEN` in + the environment ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. - `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. @@ -122,7 +128,7 @@ governs statuses the CLI itself generates: `0` OK, `1` internal error/bug ONLY, `2` expected failure (usage errors and structured failures alike), `3` user abort, `130`/`143` signals. -**Documented exception:** when the spawned deploy engine (alchemy) exits +**Documented exception** (`destroy` and `prisma dev`; `deploy` runs Alchemy in process since [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)): when the spawned deploy engine (alchemy) exits nonzero, the CLI prints the two reproduce-hint lines and passes the child's own exit status through unchanged — the child's status is the operator's signal and renumbering it would erase information. A passthrough status is the child's diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md new file mode 100644 index 000000000..dad3f545f --- /dev/null +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -0,0 +1,51 @@ +# ADR-0064: `deploy` runs Alchemy in the `prisma` process + +## Decision + +`prisma deploy`, and the `deploy` operation on `@prisma/composer/control`, run Alchemy's plan and apply in the process that runs the deploy, through Alchemy's programmatic API (`alchemy/Alchemist`). Deploy writes no `.prisma-composer/alchemy.run.ts` and starts no Alchemy child. + +```ts +const stack = lower(root, config, { + name, environment, configPool, bundles, + containers, // each extension's resolved container + preflight, // each extension's preflight payload + report: (result) => { summary = toDeploymentSummary(result); }, +}); +const plan = yield* Alchemist.plan({ stack, operation: 'deploy', stage, updateStateStore: true }); +yield* Alchemist.apply(plan); +``` + +This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. + +- **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Without them `lower()` reads the environment transports, which the generated stack file still needs. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. +- **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. +- **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. +- **Failures are structured.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`, carrying the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's and secrets removed. An interrupted one is `DEPLOY.INTERRUPTED`. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. +- **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. +- **The credential is an environment variable.** Alchemy reads its configuration only from `process.env` and `.env`. `deploy` sets `PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID` and each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both after. `@prisma/cli-engine` 0.9.0 gives a command no way to read a `prisma login` session's token, so `prisma deploy` requires `PRISMA_SERVICE_TOKEN` in the environment and otherwise fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before anything runs. + +## Reasoning + +ADR-0007 chose a generated file and the Alchemy CLI because Alchemy had no programmatic API, and because a file a user can rerun bisects a framework bug from an Alchemy bug. Alchemy now has one (`alchemy/Alchemist`, from commit `972f294`). With it, the generated file is a detour: Composer wrote TypeScript, found Alchemy's bin, spawned it, and passed containers, preflight payloads and the result through environment variables and files, only for the child to import the config again and rebuild what the parent already held. In process, the parent hands Alchemy the stack value and gets typed results and typed events back. + +The bisection argument weakens with the API. A failed deploy now reports Alchemy's own error as a structured cause, and the per-resource step events say which resource failed. The cost of losing the rerunnable file is accepted. + +## Consequences + +- `deploy` no longer resolves Alchemy's bin or needs Node on PATH under Bun. `DEPLOY.ALCHEMY_BIN_MISSING`, `DEPLOY.NODE_MISSING` and `DEPLOY.STACK_WRITE_FAILED` remain for `destroy` and `prisma dev`. +- New codes: `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED`. +- `prisma deploy` declares `needs: { credentials: true }` and no longer `maySpawn`. +- Setting `process.env` and the working directory for the length of the apply is process-wide state. Two deploys in one process at once would see each other's values. Neither the CLI nor any known host does this; the upstream asks below remove the need. +- Interactive deploys signed in with `prisma login` need `PRISMA_SERVICE_TOKEN` until `@prisma/cli-engine` gives a command the active access token, or Alchemy lets a caller pass configuration to `plan` instead of reading only the environment. + +## Alternatives considered + +- **Keep a child process running a Composer-owned runner.** Rejected: an extra process and a protocol for nothing the in-process path lacks. +- **Keep generating `alchemy.run.ts` and load it with `Alchemist.importStack`.** Rejected: the file existed so a failed run could be rerun with the Alchemy CLI; writing code to import it back is circuitous. +- **`SigilRenderer` for output.** Rejected: it would give `prisma deploy` a second output format and print nothing in JSON mode. +- **Read the stored token by spawning a process.** Rejected: it would bring back a child for credentials alone. + +## Related + +- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md), [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md), [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md), [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) +- [`../10-domains/deploy-cli.md`](../10-domains/deploy-cli.md) § The pipeline, step 9 diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index fa57eadaf..473fa94a2 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -28,7 +28,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [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. *(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. *(Amended by ADR-0050: the override is `prisma deploy --name`. Amended by ADR-0051: a node's name is its default provision ID, so changing any node's name changes its identity. Amended by ADR-0057: the root's name is the first segment of every address, so changing it changes every node's identity.)* -- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file. *(Amended by ADR-0050: `prisma deploy` and `prisma dev` generate the file; teardown is the `destroy` operation.)* +- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file. *(Amended by ADR-0050: `prisma deploy` and `prisma dev` generate the file; teardown is the `destroy` operation.)* *(Superseded for `deploy` by ADR-0064: `deploy` runs Alchemy in process; `destroy` and `prisma dev` keep the file.)* - [ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md) — The boot wrapper inlines everything except runtime built-ins. - [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — Deploy state is hosted in the workspace, not in local files. *(Superseded by ADR-0034: still hosted, now per-stage in the app's own Project.)* - [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — Deploys hold a session advisory lock per stack and stage. *(Superseded by ADR-0045: the lease is server-side now; the fail-fast contention behavior survives.)* @@ -59,13 +59,13 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0035](ADR-0035-public-entrypoints-live-in-src-exports.md) — Public entrypoints live in `src/exports/` (one file per subpath; internals stay at the `src/` root); `@internal/tsdown-config` generates `package.json#exports` from object-named entries where safe, with two deliberate exceptions kept hand-maintained (the multi-pass `cron`/`storage`/`streams`, and the two published packages). Completes ADR-0028. - [ADR-0036](ADR-0036-the-rpc-kind-is-named-service-rpc.md) — The RPC kind is named **service RPC**: subpath `@prisma/composer/service-rpc`, unchanged call-site names (`rpc()`, `contract()`, `serve()`), kind brand stays `'rpc'`. Scope recorded in connection-contracts.md: edges internal to the application topology, agent-generatable by design — not an application API layer, not general distributed-systems infrastructure. - [ADR-0037](ADR-0037-service-rpc-calls-carry-an-idempotency-key.md) — The generated service RPC client carries an `Idempotency-Key` on every call — one per logical call, reused across a bounded retry — and the provider deduplicates on it: one call per key, replaying completed 2xx/4xx answers (never 5xx) from a bounded in-process store. A keyless request (a hand-rolled or older caller) is served once without deduplication rather than rejected. Retrying is permanent protocol behavior, not a platform workaround, and there is no per-method opt-in — a flag would be an unverifiable claim. Handlers may read the key via an optional third argument for their own durable exactly-once. -- [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) — Container lifecycle (ensure/locate/remove) is an optional `container` descriptor on `ExtensionDescriptor`, the same pattern as `preflight`/`teardown`; the resolved instance is opaque to core and crosses the CLI parent→alchemy child boundary as one framework-named environment variable per extension, via the extension's own `serialize()`/`deserialize()`. `StateDescriptor` names its owning extension so core can hand it that extension's resolved container. Deletes the `crossDomainExceptions` entry that let the CLI import `@internal/lowering` directly — `0-framework` imports nothing again. +- [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) — Container lifecycle (ensure/locate/remove) is an optional `container` descriptor on `ExtensionDescriptor`, the same pattern as `preflight`/`teardown`; the resolved instance is opaque to core and crosses the CLI parent→alchemy child boundary as one framework-named environment variable per extension, via the extension's own `serialize()`/`deserialize()`. `StateDescriptor` names its owning extension so core can hand it that extension's resolved container. Deletes the `crossDomainExceptions` entry that let the CLI import `@internal/lowering` directly — `0-framework` imports nothing again. *(Amended by ADR-0064: `deploy` hands containers and preflight payloads to `lower()` as values; the environment transport remains for `destroy` and `prisma dev`.)* - [ADR-0039](ADR-0039-a-compute-services-own-origin-is-a-target-resolved-property.md) — A compute service's own platform-assigned origin is a target-resolved property, read as the service's `origin()` — never a declared param, never operator config, never in `config()`. It rides ADR-0031's reserved provider-param channel as the first *service-derived* entry (`valueForService(provisioned, address)`, written for every compute service, exposing or not), sourced from the provisioned App's `appEndpointDomain` (ADR-0048) — made trustworthy pre-promote by the upstream PRO-200 fix. `envParam(…)` remains correct for operator-known origins (custom domains); narrows ADR-0032's `appOrigin` example accordingly. - [ADR-0040](ADR-0040-the-orm-binding-carries-the-url-and-a-lazy-client.md) — `postgres(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 `rawPostgres()`'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 (`'postgres'` satisfying `'postgres'`) rejected in its favor. *(Extended by ADR-0055: the binding also carries `connection` and `clientWith(options)`.)* - [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 the Postgres emulator hosts one `@prisma/dev` server per database instance. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`OrmMigration` 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). *(Amended by ADR-0050: the command is `prisma dev`; logs are the `log` operation, not a command. Amended 2026-10-23: the entry is the `composer` section's `entry` or `--entry`, not a positional. Amended by ADR-0052: emulator instances are keyed by emulator-generated ids, not the app name, Composer maps each app's nodes to them per directory, and `prisma dev` stops the app's database servers when it ends.)* *(Amended by ADR-0056: a service runs the dev server its build adapter declares in place of its artifact, as a command deployment on the Compute emulator.)* *(Amended by ADR-0062: local dev runs as a named environment, `dev` by default.)* - [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. *(Extended by ADR-0061 to modules.)* *(Amended 2026-10-30: the forwardable secret need is removed; a module takes a secret as an input field.)* *(Amended 2026-10-30, sixth slice: `field()` and `secretString()` ship from `@prisma/composer/arktype` and `@prisma/composer/zod`; `secretString()` accepts a secret pointer.)* *(Amended 2026-10-30, seventh slice: the deploy checks a service's schema with pointers in place; a field bound to `serviceOrigin()` or a plain `generatedParam()` is wrapped with `field()`; `service.input()` has no pointers.)* -- [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. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* *(Amended by ADR-0053: `devUrl` and `devStop` join the operations.)* *(Amended by ADR-0059: `deploy` and `destroy` take `environment`, which `destroy` requires.)* *(Amended 2026-10-28: `devUrl` and `devStop` act on the app and environment in their input; `dev` and `log` take `environment`.)* -- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* +- [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. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* *(Amended by ADR-0053: `devUrl` and `devStop` join the operations.)* *(Amended by ADR-0059: `deploy` and `destroy` take `environment`, which `destroy` requires.)* *(Amended 2026-10-28: `devUrl` and `devStop` act on the app and environment in their input; `dev` and `log` take `environment`.)* *(Amended by ADR-0064: `deploy` returns the summary as a value; it uses no result file.)* +- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* *(Amended by ADR-0064: the child exit-status passthrough no longer applies to `deploy`; `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED` are added.)* - [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 ORM 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. @@ -85,3 +85,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its configuration as one Standard Schema, `input`, exactly as a compute service does; its body receives opaque per-key references it can only pass into children's inputs or derive from with `resolve()`, so the graph cannot branch on configuration. Module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, filled by config adapters. *(Amended 2026-10-29: an input field is a `Value`; the module's schema validates its binding during Load's second pass; a truthiness check on a field is not caught, but always goes the same way.)* *(Amended 2026-10-29, third slice: `paramSource()` and `secretSource()` return values and take an options argument.)* *(Amended 2026-10-30: a root's input is the config pool.)* *(Amended 2026-10-30, fifth slice: the slots are removed; `ModuleNode`.)* *(Amended 2026-10-30, seventh slice: a module's schema checks each runtime value as its pointer; `paramSource()` loses `sentinel`.)* *(Amended 2026-10-30, eighth slice: only a plain field can be a `resolve()` dependency.)* - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. - [ADR-0063](ADR-0063-a-node-depends-on-an-interface-or-exposes-one.md) — The two ends of a connection are named by what the node does with them: a node **depends on** an interface (`deps`) or **exposes** one (`expose`); an interface is a `Contract` in code; Input and Output are retired as port words, because the dataflow and ports-and-adapters traditions give the same two ends opposite names; the module body's `inputs` becomes `deps`; the topology's `in`/`out` stay, defined as the dependency end and the exposed end. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043 and ADR-0044. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 76add876b..980f7d918 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -23,10 +23,7 @@ code. The `prisma` commands and the operations you call from a script sign in differently: -- **`prisma deploy`** signs in the way every `prisma` command does. On your - machine, run `prisma auth login` once; it opens a browser and stores a - session for one workspace. In CI, set `PRISMA_SERVICE_TOKEN` instead; it - overrides any stored session. `prisma dev` needs no sign-in. +- **`prisma deploy`** needs `PRISMA_SERVICE_TOKEN` in the environment, a service token you create in the workspace's settings in the [Prisma Console](https://console.prisma.io). It overrides any stored session. Deploy runs Alchemy in the `prisma` process, and Alchemy reads its credential only from the environment, so a `prisma auth login` session alone is not enough yet: without the variable, deploy fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before it does anything. `prisma dev` needs no sign-in. - **The operations of `@prisma/composer/control`**, and so a destroy script, never use a `prisma auth login` session. `deploy` and `destroy` read two environment variables, and each is required once the operation reaches @@ -46,11 +43,11 @@ entry module (`module.ts` here) and every module it imports, so those modules de `prisma` needs. Run it with your package manager by default: `pnpm prisma deploy` or `npx prisma deploy`. -Composer starts Alchemy's launcher with Node: the Node `prisma` runs on, or +`prisma deploy` runs Alchemy inside the `prisma` process, so Alchemy runs under whatever `prisma` runs under. `prisma dev`, and the `destroy` operation, start Alchemy's launcher as a separate process, with Node: the Node `prisma` runs on, or the first `node` on PATH when `prisma` runs under Bun (`DEPLOY.NODE_MISSING` if there is none). Alchemy's launcher then moves itself to Bun when the package-manager environment says Bun invoked it, which `bunx` and `bun run` -do. So the invocation decides where each ends up: +do. So for those, the invocation decides where each ends up: | Invocation | `prisma` runs under | Alchemy runs under | | --- | --- | --- | @@ -253,7 +250,7 @@ Assembling is one step per service. A Node service with `dependencies: 'external'` can take minutes here, because its runtime dependencies are traced and copied into the deploy artifact (see [Building an app § Builds](building-an-app.md#builds)). -Planning and applying run in one deploy engine process, so they are one step. +Inside `plan and apply`, each resource the deploy engine creates, updates or deletes is a step of its own, such as `▸ creating storefront-auth.web` and `✔ created storefront-auth.web (12.0s)`, and the engine's log lines print between them. The last line is the whole deploy, from start to finish. With `--json`, each step is a @@ -735,27 +732,17 @@ What to know before embedding it: deploy engine ran and failed (`DEPLOY.ENGINE_FAILED`), the message ends with the engine's own error lines — the failed resource and the error it printed, with credentials redacted and capped at 1000 characters — which a - deploy reporting to Prisma Cloud also records on the build. Its 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. + deploy reporting to Prisma Cloud also records on the build. A `deploy` stopped by its `signal` fails with `DEPLOY.INTERRUPTED`, after it has released the environment's lease. Importing the module executes nothing until you call an operation. - **`onEvent` reports each step.** Pass `onEvent` to `deploy` to receive a `step-started` and a `step-finished` event per step (`prepare`, `assemble`, - one `assemble-service` per service, `connect`, `preflight`, `apply`, - `record`), as they happen. A finished step has `outcome`, `durationMs` and + one `assemble-service` per service, `connect`, `preflight`, `apply`, one + `apply-resource` per resource the engine touches, `record`), as they happen, + and a `message` event for each line the engine logs. A finished step has `outcome`, `durationMs` and the `data` described in [What a deploy prints](#what-a-deploy-prints). When a step fails, it and the steps around it finish as `failed`; `record` still runs. An `onEvent` that throws does not fail the deploy. A successful result's `durationMs` is the whole deploy's total. -- **`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. +- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env`, changes to `cwd` when your process is elsewhere, and sends `console.log` to stderr, then restores all three, so run one deploy at a time per process. Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. - **`dev` resolves to `{ ok: true, value: session }` or a failure** — never an exit code. The session is `{ endpoints, stop(), closed }`, with progress (`ready`, `converge-failed`, `config-changed`, `watch-error`, …) delivered through diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index b05f27fda..420535c7e 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -50,8 +50,7 @@ You'll need: - [Bun](https://bun.sh) — Prisma Compute runs Bun, so that's what the server code targets (`Bun.serve`), and it's the fastest way to run things locally. - pnpm (or npm). -- For the deploy at the end: a Prisma Cloud workspace you can sign in to - with `prisma auth login`. +- For the deploy at the end: a Prisma Cloud workspace and a service token for it. (Naming, once: **Prisma Cloud** is the platform; **Prisma Compute** runs your services on it, and **Prisma Postgres** hosts the databases.) @@ -343,16 +342,14 @@ shared contract code into a chunk neither output contains: pnpm run build ``` -Deploying needs a signed-in `prisma`. Sign in once; it opens a browser and -stores a session for one workspace: +Deploying needs a service token. Create one in your workspace's settings in the [Prisma Console](https://console.prisma.io) and set it as `PRISMA_SERVICE_TOKEN`, on your machine and in CI alike: ```sh -pnpm prisma auth login +export PRISMA_SERVICE_TOKEN= pnpm prisma deploy ``` -In CI, set `PRISMA_SERVICE_TOKEN` to a service token from the -[Prisma Console](https://console.prisma.io) instead. +A `prisma auth login` session alone is not enough for `prisma deploy` yet; without the token it fails with `DEPLOY.SERVICE_TOKEN_REQUIRED`. `pnpm prisma` runs under Node, which suits this app: the modules `prisma` loads declare services and never call Bun APIs themselves. An app whose diff --git a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts index e99a76f12..f489a46aa 100644 --- a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts @@ -140,8 +140,8 @@ describe("invariant 2: the '.' authoring entry bundles lean", () => { }); }); -describe('invariant 4: core touches the environment only to read back the container transport it wrote', () => { - test("the process-env token appears only in control/deploy.ts (deserializeContainers(config, process.env), read-back for the parent→child container transport core itself owns — never a target's own vars)", () => { +describe('invariant 4: core touches the environment only to read back the transports it wrote', () => { + test("the process-env token appears only in control/deploy.ts (the container and preflight transports a generated stack file reads back — never a target's own vars)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index 7a61098f0..03516b216 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -42,6 +42,7 @@ import { resource, service, } from '../node.ts'; +import { preflightEnvVarName } from '../preflight-transport.ts'; import { resolve } from '../value.ts'; import { anyInputSchema, conn, providerContract } from './helpers.ts'; @@ -715,6 +716,7 @@ describe('lowering a module root — a single service', () => { opts: opts(), application: undefined, container: undefined, + preflight: undefined, lowered, provisioned: new Map(), }; @@ -1558,6 +1560,48 @@ describe('container threading through lowering() (the parent→child transport r ]); }); + test('containers passed as values win over the transport in the environment', () => { + const { descriptor, config, seen } = fakeExtensionWithContainer(); + const passed = new FakeContainerInstance({ appName: 'shop', environment: 'staging' }); + const stale = { appName: 'shop', environment: 'production' }; + + withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify(stale) }, () => { + run(lowering(dbModule(), config, opts({ containers: new Map([[descriptor.id, passed]]) }))); + }); + + expect(seen).toEqual([ + { phase: 'application', container: passed }, + { phase: 'resource', container: passed }, + ]); + }); + + test("each context carries its own extension's preflight payload: passed as values, else read from the environment", () => { + const seen: unknown[] = []; + const descriptor: ExtensionDescriptor = { + id: 'test/preflight-pack', + application: { + provision: (ctx) => { + seen.push(ctx.preflight); + return Effect.succeed(undefined); + }, + }, + nodes: {}, + }; + const config: PrismaAppConfig = { + extensions: [descriptor], + state: { extension: descriptor.id, create: () => stateSentinel('config') }, + }; + const root = module('shop', {}, () => ({})); + + withEnv({ [preflightEnvVarName(descriptor.id)]: 'from-env' }, () => { + run(lowering(root, config, opts({ preflight: new Map([[descriptor.id, 'passed']]) }))); + run(lowering(root, config, opts({ preflight: new Map() }))); + run(lowering(root, config, opts())); + }); + + expect(seen).toEqual(['passed', undefined, 'from-env']); + }); + test('an extension declaring no container descriptor at all sees ctx.container as undefined', () => { const seen: unknown[] = []; const descriptor: ExtensionDescriptor = { diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index f5d8dc170..f25cf1b24 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -17,6 +17,7 @@ import { type ServiceNode, slotsOf, } from '../node.ts'; +import { type PreflightPayload, readPreflightPayload } from '../preflight-transport.ts'; import type { ContainerInstance, ExtensionDescriptor, @@ -143,6 +144,8 @@ export interface LowerContext { * container descriptor. */ readonly container: ContainerInstance | undefined; + /** What the owning extension's preflight handed back this run; `undefined` when it returned nothing or ran no preflight. Core never reads it. */ + readonly preflight: PreflightPayload; /** Already-lowered deps (topo order). */ readonly lowered: ReadonlyMap; /** Every provisioned param value minted this lowering, keyed by edge id (ADR-0031). */ @@ -233,6 +236,10 @@ export interface LowerOptions { readonly report?: (result: DeploymentResult) => void; /** Addresses lowered without an artifact: `package` and `deploy` are skipped and `reserve` runs instead (ADR-0056). Only the dev stack file sets it. */ readonly unassembled?: readonly string[]; + /** Each extension's resolved container, by extension id. Absent means read them from the container transport in the environment (the generated stack file's path). */ + readonly containers?: ReadonlyMap; + /** Each extension's preflight payload, by extension id. Absent means read them from the preflight transport in the environment (the generated stack file's path). */ + readonly preflight?: ReadonlyMap; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -661,10 +668,11 @@ export function lowering( ...(opts.configPool !== undefined ? { configPool: opts.configPool } : {}), }); const extensions = yield* extensionsById(config); - // Each extension's own resolved container, deserialized once from the - // framework transport (the CLI parent wrote it; this is the alchemy - // child) — read-only for the rest of this lowering. - const containers = deserializeContainers(config.extensions, process.env); + const containers = containersOf(config, opts); + const preflightOf = (extensionId: string): PreflightPayload => + opts.preflight === undefined + ? readPreflightPayload(extensionId, process.env) + : opts.preflight.get(extensionId); const lowered = new Map(); // Each node's reported entities, in topo order — the loop is the only // party that holds both the node's identity and what it became. Collected @@ -692,6 +700,7 @@ export function lowering( opts, application: undefined, container: containers.get(descriptor.id), + preflight: preflightOf(descriptor.id), lowered, provisioned, }; @@ -774,6 +783,7 @@ export function lowering( opts, application: applications.get(node.extension), container: containers.get(node.extension), + preflight: preflightOf(node.extension), lowered, provisioned, services, @@ -872,16 +882,24 @@ export function lowering( }) as Effect.Effect; } +/** The containers `opts` carries, else the ones the generated stack file's process reads from the container transport. */ +function containersOf( + config: PrismaAppConfig, + opts: LowerOptions, +): ReadonlyMap { + return opts.containers ?? deserializeContainers(config.extensions, process.env); +} + /** * The whole-stack wrapper: Load → route each node through the config's - * extension registries → an Alchemy Stack (the default export the alchemy - * CLI consumes). + * extension registries → an Alchemy Stack, which `deploy` hands to + * `Alchemist.plan` and a generated stack file exports for the alchemy CLI. */ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOptions) { // A LowerError at deploy is fatal; orDie moves it off the error channel to // match what Alchemy.Stack accepts. const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; - const containers = deserializeContainers(config.extensions, process.env); + const containers = containersOf(config, opts); const providers = opts.providers ?? mergedProviders(config); return Alchemy.Stack( diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index f8de7c665..37276795b 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -35,6 +35,7 @@ "@types/cross-spawn": "^6.0.6", "@types/node": "^26.0.1", "alchemy": "catalog:", + "effect": "catalog:", "tsdown": "^0.22.7", "typescript": "^6.0.3" }, diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts new file mode 100644 index 000000000..51591b21f --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -0,0 +1,315 @@ +/** + * The in-process converge against Alchemy's real plan and apply engine, with + * an in-memory state store and a fake extension whose one resource node + * creates nothing on any platform. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { type Contract, module, resource } from '@internal/core'; +import type { ExtensionDescriptor, PrismaAppConfig } from '@internal/core/config'; +import { type DeploymentResult, type LowerOptions, lower } from '@internal/core/deploy'; +import * as State from 'alchemy/State'; +import { InMemoryService } from 'alchemy/State/InMemoryState'; +import * as Cause from 'effect/Cause'; +import * as Data from 'effect/Data'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import { + type ConvergeEvent, + type ConvergeRequest, + convergeInProcess, + failureText, + toConvergeEvent, +} from '../converge-in-process.ts'; +import { deployWithDeps } from '../operations/deploy.ts'; + +const thingContract: Contract<'fake/thing', unknown> = Object.freeze({ + kind: 'fake/thing', + __cmp: {}, + satisfies: (required: Contract<'fake/thing', unknown>) => required.kind === 'fake/thing', +}); + +const app = () => + module('shop', {}, (h) => { + h.provision(resource({ name: 'thing', extension: 'test/pack', provides: thingContract }), { + id: 'thing', + }); + return {}; + }); + +interface Fixture { + readonly config: PrismaAppConfig; + /** True once the state store's scope has closed, which is when a remote store releases its lease. */ + readonly released: () => boolean; +} + +function fixture(lowerThing: ExtensionDescriptor['nodes'][string]): Fixture { + let released = false; + const state = Layer.effect( + State.State, + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + released = true; + }), + ); + return InMemoryService({}); + }), + ); + const extension: ExtensionDescriptor = { id: 'test/pack', nodes: { 'fake/thing': lowerThing } }; + return { + config: { extensions: [extension], state: { extension: 'test/pack', create: () => state } }, + released: () => released, + }; +} + +const reportsThing = Object.assign( + () => + Effect.succeed({ + outputs: {}, + entities: [{ kind: 'thing', id: 'thing-1', url: 'https://thing.example' }], + }), + { kind: 'resource' as const }, +); + +function tmpDir(): string { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-converge-'))); +} + +function request( + config: PrismaAppConfig, + extra: Partial = {}, + overrides: Partial = {}, +): ConvergeRequest { + return { + stack: lower(app(), config, { name: 'shop', bundles: {}, ...extra }), + stage: 'test', + cwd: tmpDir(), + env: {}, + ...overrides, + }; +} + +describe('convergeInProcess', () => { + test('plans and applies in this process, and the report hook hands back the deployment as a value', async () => { + const { config, released } = fixture(reportsThing); + let reported: DeploymentResult | undefined; + const events: ConvergeEvent[] = []; + + const outcome = await convergeInProcess( + request( + config, + { + report: (result) => { + reported = result; + }, + }, + { onEvent: (event) => events.push(event) }, + ), + ); + + expect(outcome).toEqual({ kind: 'applied' }); + expect(reported?.nodes.map((node) => [node.address, node.entities])).toEqual([ + ['shop.thing', [{ kind: 'thing', id: 'thing-1', url: 'https://thing.example' }]], + ]); + expect(released()).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + kind: 'resource', + fqn: 'composer-deployment-report', + phase: 'done', + }), + ); + }); + + test('sets the requested environment and working directory only while it runs', async () => { + const seen: { token?: string | undefined; cwd?: string } = {}; + const { config } = fixture( + Object.assign( + () => + Effect.sync(() => { + seen.token = process.env['COMPOSER_TEST_TOKEN']; + seen.cwd = process.cwd(); + return { outputs: {}, entities: [] }; + }), + { kind: 'resource' as const }, + ), + ); + const cwd = tmpDir(); + const before = process.cwd(); + + await convergeInProcess(request(config, {}, { cwd, env: { COMPOSER_TEST_TOKEN: 'tok' } })); + + expect(seen).toEqual({ token: 'tok', cwd }); + expect(process.env['COMPOSER_TEST_TOKEN']).toBeUndefined(); + expect(process.cwd()).toBe(before); + }); + + test('a failing resource comes back as a failed outcome naming the error', async () => { + class ThingRefused extends Data.TaggedError('ThingRefused')<{ readonly message: string }> {} + const { config, released } = fixture( + Object.assign(() => Effect.fail(new ThingRefused({ message: 'the platform said no' })), { + kind: 'resource' as const, + }), + ); + + const outcome = await convergeInProcess(request(config)); + + expect(outcome).toEqual({ kind: 'failed', cause: 'ThingRefused: the platform said no' }); + expect(released()).toBe(true); + }); + + test('an abort interrupts the plan, and the state store scope still closes', async () => { + const controller = new AbortController(); + const { config, released } = fixture( + Object.assign( + () => + Effect.sync(() => controller.abort()).pipe( + Effect.andThen(Effect.never), + ) as Effect.Effect, + { kind: 'resource' as const }, + ), + ); + + const outcome = await convergeInProcess(request(config, {}, { signal: controller.signal })); + + expect(outcome).toEqual({ kind: 'interrupted' }); + expect(released()).toBe(true); + }); +}); + +const coreIndex = path.resolve(import.meta.dir, '../../../../1-core/core/src/exports/index.ts'); + +describe('deploy with the real in-process converge', () => { + test('returns the summary as a value, writes no stack file and needs no alchemy bin', async () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'prisma.config.ts'), 'export default {};\n'); + const entry = path.join(dir, 'module.ts'); + fs.writeFileSync( + entry, + [ + `import { module, service } from ${JSON.stringify(coreIndex)};`, + "export default module('shop', {}, (h) => {", + ' h.provision(', + " service({ name: 'api', extension: 'test/pack', type: 'fake/service', inputs: {}, params: {},", + " build: { extension: 'test/build', type: 'node', module: import.meta.url, entry: 'server.js' } }),", + " { id: 'api' },", + ' );', + ' return {};', + '});', + '', + ].join('\n'), + ); + const config: PrismaAppConfig = { + extensions: [ + { + id: 'test/pack', + nodes: { + 'fake/service': { + kind: 'service', + provision: () => Effect.succeed(undefined), + serialize: () => Effect.succeed(undefined), + package: () => Effect.succeed({ path: 'artifact.tgz', sha256: 'none' }), + deploy: () => + Effect.succeed({ + outputs: {}, + entities: [{ kind: 'service', id: 'svc-1', url: 'https://api.example' }], + }), + }, + }, + }, + { + id: 'test/build', + nodes: { + node: { + kind: 'build', + assemble: () => { + throw new Error('the test assembler stands in'); + }, + }, + }, + }, + ], + state: fixture(reportsThing).config.state, + }; + + const result = await deployWithDeps( + { + config: { value: config, file: path.join(dir, 'prisma.config.ts') }, + entry, + environment: 'test', + cwd: dir, + }, + { runAssembler: async () => ({ dir, entry: 'server.js' }) }, + ); + + if (!result.ok) throw result.failure; + expect(result.value.summary).toEqual({ + app: 'shop', + nodes: [ + { + address: 'shop.api', + entities: [{ kind: 'service', id: 'svc-1', url: 'https://api.example' }], + }, + ], + }); + expect(fs.existsSync(path.join(dir, '.prisma-composer', 'alchemy.run.ts'))).toBe(false); + }); +}); + +describe('toConvergeEvent', () => { + const status = (value: string) => + toConvergeEvent({ + type: 'apply.resource.status', + fqn: 'shop.api', + id: 'api', + resourceType: 'Prisma.Deployment', + status: value as never, + }); + + test("a resource's status becomes a phase: pending, running, done or failed", () => { + expect(status('pending')).toMatchObject({ phase: 'pending' }); + expect(status('creating')).toMatchObject({ phase: 'running', status: 'creating' }); + expect(status('replacing')).toMatchObject({ phase: 'running' }); + expect(status('created')).toMatchObject({ phase: 'done', fqn: 'shop.api' }); + expect(status('skipped')).toMatchObject({ phase: 'done' }); + expect(status('fail')).toMatchObject({ phase: 'failed' }); + }); + + test('a note becomes a log line, and plan progress is not shown', () => { + expect( + toConvergeEvent({ type: 'apply.resource.note', fqn: 'shop.api', id: 'api', message: 'hi' }), + ).toEqual({ kind: 'log', severity: 'info', text: 'shop.api: hi' }); + expect( + toConvergeEvent({ + type: 'apply.resource.note', + fqn: 'shop.api', + id: 'api', + message: 'npm ci', + kind: 'output', + }), + ).toMatchObject({ severity: 'verbose' }); + expect(toConvergeEvent({ type: 'plan.phase', phase: 'computing-plan' })).toBeUndefined(); + }); +}); + +describe('failureText', () => { + test('one line per distinct error, each with its tag', () => { + class Refused extends Data.TaggedError('Refused')<{ readonly message: string }> {} + const cause = Cause.combine( + Cause.fail(new Refused({ message: 'quota exceeded' })), + Cause.combine( + Cause.die(new Error('boom')), + Cause.fail(new Refused({ message: 'quota exceeded' })), + ), + ); + + expect(failureText(cause)).toBe('Refused: quota exceeded\nboom'); + }); + + test('a non-error failure is printed as text', () => { + expect(failureText(Cause.fail('plain'))).toBe('plain'); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts new file mode 100644 index 000000000..1fd58ccc7 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts @@ -0,0 +1,214 @@ +/** + * Runs a lowered stack's plan and apply in this process through Alchemy's + * programmatic API (`alchemy/Alchemist`). Alchemy's typed events and log + * lines come back as {@link ConvergeEvent}s, so the caller renders them in + * its own output format; nothing here writes to stdout. + * + * Reached only behind an operation's lazy executor import: it loads alchemy + * and effect. + */ +import type { lower } from '@internal/core/deploy'; +import * as Alchemist from 'alchemy/Alchemist'; +import { recordOf } from 'alchemy/Util/LogRecord'; +import * as Cause from 'effect/Cause'; +import * as Effect from 'effect/Effect'; +import * as Exit from 'effect/Exit'; +import * as Logger from 'effect/Logger'; + +/** What `lower()` returns: the stack value `Alchemist.plan` takes. */ +export type LoweredStack = ReturnType; + +/** Where a resource is in its apply. `pending` is planned but not yet touched. */ +export type ResourcePhase = 'pending' | 'running' | 'done' | 'failed'; + +export type ConvergeEvent = + | { + readonly kind: 'resource'; + readonly fqn: string; + readonly resourceType: string; + /** Alchemy's own status word, such as `creating` or `updated`. */ + readonly status: string; + readonly phase: ResourcePhase; + readonly message?: string | undefined; + } + | { + readonly kind: 'log'; + readonly severity: 'info' | 'warn' | 'verbose'; + readonly text: string; + }; + +export interface ConvergeRequest { + readonly stack: LoweredStack; + readonly stage: string; + /** The directory Alchemy runs in: where it reads `.env` and keeps `.alchemy`. */ + readonly cwd: string; + /** Set in `process.env` while Alchemy plans and applies, and restored after. Alchemy reads its configuration only from there. */ + readonly env: Readonly>; + readonly signal?: AbortSignal | undefined; + readonly onEvent?: ((event: ConvergeEvent) => void) | undefined; +} + +export type ConvergeOutcome = + | { readonly kind: 'applied' } + | { readonly kind: 'interrupted' } + | { readonly kind: 'failed'; readonly cause: string }; + +/** Plans and applies a lowered stack. The deploy executor's default is {@link convergeInProcess}. */ +export type Converge = (request: ConvergeRequest) => Promise; + +const FINISHED: ReadonlySet = new Set([ + 'created', + 'updated', + 'adopted', + 'deleted', + 'orphaned', + 'replaced', + 'ran', + 'skipped', +]); + +function phaseOf(status: string): ResourcePhase { + if (status === 'pending') return 'pending'; + if (status === 'fail') return 'failed'; + return FINISHED.has(status) ? 'done' : 'running'; +} + +/** Alchemy's typed event as a converge event; undefined for the events Composer does not show. */ +export function toConvergeEvent(event: Alchemist.Event): ConvergeEvent | undefined { + switch (event.type) { + case 'apply.resource.status': + return { + kind: 'resource', + fqn: event.fqn, + resourceType: event.resourceType, + status: event.status, + phase: phaseOf(event.status), + ...(event.message === undefined ? {} : { message: event.message }), + }; + case 'apply.resource.note': + return { + kind: 'log', + severity: event.kind === undefined ? 'info' : 'verbose', + text: `${event.fqn}: ${event.message}`, + }; + default: + return undefined; + } +} + +const SEVERITY_OF: Readonly> = { + info: 'info', + warn: 'warn', + error: 'warn', + fatal: 'warn', +}; + +/** Alchemy's log lines at info and above, as Alchemy's console sink would print them. Typed events are left to `Alchemist.onEvent`. */ +function logEvents(emit: (event: ConvergeEvent) => void) { + return Logger.make((options) => { + const record = recordOf(options); + const severity = SEVERITY_OF[record.level]; + if (severity === undefined) return; + if (record.kind === 'log') { + const fqn = record.attributes['fqn']; + const prefix = typeof fqn === 'string' ? `${fqn}: ` : ''; + emit({ kind: 'log', severity, text: `${prefix}${record.data.message}` }); + if (record.data.cause !== null) emit({ kind: 'log', severity, text: record.data.cause }); + } else if (record.kind === 'output') { + emit({ kind: 'log', severity: 'verbose', text: record.data.text }); + } + }); +} + +/** The failure a converge ended with, as one line per error: the error's name when it has one, then its message. */ +export function failureText(cause: Cause.Cause): string { + const lines = cause.reasons.flatMap((reason) => { + if (Cause.isInterruptReason(reason)) return []; + const error = Cause.isFailReason(reason) ? reason.error : reason.defect; + if (error instanceof Error) { + const tag = '_tag' in error && typeof error._tag === 'string' ? error._tag : error.name; + return [ + tag === 'Error' || error.message.startsWith(tag) + ? error.message + : `${tag}: ${error.message}`, + ]; + } + return [typeof error === 'string' ? error : String(error)]; + }); + return lines.length > 0 ? [...new Set(lines)].join('\n') : Cause.pretty(cause); +} + +/** + * Sets the process state Alchemy reads for the length of `run`, and restores + * it after: the variables in `env`, the working directory, and `console`'s + * stdout methods, which go to stderr so a provider's `console.log` cannot + * corrupt structured output. + */ +async function withProcessState( + env: Readonly>, + cwd: string, + run: () => Promise, +): Promise { + const previousEnv = Object.keys(env).map((name) => [name, process.env[name]] as const); + const previousCwd = process.cwd(); + const { log, info, debug } = console; + Object.assign(process.env, env); + if (previousCwd !== cwd) process.chdir(cwd); + console.log = console.error; + console.info = console.error; + console.debug = console.error; + try { + return await run(); + } finally { + Object.assign(console, { log, info, debug }); + if (process.cwd() !== previousCwd) process.chdir(previousCwd); + for (const [name, value] of previousEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +/** + * Plans and applies `stack` in this process, as `alchemy deploy --yes` would. + * The plan's scope, and the state-store lease it holds, closes before this + * returns, including when `signal` interrupts it. + */ +export const convergeInProcess: Converge = (request) => { + const emit = (event: ConvergeEvent): void => { + try { + request.onEvent?.(event); + } catch { + // Rendering is the caller's; its failures are not the deploy's. + } + }; + const program = Effect.gen(function* () { + const plan = yield* Alchemist.plan({ + stack: request.stack, + operation: 'deploy', + stage: request.stage, + updateStateStore: true, + }); + yield* Alchemist.apply(plan); + }).pipe( + Alchemist.onEvent((event) => + Effect.sync(() => { + const converted = toConvergeEvent(event); + if (converted !== undefined) emit(converted); + }), + ), + Effect.scoped, + Effect.provide(Logger.layer([logEvents(emit)])), + Effect.provide(Alchemist.layer()), + ); + + return withProcessState(request.env, request.cwd, async () => { + const exit = await Effect.runPromiseExit( + program, + request.signal === undefined ? undefined : { signal: request.signal }, + ); + if (Exit.isSuccess(exit)) return { kind: 'applied' }; + if (Cause.hasInterruptsOnly(exit.cause)) return { kind: 'interrupted' }; + return { kind: 'failed', cause: failureText(exit.cause) }; + }); +}; diff --git a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts index 0d9d5451e..d61dd8c0f 100644 --- a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts +++ b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts @@ -263,6 +263,22 @@ export function engineFailureCause( : `${cause.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; } +/** + * The cause an in-process converge failed with, fit for a failure message + * that may leave the machine: Composer's advice in place of Alchemy CLI + * advice, credentials and pool secrets removed, capped. + */ +export function convergeFailureCause( + cause: string, + env: Readonly>, + secrets: readonly string[], +): string { + const cleaned = redactValues(redactSecrets(withComposerAdvice(cause), env), secrets); + return cleaned.length <= ENGINE_FAILURE_CAUSE_LIMIT + ? cleaned + : `${cleaned.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; +} + /** Replaces each value of 8 characters or more, longest first, so a one-letter placeholder does not blank every matching letter. */ function redactValues(text: string, values: readonly string[]): string { let redacted = text; diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/converge-settlement.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/converge-settlement.test.ts deleted file mode 100644 index a6fa3a5fd..000000000 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/converge-settlement.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * The one settlement rule no real deploy run can demonstrate: what - * `settleConverge` does when the operation SUCCEEDED and its child was killed - * by a signal. - * - * Composer's own operations never produce that pair — deploy and dev - * each report a signal-killed converge as a failure — so the rule is stated - * here against a probe command that produces it deliberately. Composer used to - * settle this as an abort and present nothing; it now presents the result it - * has, while the engine still ends the run at 130 from its own record of the - * interrupt. - */ -import { describe, expect, test } from 'bun:test'; -import { ok } from '@internal/foundation/result'; -import { defineCommand, defineCommandFamily } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; -import { settleConverge } from '../converge.ts'; - -const AS_TTY = { isTty: { stdout: true, stderr: true } } as const; - -/** Runs a child, then settles a SUCCESSFUL converge over however that child ended. */ -const converged = defineCommand({ - help: { summary: 'Settle a successful converge over the child that just ran.' }, - maySpawn: true, - handler: async (_args, ctx) => { - await ctx.spawn({ command: 'alchemy' }); - return settleConverge(ok({ app: 'shop' }), ctx, (value) => - ctx.present( - { data: value }, - { - human: () => [{ kind: 'summary', status: 'ok', text: `Deployed ${value.app}.` }], - stdout: () => [], - json: () => value, - next: () => [], - }, - ), - ); - }, -}); - -const family = defineCommandFamily({ commands: { converged } }); - -describe('a converge that succeeded and was then interrupted', () => { - test('presents its result, and the engine still ends the run at 130', async () => { - // Ctrl-C reaches the whole process group: the child dies of the signal and - // the engine records that same signal, which is what aborting from inside - // the scripted child models. - const interrupt = new AbortController(); - const cli = createTestCli({ - commandFamilies: [family], - commands: { ...family.commands }, - spawnScript: () => { - interrupt.abort(); - return { exitCode: null, signal: 'SIGINT' }; - }, - }); - - const result = await cli.run(['converged'], { ...AS_TTY, abort: interrupt.signal }); - - expect(result.exitCode).toBe(130); - expect(result.presented?.data).toEqual({ app: 'shop' }); - }); -}); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index c1e108b28..41fffbd16 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -9,7 +9,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CliStructuredError } from '@internal/foundation/errors'; import { notOk, ok } from '@internal/foundation/result'; -import type { ScriptedChildProgram, TestCli } from '@prisma/cli-engine/testing'; +import type { TestCli } from '@prisma/cli-engine/testing'; import { createTestCli, mintTestJwt } from '@prisma/cli-engine/testing'; import { createOperationsDouble, type OperationsDouble } from '../../testing/operations-double.ts'; import { createComposerFamily } from '../family.ts'; @@ -18,28 +18,7 @@ import { validComposerSection } from './fixtures/composer-section.ts'; type Fixtures = Parameters[0]; -/** - * A real app directory with @prisma/composer and its `alchemy` installed, - * because the handler's spawn adapter resolves that alchemy's bin before - * handing the terminal over, as a real run does. The bin is never executed: - * the engine's scripted fake child stands in for the process. - */ -const CWD = (() => { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-family-'))); - const nodeModules = path.join(dir, 'node_modules'); - fs.mkdirSync(path.join(nodeModules, '@prisma', 'composer'), { recursive: true }); - fs.writeFileSync( - path.join(nodeModules, '@prisma', 'composer', 'package.json'), - JSON.stringify({ name: '@prisma/composer', exports: { './package.json': './package.json' } }), - ); - fs.mkdirSync(path.join(nodeModules, 'alchemy', 'bin'), { recursive: true }); - fs.writeFileSync( - path.join(nodeModules, 'alchemy', 'package.json'), - JSON.stringify({ name: 'alchemy', bin: { alchemy: './bin/cli.js' } }), - ); - fs.writeFileSync(path.join(nodeModules, 'alchemy', 'bin', 'cli.js'), ''); - return dir; -})(); +const CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-family-'))); /** A credential that is signed in and nowhere near expiry. */ function activeCredential() { @@ -63,8 +42,8 @@ interface Harness { function composerCli( spec: { readonly fixtures?: Fixtures; - readonly signedIn?: boolean; - readonly spawnScript?: ScriptedChildProgram; + /** How the run is signed in: a service token in the environment (the default), a `prisma login` session, or not at all. */ + readonly signedIn?: 'environment' | 'session' | false; } = {}, ): Harness { const double = createOperationsDouble(spec.fixtures ?? {}); @@ -76,8 +55,11 @@ function composerCli( // group; their own tests mount them alone. commands: { deploy: commandAt(family, 'deploy'), dev: commandAt(family, 'dev') }, config: { composer: validComposerSection() }, - ...(spec.signedIn === false ? {} : { credential: activeCredential() }), - ...(spec.spawnScript === undefined ? {} : { spawnScript: spec.spawnScript }), + ...(spec.signedIn === false + ? {} + : spec.signedIn === 'session' + ? { credential: activeCredential() } + : { environmentCredential: activeCredential() }), }); return { cli, double }; } @@ -89,16 +71,6 @@ const AS_TTY = { isTty: { stdout: true, stderr: true }, cwd: CWD } as const; const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); const plain = (text: string): string => text.replace(ANSI, ''); -const childExiting = (status: number): ScriptedChildProgram => - function scriptedExit() { - return { exitCode: status, signal: null }; - }; - -const childKilledBy = (signal: 'SIGINT' | 'SIGTERM'): ScriptedChildProgram => - function scriptedSignal() { - return { exitCode: null, signal }; - }; - describe('argument validation', () => { test('a leftover positional entry is refused by the grammar and never reaches the handler', async () => { for (const command of ['deploy', 'dev']) { @@ -193,18 +165,14 @@ describe('argument validation', () => { environment: 'feat-auth', cwd: CWD, onEvent: expect.any(Function), + signal: expect.any(AbortSignal), }, ]); }); }); -/** - * Engine 0.2.0 (prisma-cli#184): a spawning command uses the engine's normal - * structured-output contract. `--json` runs the command — the terminal frame - * on stdout is the machine surface, and the child's own output becomes - * diagnostics — instead of being refused up front as it was on engine 0.1.1. - */ -describe('--json on a spawning command', () => { +/** `--json` runs the command, and the terminal frame on stdout is the machine surface. */ +describe('--json', () => { for (const argv of [['deploy', '--entry', 'src/service.ts']]) { test(`\`${argv[0]} --json\` runs and emits one result frame`, async () => { const { cli, double } = composerCli(); @@ -234,82 +202,63 @@ describe('credentials', () => { expect(result.exitCode).toBe(2); expect(plain(result.stderr)).toContain('CLI.CREDENTIALS_REQUIRED'); - expect(result.spawns).toEqual([]); + expect(double.calls.deploy).toEqual([]); + }); + + test('a `prisma login` session without PRISMA_SERVICE_TOKEN is refused before the operation runs', async () => { + const { cli, double } = composerCli({ signedIn: 'session' }); + const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + + expect(result.exitCode).toBe(2); + expect(plain(result.stderr)).toContain('DEPLOY.SERVICE_TOKEN_REQUIRED'); + expect(plain(result.stderr)).toContain('set PRISMA_SERVICE_TOKEN'); expect(double.calls.deploy).toEqual([]); }); /** - * The child leg: the command declares `needs: { credentials: 'child' }`, so - * the engine composes the credential into the converge's environment. Only - * the KEY is observable here — the harness never records a value, so a - * fixture cannot carry token material. + * Alchemy runs in this process and reads its credential from + * `process.env`, so the handler hands the operation the token and the + * credential's workspace to set there for the apply. */ - test('the converge child is given the credential by the engine', async () => { - const { cli } = composerCli({ spawnScript: childExiting(0) }); + test('the service token and the workspace reach the converge environment, and no child is spawned', async () => { + const { cli, double } = composerCli(); const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); expect(result.exitCode).toBe(0); - expect(result.spawns[0]?.envKeys).toContain('PRISMA_SERVICE_TOKEN'); + expect(result.spawns).toEqual([]); + const deps = double.calls.deps.deploy[0]; + expect(Object.keys(deps?.convergeEnv ?? {}).sort()).toEqual([ + 'PRISMA_SERVICE_TOKEN', + 'PRISMA_WORKSPACE_ID', + ]); + expect(deps?.convergeEnv?.['PRISMA_WORKSPACE_ID']).toBe('ws_1'); + expect(deps?.credentials?.workspaceId).toBe('ws_1'); + expect(double.calls.deploy[0]?.signal).toBeInstanceOf(AbortSignal); }); }); -/** - * How a converge settles, which is the whole reason the commands run the - * child through `ctx.spawn` rather than describing the outcome themselves. - */ describe('settlement', () => { - test("a converge that exits non-zero settles with the child's own status, not a collapsed one", async () => { - const { cli } = composerCli({ spawnScript: childExiting(7) }); - const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); - - expect(result.exitCode).toBe(7); - }); - - test('a failed converge renders the reproduce hint and no failure envelope', async () => { - const { cli } = composerCli({ spawnScript: childExiting(1) }); + test('a failed converge renders the normal envelope and exits 2', async () => { + const { cli } = composerCli({ + fixtures: { + deploy: notOk( + new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + 'Alchemy could not deploy the app.\nPrismaApiError: quota exceeded', + ), + ), + }, + }); const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); - expect(result.exitCode).toBe(1); - expect(plain(result.stderr)).toContain( - `Run the converge directly from ${CWD} to reproduce this`, - ); - // The bin path is quoted when it contains a character the platform's shell - // treats specially, such as a space, so the path and the arguments are - // checked apart. - expect(plain(result.stderr)).toContain( - path.join(CWD, 'node_modules', 'alchemy', 'bin', 'cli.js'), - ); - expect(plain(result.stderr)).toContain( - 'deploy .prisma-composer/alchemy.run.ts --yes --stage test', - ); - expect(plain(result.stderr)).not.toContain('✖'); + expect(result.exitCode).toBe(2); + expect(plain(result.stderr)).toContain('DEPLOY.ENGINE_FAILED'); + expect(plain(result.stderr)).toContain('PrismaApiError: quota exceeded'); + expect(plain(result.stderr)).not.toContain('to reproduce this'); }); /** - * The rule this whole restructure exists for. A signal-killed child is the - * user aborting, not a deploy that failed: it settles 128 + the signal, and - * it renders NOTHING — no failure envelope, because nothing failed, and no - * reproduce hint, because the user stopped it and there is nothing to - * reproduce. The old CLI collapsed the signal into an exit code and reported - * a Ctrl-C'd deploy as an engine failure. - */ - for (const [signal, code] of [ - ['SIGINT', 130], - ['SIGTERM', 143], - ] as const) { - test(`a deploy whose converge is killed by ${signal} settles ${String(code)} in silence`, async () => { - const { cli } = composerCli({ spawnScript: childKilledBy(signal) }); - const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); - - expect(result.exitCode).toBe(code); - expect(plain(result.stderr)).toBe(''); - expect(plain(result.stdout)).toBe(''); - }); - } - - /** - * A failure that never reached the child is an ordinary structured error, so - * it gets the ordinary envelope — and composer's `fix` prose has to survive + * A structured failure gets the ordinary envelope — and composer's `fix` prose has to survive * the translation into the engine's `nextActions`, which is the one part of * an envelope that changes representation at the family boundary. */ @@ -490,6 +439,49 @@ describe('step events', () => { expect(plain(result.stderr)).toContain('Deployed shop to pr-7 in 12.3s.'); }); + test("each resource Alchemy applies is a step under apply, and Alchemy's log lines are messages", async () => { + const step = { + name: 'apply-resource', + fqn: 'shop.api', + resourceType: 'Prisma.Deployment', + } as const; + const { cli } = composerCli({ + fixtures: { + deploy, + deployEvents: [ + { kind: 'step-started', step: { name: 'apply' } }, + { kind: 'step-started', step: { ...step, status: 'creating' } }, + { kind: 'message', severity: 'info', text: 'shop.api: uploading 3 files' }, + { + kind: 'step-finished', + step: { ...step, status: 'created' }, + outcome: 'ok', + durationMs: 1_200, + }, + ], + }, + }); + const human = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + const json = await composerCli({ + fixtures: { + deploy, + deployEvents: [{ kind: 'step-started', step: { ...step, status: 'creating' } }], + }, + }).cli.run(['deploy', '--entry', 'src/service.ts', '--json'], AS_TTY); + + const lines = plain(human.stderr).split('\n'); + expect(lines).toContain('▸ creating shop.api'); + expect(lines).toContain('shop.api: uploading 3 files'); + expect(lines).toContain('✔ created shop.api (1.2s)'); + expect(json.json[0]).toMatchObject({ + kind: 'step-started', + step: 'creating shop.api', + id: 'deploy.apply.shop.api', + parentId: 'deploy.apply', + data: { resourceType: 'Prisma.Deployment' }, + }); + }); + test("a finished step's durationMs is the measured one, even when the adapter reported its own", async () => { const { cli } = composerCli({ fixtures: { diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts index f71052d30..6154d2dcb 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts @@ -1,21 +1,24 @@ /** - * `deploy` — a result command that hands the terminal to alchemy. + * `deploy` — a result command that runs Alchemy's plan and apply in this + * process and reports its progress as step events. * * `--production` is gone. It was accepted and then always errored ("only * valid with destroy"), so no invocation using it could ever have succeeded; * deploy targets the environment production when no `--environment` is given. */ +import { CliStructuredError } from '@internal/foundation/errors'; import type { EngineEvent } from '@prisma/cli-engine'; -import { defineCommand, flag } from '@prisma/cli-engine'; +import { defineCommand, flag, SERVICE_TOKEN_ENV_VAR } from '@prisma/cli-engine'; +import { notOk, ok } from '@prisma/cli-engine/protocol'; import { DEFAULT_DEPLOY_ENVIRONMENT, type DeployEvent, type DeployStep, } from '../../operations/deploy.ts'; -import { convergeSpawn, operationDeps, settleConverge } from '../converge.ts'; import { dataLine } from '../data-lines.ts'; import type { ComposerOperations } from '../family.ts'; import { composerSection } from '../section.ts'; +import { toEngineError } from '../translate-error.ts'; import { workspaceIdOf } from '../workspace.ts'; const STEP_LABELS = { @@ -28,13 +31,36 @@ const STEP_LABELS = { } as const; function stepLabel(step: DeployStep): string { - return step.name === 'assemble-service' ? `assemble ${step.address}` : STEP_LABELS[step.name]; + switch (step.name) { + case 'assemble-service': + return `assemble ${step.address}`; + case 'apply-resource': + return `${step.status} ${step.fqn}`; + default: + return STEP_LABELS[step.name]; + } } function stepId(step: DeployStep): string { - return step.name === 'assemble-service' - ? `deploy.assemble.${step.address}` - : `deploy.${step.name}`; + switch (step.name) { + case 'assemble-service': + return `deploy.assemble.${step.address}`; + case 'apply-resource': + return `deploy.apply.${step.fqn}`; + default: + return `deploy.${step.name}`; + } +} + +function parentId(step: DeployStep): string | undefined { + switch (step.name) { + case 'assemble-service': + return 'deploy.assemble'; + case 'apply-resource': + return 'deploy.apply'; + default: + return undefined; + } } /** `850ms`, `12.3s`, `9m 35s`. */ @@ -47,13 +73,20 @@ function formatDuration(ms: number): string { /** The operation's step events as engine events. The engine prints only `step`, so a finished step carries its duration in the text too. */ function toEngineEvent(event: DeployEvent): EngineEvent { + if (event.kind === 'message') { + return { kind: 'message', severity: event.severity, text: event.text }; + } const id = stepId(event.step); if (event.kind === 'step-started') { + const parent = parentId(event.step); return { kind: 'step-started', step: stepLabel(event.step), id, - ...(event.step.name === 'assemble-service' ? { parentId: 'deploy.assemble' } : {}), + ...(parent === undefined ? {} : { parentId: parent }), + ...(event.step.name === 'apply-resource' + ? { data: { resourceType: event.step.resourceType } } + : {}), }; } return { @@ -115,10 +148,13 @@ export const createDeployCommand = (operations: ComposerOperations) => }), }, }, - needs: { config: composerSection, credentials: 'child' }, - maySpawn: true, + needs: { config: composerSection, credentials: true }, handler: async (args, ctx) => { - const alchemy = convergeSpawn(ctx); + const token = ctx.env[SERVICE_TOKEN_ENV_VAR]; + if (token === undefined || token.length === 0) { + return notOk(toEngineError(serviceTokenRequired())); + } + const workspaceId = await workspaceIdOf(ctx); const result = await operations.deploy( { entry: args.flags.entry, @@ -129,24 +165,29 @@ export const createDeployCommand = (operations: ComposerOperations) => reportPath: args.flags.report, reportId: args.flags.buildId, onEvent: (event) => ctx.report(toEngineEvent(event)), + signal: ctx.signal, + }, + { + credentials: { workspaceId, client: ctx.api }, + convergeEnv: { + [SERVICE_TOKEN_ENV_VAR]: token, + ...(workspaceId === undefined ? {} : { PRISMA_WORKSPACE_ID: workspaceId }), + }, }, - operationDeps({ - alchemy, - workspaceId: await workspaceIdOf(ctx), - client: ctx.api, - }), ); + if (!result.ok) return notOk(toEngineError(result.failure)); - return settleConverge(result, ctx, ({ summary, durationMs }) => { - const target = `to ${args.flags.environment ?? DEFAULT_DEPLOY_ENVIRONMENT} in ${formatDuration(durationMs)}`; - for (const node of summary?.nodes ?? []) { - for (const entity of node.entities) { - if (entity.url !== undefined) { - ctx.report({ kind: 'endpoint', name: node.address, url: entity.url }); - } + const { summary, durationMs } = result.value; + const target = `to ${args.flags.environment ?? DEFAULT_DEPLOY_ENVIRONMENT} in ${formatDuration(durationMs)}`; + for (const node of summary?.nodes ?? []) { + for (const entity of node.entities) { + if (entity.url !== undefined) { + ctx.report({ kind: 'endpoint', name: node.address, url: entity.url }); } } - return ctx.present( + } + return ok( + ctx.present( { data: { summary: summary ?? null, durationMs } }, { human: (ui) => @@ -176,7 +217,23 @@ export const createDeployCommand = (operations: ComposerOperations) => json: () => ({ summary: summary ?? null, durationMs }), next: () => [], }, - ); - }); + ), + ); }, }); + +/** + * Alchemy runs in this process and reads its Prisma credential only from the + * environment, and the engine gives a command no way to read a `prisma login` + * session's token. So deploy needs the service token in the environment. + */ +function serviceTokenRequired(): CliStructuredError { + return new CliStructuredError( + 'DEPLOY.SERVICE_TOKEN_REQUIRED', + `\`prisma deploy\` needs ${SERVICE_TOKEN_ENV_VAR} in the environment.`, + { + why: 'Alchemy applies the deploy in this process and reads its Prisma credential from the environment. A `prisma login` session cannot reach it yet.', + fix: `Create a service token for the workspace in the Prisma Console and set ${SERVICE_TOKEN_ENV_VAR} to it, then deploy again.`, + }, + ); +} diff --git a/packages/0-framework/3-tooling/cli/src/family/converge.ts b/packages/0-framework/3-tooling/cli/src/family/converge.ts index 5635400bb..fe2a54db5 100644 --- a/packages/0-framework/3-tooling/cli/src/family/converge.ts +++ b/packages/0-framework/3-tooling/cli/src/family/converge.ts @@ -1,40 +1,18 @@ /** - * The `deploy` command's converge: the two-leg authenticated run of a - * converge, and the settlement rules for how the child ended. - * - * The two legs (S3 § Auth). The IN-PROCESS leg is the pre-work that happens - * in this process — config evaluation, container ensure/locate, preflight — - * and it authenticates through composer's own injection seam: the engine's - * pinned, refreshing API client goes in as the operation deps' client, and - * the workspace id comes from the engine's credential read. Nothing here - * reads the environment for token material. The CHILD leg is the engine's: - * it composes the credential into the child environment at spawn time because - * the command declares `needs: { credentials: 'child' }`, which is also what - * makes the engine refuse a near-expiry credential BEFORE the handler runs — - * before the in-process leg creates anything on any platform. + * The `dev` command's converge child: the spawn adapter backed by the + * engine's `ctx.spawn`, and the settlement rules for how the child ended. + * `deploy` converges in this process and settles as an ordinary command. */ import type { CliStructuredError as ComposerError } from '@internal/foundation/errors'; -import type { Result as ComposerResult } from '@internal/foundation/result'; -import type { - ChildResult, - ChildStatusSettlement, - ManagementApiClient, - PresentedResult, -} from '@prisma/cli-engine'; +import type { ChildResult, ChildStatusSettlement } from '@prisma/cli-engine'; import { exitWithChildStatus } from '@prisma/cli-engine'; import type { CliStructuredError, NextAction, Result } from '@prisma/cli-engine/protocol'; import { notOk, ok } from '@prisma/cli-engine/protocol'; -import { executionDiagnostics, type OperationDeps } from '../operations/shared.ts'; +import { executionDiagnostics } from '../operations/shared.ts'; import { alchemyCommandLine, type RunAlchemy } from '../run-alchemy.ts'; import { toEngineError } from './translate-error.ts'; -/** What a result command's handler may settle with. */ -export type HandlerResult = Result< - PresentedResult | ChildStatusSettlement, - CliStructuredError ->; - /** The subset of the engine context this module needs, so the helpers stay * testable without building a whole context. */ export interface ConvergeContext { @@ -77,19 +55,6 @@ export function convergeSpawn(ctx: ConvergeContext): RunAlchemy { }; } -/** The in-process leg's deps: composer's existing seam, carrying the engine's - * own client and workspace id rather than anything read from the env. */ -export function operationDeps(spec: { - readonly alchemy: RunAlchemy; - readonly workspaceId: string | undefined; - readonly client: ManagementApiClient; -}): OperationDeps { - return { - alchemy: spec.alchemy, - credentials: { workspaceId: spec.workspaceId, client: spec.client }, - }; -} - /** * The reproduce hint a failed converge carries. Deliberately absent for an * aborted one: the user stopped it, so there is nothing to reproduce. @@ -108,14 +73,14 @@ export function reproduceHint(failure: ComposerError): readonly NextAction[] { } /** - * How a FAILED converge settles. `deploy` and `dev` both end this - * way and must not drift apart, so the decision lives here once rather than - * inline in each handler. + * How a FAILED `dev` converge settles. * * A failure that reached the child exits with the child's status verbatim and * carries the reproduce hint; a failure that never reached the child is an * ordinary structured error and gets the normal envelope. Nothing here reads - * `signal` — see settleConverge. + * `signal`: `exitWithChildStatus` settles from the engine's own record of the + * child, and a signal-killed one overrules it with 128 + the signal and no + * reproduce hint. */ export function settleConvergeFailure( failure: ComposerError, @@ -127,34 +92,3 @@ export function settleConvergeFailure( } return notOk(toEngineError(failure)); } - -/** - * How a converge settles, now that the engine owns the child's status. - * - * Nothing here reads `signal`. `exitWithChildStatus` settles from the engine's - * own record of the child, and a signal-killed one overrules whatever this - * function asked for: 128 + the signal, no failure envelope, and the reproduce - * hint dropped, because the user stopped the run and there is nothing to - * reproduce. - * - * What is left is the operation's own verdict. A failure that reached the - * child exits with the child's status verbatim and carries the reproduce hint; - * a failure that never reached the child is an ordinary structured error and - * gets the normal envelope. - * - * A SUCCESS presents its result even when the child was signal-killed. No - * composer operation produces that pair today — each one reports a - * signal-killed converge as a failure — and an operation that ever did would - * have a real result worth showing. The run still exits 130, because the - * engine settles a signal-terminated run from its own record of the signal - * whatever the handler returns. - */ -export function settleConverge( - result: ComposerResult, - ctx: ConvergeContext, - present: (value: T) => PresentedResult, -): HandlerResult { - if (!result.ok) return settleConvergeFailure(result.failure, ctx); - - return ok(present(result.value)); -} diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 2de18ef54..fa740e15a 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -29,13 +29,9 @@ import { CliStructuredError } from '@internal/foundation/errors'; import * as Layer from 'effect/Layer'; import type { ComposerConfigSource } from '../../composer-config.ts'; import { dotenv } from '../../config-adapters.ts'; -import { - DEPLOYMENT_RESULT_FILE_ENV, - type DeploymentSummary, - engineFailureFilePath, -} from '../../deployment-summary.ts'; +import type { Converge, ConvergeOutcome, ConvergeRequest } from '../../converge-in-process.ts'; +import { DEPLOYMENT_RESULT_FILE_ENV, engineFailureFilePath } from '../../deployment-summary.ts'; import type { AppIdentity } from '../../pipeline.ts'; -import { type AlchemyInvocation, alchemyCommandLine } from '../../run-alchemy.ts'; import { RUN_REPORT_VERSION } from '../../run-report.ts'; import { type DeployEvent, deployWithDeps } from '../deploy.ts'; import { type DestroyInput, destroyWithDeps } from '../destroy.ts'; @@ -142,7 +138,8 @@ function fakeConfig( }, { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, ], - state: { extension: 'fixture-extension', create: unused }, + // lower() builds the stack value eagerly; nothing here ever plans it. + state: { extension: 'fixture-extension', create: () => Layer.empty as never }, }; } @@ -201,15 +198,26 @@ const fakeAssembler = async (node: ServiceNode) => ({ entry: 'server.js', }); -const summaryFixture: DeploymentSummary = { - app: 'hello-ops', - nodes: [{ address: 'app', entities: [{ kind: 'compute-service', id: 'cps_1' }] }], -}; +const APPLIED: ConvergeOutcome = { kind: 'applied' }; + +/** Stands in for Alchemy's plan and apply: deploy tests never run the real engine here. */ +const applied: Converge = async () => APPLIED; + +function recordingConverge( + requests: ConvergeRequest[], + outcome: ConvergeOutcome = APPLIED, +): Converge { + return async (request) => { + requests.push(request); + return outcome; + }; +} describe('deploy()', () => { - test('a successful deploy passes the result-file env var to alchemy and returns the summary the child wrote', async () => { + test('a successful deploy converges the lowered app in this process: no stack file, no child', async () => { const app = makeAppDir('hello-ops'); - const calls: AlchemyInvocation[] = []; + const requests: ConvergeRequest[] = []; + let spawned = false; const result = await silently(() => deployWithDeps( @@ -221,25 +229,27 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - calls.push(input); - const file = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; - if (typeof file === 'string') fs.writeFileSync(file, JSON.stringify(summaryFixture)); + converge: recordingConverge(requests), + alchemy: async () => { + spawned = true; return { exitCode: 0, signal: null }; }, + convergeEnv: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler' }, }, ), ); - expect(calls).toHaveLength(1); - const resultFile = calls[0]?.env?.[DEPLOYMENT_RESULT_FILE_ENV]; - expect(resultFile).toStartWith(path.join(app.dir, '.prisma-composer', 'deployment-result-')); - expect(resultFile).toEndWith('.json'); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error('unreachable'); - expect(result.value.summary).toEqual(summaryFixture); - // Read once, then removed — nothing left for a later run to misread. - expect(fs.existsSync(resultFile ?? '')).toBe(false); + if (!result.ok) throw result.failure; + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + stage: 'ci-7', + cwd: app.dir, + env: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler' }, + }); + expect(requests[0]?.stack.stackName).toBe('hello-ops'); + expect(spawned).toBe(false); + expect(fs.existsSync(path.join(app.dir, '.prisma-composer', 'alchemy.run.ts'))).toBe(false); + expect(result.value.summary).toBeUndefined(); }); test("the config's entry resolves against the config file's directory, not cwd", async () => { @@ -262,9 +272,9 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => { + converge: async () => { ran = true; - return { exitCode: 0, signal: null }; + return APPLIED; }, }, ), @@ -291,7 +301,7 @@ describe('deploy()', () => { ...(input.entry === undefined ? {} : { entry: input.entry }), cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ), ); @@ -323,9 +333,9 @@ describe('deploy()', () => { { config: composerConfig(fakeConfig()), cwd: app.dir }, { runAssembler: fakeAssembler, - alchemy: async () => { + converge: async () => { ran = true; - return { exitCode: 0, signal: null }; + return APPLIED; }, }, ), @@ -339,177 +349,10 @@ describe('deploy()', () => { expect(ran).toBe(false); }); - test('a deploy whose child wrote no result file still succeeds, with an undefined summary', async () => { - const app = makeAppDir('hello-ops'); - - const result = await silently(() => - deployWithDeps( - { - config: composerConfig(fakeConfig()), - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), - }, - ), - ); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error('unreachable'); - expect(result.value.summary).toBeUndefined(); - }); - - test('a malformed result file is treated as absent, never a deploy failure', async () => { - const app = makeAppDir('hello-ops'); - - const result = await silently(() => - deployWithDeps( - { - config: composerConfig(fakeConfig()), - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async (input) => { - const file = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; - if (typeof file === 'string') fs.writeFileSync(file, '{"app": 42, "nodes": "nope"'); - return { exitCode: 0, signal: null }; - }, - }, - ), - ); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error('unreachable'); - expect(result.value.summary).toBeUndefined(); - }); - - test("each run's result file is its own — a stale file from another run is never read, and two runs never share a path", async () => { - const app = makeAppDir('hello-ops'); - fs.mkdirSync(path.join(app.dir, '.prisma-composer'), { recursive: true }); - fs.writeFileSync( - path.join(app.dir, '.prisma-composer', 'deployment-result-99999-stale.json'), - JSON.stringify(summaryFixture), - ); - const resultFiles: (string | undefined)[] = []; - const deps = { - runAssembler: fakeAssembler, - alchemy: async (input: AlchemyInvocation) => { - resultFiles.push(input.env?.[DEPLOYMENT_RESULT_FILE_ENV]); - return { exitCode: 0, signal: null }; - }, - }; - - const first = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - deps, - ), - ); - const second = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - deps, - ), - ); - - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - expect(resultFiles).toHaveLength(2); - expect(resultFiles[0]).not.toBe(resultFiles[1]); - }); - - test('a failed deploy removes the result file the child already wrote', async () => { - const app = makeAppDir('hello-ops'); - let resultFile: string | undefined; - - const result = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - { - runAssembler: fakeAssembler, - alchemy: async (input) => { - resultFile = input.env?.[DEPLOYMENT_RESULT_FILE_ENV]; - if (typeof resultFile === 'string') { - fs.writeFileSync(resultFile, JSON.stringify(summaryFixture)); - } - return { exitCode: 1, signal: null }; - }, - }, - ), - ); - - expect(result.ok).toBe(false); - expect(resultFile).toBeDefined(); - expect(fs.existsSync(resultFile ?? '')).toBe(false); - }); - - test('the summary round-trips through a real child process writing via the report writer', async () => { - const app = makeAppDir('hello-ops'); - const writerPath = fileURLToPath(new URL('../../deployment-summary.ts', import.meta.url)); - const childPath = path.join(app.dir, 'report-child.ts'); - fs.writeFileSync( - childPath, - `import { writeDeploymentSummaryFile } from ${JSON.stringify(writerPath)};\n` + - 'const result = {\n' + - " app: 'hello-ops',\n" + - " nodes: [{ address: 'app', node: undefined, entities: [{ kind: 'compute-service', id: 'cps_1' }] }],\n" + - '} as never;\n' + - 'writeDeploymentSummaryFile(result);\n', - ); - - const result = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - { - runAssembler: fakeAssembler, - alchemy: async (input) => { - const child = spawnSync(process.execPath, [childPath], { - cwd: app.dir, - env: { ...process.env, ...input.env }, - encoding: 'utf-8', - }); - expect(child.stderr).toBe(''); - return { exitCode: child.status ?? 1, signal: null }; - }, - }, - ), - ); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error('unreachable'); - expect(result.value.summary).toEqual(summaryFixture); - }, 15_000); - test('without an environment, deploy targets production and runs Alchemy under that name when the container pins no stage', async () => { const app = makeAppDir(); const containerCalls: ContainerCall[] = []; - const invocations: AlchemyInvocation[] = []; + const requests: ConvergeRequest[] = []; const result = await silently(() => deployWithDeps( @@ -520,10 +363,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - invocations.push(input); - return { exitCode: 0, signal: null }; - }, + converge: recordingConverge(requests), }, ), ); @@ -533,7 +373,7 @@ describe('deploy()', () => { op: 'ensure', input: { appName: 'fixture-app', environment: 'production' }, }); - expect(invocations[0]?.stage).toBe('production'); + expect(requests[0]?.stage).toBe('production'); }); test('an invalid environment name is an invalid-input failure, before any container call', async () => { @@ -550,7 +390,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -566,7 +406,7 @@ describe('deploy()', () => { const app = makeAppDir(); const containerCalls: ContainerCall[] = []; let assembled = false; - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => deployWithDeps( @@ -581,9 +421,9 @@ describe('deploy()', () => { assembled = true; return fakeAssembler(node); }, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; + converge: async () => { + convergeRan = true; + return APPLIED; }, }, ), @@ -593,7 +433,7 @@ describe('deploy()', () => { if (result.ok) throw new Error('unreachable'); expect(result.failure.message).toContain('The app name "acme.shop" is not valid.'); expect(assembled).toBe(false); - expect(alchemyRan).toBe(false); + expect(convergeRan).toBe(false); expect(containerCalls).toEqual([]); }); @@ -602,21 +442,21 @@ describe('deploy()', () => { app: { dir: string; entryPath: string }, config: ComposerConfigSource, ) { - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => deployWithDeps( { config, entry: app.entryPath, cwd: app.dir }, { runAssembler: fakeAssembler, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; + converge: async () => { + convergeRan = true; + return APPLIED; }, }, ), ); if (result.ok) throw new Error('expected a failure'); - return { failure: result.failure, alchemyRan }; + return { failure: result.failure, convergeRan }; } test('the whole prisma.config.ts export in place of its composer property', async () => { @@ -624,7 +464,7 @@ describe('deploy()', () => { const containerCalls: ContainerCall[] = []; const wholeExport = { $prismaConfig: 1, composer: fakeConfig({}, { calls: containerCalls }) }; - const { failure, alchemyRan } = await deployWith(app, { + const { failure, convergeRan } = await deployWith(app, { value: wholeExport as unknown as PrismaAppConfig, file: 'prisma.config.ts', }); @@ -635,7 +475,7 @@ describe('deploy()', () => { where: { path: path.join(app.dir, 'prisma.config.ts') }, }); expect(containerCalls).toEqual([]); - expect(alchemyRan).toBe(false); + expect(convergeRan).toBe(false); }); test('an old prisma-composer.config.ts beside config.file', async () => { @@ -643,7 +483,7 @@ describe('deploy()', () => { const containerCalls: ContainerCall[] = []; fs.writeFileSync(path.join(app.dir, 'prisma-composer.config.ts'), 'export default {};\n'); - const { failure, alchemyRan } = await deployWith( + const { failure, convergeRan } = await deployWith( app, composerConfig(fakeConfig({}, { calls: containerCalls })), ); @@ -653,7 +493,7 @@ describe('deploy()', () => { message: `${path.join(app.dir, 'prisma-composer.config.ts')} is no longer read.`, }); expect(containerCalls).toEqual([]); - expect(alchemyRan).toBe(false); + expect(convergeRan).toBe(false); }); test('every finding, in the order the CLI reports them', async () => { @@ -707,9 +547,9 @@ describe('deploy()', () => { }); }); - test('an extension-preflight throw is a pipeline failure — alchemy never runs, no stack file is written', async () => { + test('an extension-preflight throw is a pipeline failure — alchemy never runs', async () => { const app = makeAppDir('hello-preflight-fail'); - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => deployWithDeps( @@ -727,9 +567,9 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; + converge: async () => { + convergeRan = true; + return APPLIED; }, }, ), @@ -739,11 +579,10 @@ describe('deploy()', () => { if (result.ok) throw new Error('unreachable'); expect(result.failure.code).toBe('DEPLOY.PREFLIGHT_FAILED'); expect(result.failure.message).toContain('SECRET_X is not provisioned'); - expect(alchemyRan).toBe(false); - expect(fs.existsSync(path.join(app.dir, '.prisma-composer', 'alchemy.run.ts'))).toBe(false); + expect(convergeRan).toBe(false); }); - test('an alchemy exit 42 is an execution failure carrying the exit code and both hint fields', async () => { + test("a failed converge is DEPLOY.ENGINE_FAILED carrying Alchemy's cause, with Composer's advice in place of the Alchemy CLI's", async () => { const app = makeAppDir(); const result = await silently(() => @@ -756,91 +595,22 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 42, signal: null }), - }, - ), - ); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(result.failure.code).toBe('DEPLOY.ENGINE_FAILED'); - expect(result.failure.message).toBe('alchemy deploy exited with status 42.'); - expect(executionDiagnostics(result.failure)).toEqual({ - exitCode: 42, - stackFilePath: path.join(app.dir, '.prisma-composer', 'alchemy.run.ts'), - reproduceCommand: `alchemy deploy ${path.join('.prisma-composer', 'alchemy.run.ts')} --yes --stage ci-7`, - cwd: app.dir, - }); - }); - - test('the reproduce command is the command line the adapter started', async () => { - const app = makeAppDir(); - - const result = await silently(() => - deployWithDeps( - { - config: composerConfig(fakeConfig()), - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async (invocation) => ({ - exitCode: 1, - signal: null, - commandLine: alchemyCommandLine( - invocation, - '/store/alchemy/bin/cli.js', - '/usr/bin/node', - ), + converge: async () => ({ + kind: 'failed', + cause: + 'PrismaApiError: the bucket is held by another app. Re-run with `--adopt` (or wrap the effect in `adopt(true)`) to take it over.', }), }, ), ); - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(executionDiagnostics(result.failure)?.reproduceCommand).toBe( - `/usr/bin/node /store/alchemy/bin/cli.js deploy ${path.join('.prisma-composer', 'alchemy.run.ts')} --yes --stage ci-7`, - ); - }); - - test('an engine failure carries the cause the child recorded, after the status sentence and in meta', async () => { - const app = makeAppDir(); - let failureFile: string | undefined; - - const result = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - { - runAssembler: fakeAssembler, - alchemy: async (input) => { - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (resultFile === undefined) throw new Error('the result-file env var must be set'); - failureFile = engineFailureFilePath(resultFile); - fs.writeFileSync(failureFile, '[web] fail — PrismaApiError: 409 Conflict'); - return { exitCode: 1, signal: null }; - }, - }, - ), - ); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); + if (result.ok) throw new Error('expected a failure'); + const cause = + 'PrismaApiError: the bucket is held by another app. Something other than this app holds it. Remove that resource, then deploy again.'; expect(result.failure.code).toBe('DEPLOY.ENGINE_FAILED'); - expect(result.failure.message).toBe( - 'alchemy deploy exited with status 1.\n[web] fail — PrismaApiError: 409 Conflict', - ); - expect(result.failure.meta?.['engineCause']).toBe('[web] fail — PrismaApiError: 409 Conflict'); - expect(result.failure.meta?.['exitCode']).toBe(1); - expect(executionDiagnostics(result.failure)?.exitCode).toBe(1); - expect(fs.existsSync(failureFile ?? '')).toBe(false); + expect(result.failure.message).toBe(`Alchemy could not deploy the app.\n${cause}`); + expect(result.failure.meta?.['engineCause']).toBe(cause); + expect(executionDiagnostics(result.failure)).toBeUndefined(); }); test('an engine failure cause never carries a value the pools read from a dotenv file', async () => { @@ -870,15 +640,10 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (resultFile === undefined) throw new Error('the result-file env var must be set'); - fs.writeFileSync( - engineFailureFilePath(resultFile), - `error: rejected ${onlyInDotenvFile}, ${inBothPools} and ${secretNamedConfigValue}`, - ); - return { exitCode: 1, signal: null }; - }, + converge: async () => ({ + kind: 'failed', + cause: `error: rejected ${onlyInDotenvFile}, ${inBothPools} and ${secretNamedConfigValue}`, + }), }, ), ); @@ -891,32 +656,11 @@ describe('deploy()', () => { expect(JSON.stringify(result.failure)).not.toContain('SENTINEL'); }); - test('an engine failure with no recorded cause keeps the bare status sentence and no engineCause', async () => { - const app = makeAppDir(); - - const result = await silently(() => - deployWithDeps( - { - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - config: composerConfig(fakeConfig()), - }, - { - runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 1, signal: null }), - }, - ), - ); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(result.failure.message).toBe('alchemy deploy exited with status 1.'); - expect(result.failure.meta).not.toHaveProperty('engineCause'); - }); - - test('an interrupted converge reports only its signal, since a killed child records no cause', async () => { + test('an interrupted converge is DEPLOY.INTERRUPTED, and the apply step closes as failed', async () => { const app = makeAppDir(); + const events: DeployEvent[] = []; + const controller = new AbortController(); + const requests: ConvergeRequest[] = []; const result = await silently(() => deployWithDeps( @@ -925,53 +669,21 @@ describe('deploy()', () => { environment: 'ci-7', cwd: app.dir, config: composerConfig(fakeConfig()), + onEvent: (event) => events.push(event), + signal: controller.signal, }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (resultFile === undefined) throw new Error('the result-file env var must be set'); - fs.writeFileSync(engineFailureFilePath(resultFile), 'lease held by another deploy'); - return { exitCode: null, signal: 'SIGTERM' }; - }, + converge: recordingConverge(requests, { kind: 'interrupted' }), }, ), ); - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(result.failure.message).toBe('alchemy deploy was interrupted by SIGTERM.'); - expect(result.failure.meta?.['signal']).toBe('SIGTERM'); + if (result.ok) throw new Error('expected a failure'); + expect(requests[0]?.signal).toBe(controller.signal); + expect(result.failure.code).toBe('DEPLOY.INTERRUPTED'); expect(result.failure.meta?.['engineCause']).toBeUndefined(); - }); - - test('.prisma-composer existing as a FILE is a pipeline failure, not a rejection', async () => { - const app = makeAppDir('hello-ops'); - fs.writeFileSync(path.join(app.dir, '.prisma-composer'), 'not a directory'); - let alchemyRan = false; - - const result = await silently(() => - deployWithDeps( - { - config: composerConfig(fakeConfig()), - entry: app.entryPath, - environment: 'ci-7', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; - }, - }, - ), - ); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error('unreachable'); - expect(result.failure.code).toBe('DEPLOY.STACK_WRITE_FAILED'); - expect(alchemyRan).toBe(false); + expect(events.map(stepLine)).toContain('finished apply failed'); }); test('an executor that cannot load is a structured failure carrying the import error — the host stays alive', () => { @@ -1043,10 +755,13 @@ describe('deploy()', () => { /** Each event as `kind name[:address] outcome`, for asserting order without timings. */ function stepLine(event: DeployEvent): string { + if (event.kind === 'message') return `message ${event.text}`; const name = event.step.name === 'assemble-service' ? `assemble-service:${event.step.address}` - : event.step.name; + : event.step.name === 'apply-resource' + ? `apply-resource:${event.step.fqn}` + : event.step.name; return event.kind === 'step-started' ? `started ${name}` : `finished ${name} ${event.outcome}`; } @@ -1077,7 +792,7 @@ describe('deploy() step events', () => { report?.({ bytes: 4096 }); return fakeAssembler(node); }, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -1112,6 +827,94 @@ describe('deploy() step events', () => { expect(result.value.durationMs).toBeGreaterThanOrEqual(0); }); + test("Alchemy's progress becomes one step per resource inside apply, and its log lines become messages", async () => { + const app = makeAppDir('hello-resources'); + const events: DeployEvent[] = []; + const resource = (status: string, phase: 'pending' | 'running' | 'done' | 'failed') => + ({ + kind: 'resource', + fqn: 'hello-resources.app', + resourceType: 'Prisma.Deployment', + status, + phase, + }) as const; + + const result = await silently(() => + deployWithDeps( + { + config: composerConfig(fakeConfig()), + entry: app.entryPath, + environment: 'ci-7', + cwd: app.dir, + onEvent: (event) => events.push(event), + }, + { + runAssembler: fakeAssembler, + converge: async (request) => { + request.onEvent?.(resource('pending', 'pending')); + request.onEvent?.(resource('creating', 'running')); + request.onEvent?.({ kind: 'log', severity: 'info', text: 'uploading 3 files' }); + request.onEvent?.(resource('created', 'done')); + return APPLIED; + }, + }, + ), + ); + + expect(result.ok).toBe(true); + const applyEvents = events.slice( + events.findIndex((event) => event.kind === 'step-started' && event.step.name === 'apply'), + ); + expect(applyEvents.map(stepLine).slice(0, 5)).toEqual([ + 'started apply', + 'started apply-resource:hello-resources.app', + 'message uploading 3 files', + 'finished apply-resource:hello-resources.app ok', + 'finished apply ok', + ]); + expect(applyEvents[1]).toMatchObject({ step: { status: 'creating' } }); + expect(applyEvents[3]).toMatchObject({ step: { status: 'created' } }); + }); + + test('a resource Alchemy failed closes its step as failed and reports the reason', async () => { + const app = makeAppDir('hello-resource-fail'); + const events: DeployEvent[] = []; + + await silently(() => + deployWithDeps( + { + config: composerConfig(fakeConfig()), + entry: app.entryPath, + environment: 'ci-7', + cwd: app.dir, + onEvent: (event) => events.push(event), + }, + { + runAssembler: fakeAssembler, + converge: async (request) => { + request.onEvent?.({ + kind: 'resource', + fqn: 'site', + resourceType: 'Prisma.CustomDomain', + status: 'fail', + phase: 'failed', + message: 'domain-ownership-required', + }); + return { kind: 'failed', cause: 'PrismaApiError: domain-ownership-required' }; + }, + }, + ), + ); + + expect(events.map(stepLine)).toContain('finished apply-resource:site failed'); + expect(events).toContainEqual({ + kind: 'message', + severity: 'warn', + text: 'site: domain-ownership-required', + }); + expect(events.map(stepLine)).toContain('finished apply failed'); + }); + test('a thrown defect closes the open steps as failed and still records the result', async () => { const app = makeAppDir('hello-steps-defect'); const events: DeployEvent[] = []; @@ -1144,7 +947,7 @@ describe('deploy() step events', () => { cwd: app.dir, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ).catch((error: unknown) => error), ); @@ -1213,7 +1016,7 @@ describe('deploy() step events', () => { reportPath: path.join(app.dir, 'package.json', 'report.json'), onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ); } finally { warnSpy.mockRestore(); @@ -1237,7 +1040,7 @@ describe('deploy() step events', () => { throw new Error('renderer bug'); }, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ), ); @@ -1444,6 +1247,10 @@ describe('destroy()', () => { ran.push('alchemy'); return { exitCode: 0, signal: null }; }, + converge: async () => { + ran.push('alchemy'); + return APPLIED; + }, }; const result = action === 'deploy' @@ -2548,7 +2355,7 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { endpoints: () => Promise.resolve([]), logs: async function* () {}, }; - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => devWithDeps( @@ -2560,7 +2367,7 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { { runAssembler: fakeAssembler, alchemy: async () => { - alchemyRan = true; + convergeRan = true; return { exitCode: 0, signal: null }; }, }, @@ -2570,7 +2377,7 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { expect(result.ok).toBe(false); if (result.ok) throw new Error('unreachable'); expect(result.failure.code).toBe('DEV.STACK_WRITE_FAILED'); - expect(alchemyRan).toBe(false); + expect(convergeRan).toBe(false); }, 15_000); }); @@ -2962,10 +2769,10 @@ describe('deploy-run reporting', () => { } }; - test('a successful deploy is begun, attached, and finished, and its build reaches the apply child', async () => { + test('a successful deploy is begun, attached, and finished, and its build reaches the apply', async () => { const app = makeAppDir('reported-app'); const log = recordingReporter(); - const invocations: AlchemyInvocation[] = []; + const requests: ConvergeRequest[] = []; const result = await silently(() => deployWithDeps( @@ -2977,10 +2784,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async (invocation) => { - invocations.push(invocation); - return { exitCode: 0, signal: null }; - }, + converge: recordingConverge(requests), }, ), ); @@ -2991,7 +2795,7 @@ describe('deploy-run reporting', () => { 'attach:container', 'finish:ok', ]); - expect(invocations[0]?.env['FAKE_BUILD_ID']).toBe('bld_1'); + expect(requests[0]?.env['FAKE_BUILD_ID']).toBe('bld_1'); }); test('the engine credentials and the --build-id both reach begin', async () => { @@ -3009,7 +2813,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, credentials: { workspaceId: 'ws-1', client: {} }, }, ), @@ -3048,7 +2852,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -3074,7 +2878,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: null, signal: 'SIGINT' }), + converge: async () => ({ kind: 'interrupted' }), }, ), ); @@ -3103,12 +2907,7 @@ describe('deploy-run reporting', () => { { entry: app.entryPath, environment: 'ci-6', cwd: app.dir, config: composerConfig(config) }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (resultFile === undefined) throw new Error('the result-file env var must be set'); - fs.writeFileSync(engineFailureFilePath(resultFile), '[web] fail — 409 Conflict'); - return { exitCode: 1, signal: null }; - }, + converge: async () => ({ kind: 'failed', cause: 'PrismaApiError: 409 Conflict' }), }, ), ); @@ -3116,7 +2915,7 @@ describe('deploy-run reporting', () => { expect(outcomes).toHaveLength(1); expect(outcomes[0]?.failingStep).toBe('DEPLOY.ENGINE_FAILED'); expect(outcomes[0]?.errorMessage).toBe( - 'alchemy deploy exited with status 1.\n[web] fail — 409 Conflict', + 'Alchemy could not deploy the app.\nPrismaApiError: 409 Conflict', ); }); @@ -3135,7 +2934,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -3182,7 +2981,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -3210,7 +3009,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: applied, }, ), ); @@ -3540,7 +3339,6 @@ describe('the operations read the pools from the config adapters', () => { test("deploy fills the root's input from the config adapters", async () => { const app = makeInputAppDir(); const bindings: unknown[] = []; - let stackFileSeen = false; const result = await silently(() => deployWithDeps( @@ -3560,16 +3358,12 @@ describe('the operations read the pools from the config adapters', () => { }, { runAssembler: fakeAssembler, - alchemy: async (invocation) => { - stackFileSeen = fs.existsSync(path.join(app.dir, invocation.stackFileRelativePath)); - return { exitCode: 0, signal: null }; - }, + converge: applied, }, ), ); expect(result.ok).toBe(true); - expect(stackFileSeen).toBe(true); expect(bindings).toEqual([{ greeting: 'from-adapter' }]); }); @@ -3594,7 +3388,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ), ); @@ -3606,7 +3400,7 @@ describe('the operations read the pools from the config adapters', () => { const app = makeAppDir(); fs.mkdirSync(path.join(app.dir, '.alchemy'), { recursive: true }); fs.writeFileSync(path.join(app.dir, '.alchemy', 'state.json'), '{}'); - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => destroyWithDeps( @@ -3622,7 +3416,7 @@ describe('the operations read the pools from the config adapters', () => { { runAssembler: fakeAssembler, alchemy: async () => { - alchemyRan = true; + convergeRan = true; return { exitCode: 0, signal: null }; }, }, @@ -3630,7 +3424,7 @@ describe('the operations read the pools from the config adapters', () => { ); expect(result.ok).toBe(true); - expect(alchemyRan).toBe(true); + expect(convergeRan).toBe(true); }); test('a secrets pool value appears in no event, result or report', async () => { @@ -3660,7 +3454,7 @@ describe('the operations read the pools from the config adapters', () => { reportPath, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ), ); @@ -3687,7 +3481,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: applied }, ), ); diff --git a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts index 0c2669315..a88947765 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/deploy.ts @@ -12,13 +12,27 @@ import type { ComposerConfigSource } from '../composer-config.ts'; import type { DeploymentSummary } from '../deployment-summary.ts'; import { executorLoadFailure, type OperationDeps } from './shared.ts'; -/** A deploy step. `assemble-service` steps run inside `assemble`, one per service. */ +/** A deploy step. `assemble-service` steps run inside `assemble`, one per service, and `apply-resource` steps inside `apply`, one per resource Alchemy touches. */ export type DeployStep = | { readonly name: 'prepare' | 'assemble' | 'connect' | 'preflight' | 'apply' | 'record' } - | { readonly name: 'assemble-service'; readonly address: string }; + | { readonly name: 'assemble-service'; readonly address: string } + | { + readonly name: 'apply-resource'; + /** The resource's id in the stack. */ + readonly fqn: string; + readonly resourceType: string; + /** Alchemy's status word for what is happening to it, such as `creating` or `created`. */ + readonly status: string; + }; export type DeployEvent = | { readonly kind: 'step-started'; readonly step: DeployStep } + | { + readonly kind: 'message'; + readonly severity: 'info' | 'warn' | 'verbose'; + /** A line Alchemy logged while it planned or applied. */ + readonly text: string; + } | { readonly kind: 'step-finished'; readonly step: DeployStep; @@ -60,11 +74,12 @@ export interface DeployInput { readonly reportId?: string | undefined; /** Each step as it starts and finishes, in real time. Rendering is the host's. */ readonly onEvent?: ((event: DeployEvent) => void) | undefined; + /** Interrupts the plan or apply when it aborts. The deploy then fails with `DEPLOY.INTERRUPTED`, after releasing the environment's state lease. */ + readonly signal?: AbortSignal | undefined; } export interface DeploySuccess { - /** Parsed from the alchemy child's result file. Undefined when the child - * did not write one (injected fake alchemy, or a report-less apply). */ + /** What the apply deployed. Undefined when the apply produced no report. */ readonly summary: DeploymentSummary | undefined; /** How long the whole deploy took, from the call to its result. */ readonly durationMs: number; diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index d78b99918..20a46f9fc 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -15,17 +15,25 @@ import type { ReporterDescriptor, RunReporter, } from '@internal/core/config'; -import { containerEnv, preflightEnv, preflightEnvVarName } from '@internal/core/config'; -import { CliStructuredError } from '@internal/foundation/errors'; +import { containerEnv, preflightEnvVarName } from '@internal/core/config'; +import { lower } from '@internal/core/deploy'; +import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; import type { ComposerConfigSource } from '../composer-config.ts'; import { + type ConvergeEvent, + convergeInProcess, + type LoweredStack, +} from '../converge-in-process.ts'; +import { + convergeFailureCause, DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary, engineFailureFilePath, poolSecrets, - readDeploymentSummary, readEngineFailureCause, + toDeploymentSummary, + withComposerAdvice, } from '../deployment-summary.ts'; import { GENERATED_STACK_RELATIVE_PATH, writeStackFile } from '../generate-stack.ts'; import { type PipelineDeps, type PipelineResult, runPipeline } from '../pipeline.ts'; @@ -73,7 +81,11 @@ function hasNoLocalDeployState(cwd: string): boolean { function deploySteps(onEvent: ((event: DeployEvent) => void) | undefined) { const open = new Map(); const keyOf = (step: DeployStep): string => - step.name === 'assemble-service' ? `${step.name}:${step.address}` : step.name; + step.name === 'assemble-service' + ? `${step.name}:${step.address}` + : step.name === 'apply-resource' + ? `${step.name}:${step.fqn}` + : step.name; const emit = (event: DeployEvent): void => { try { onEvent?.(event); @@ -106,6 +118,12 @@ function deploySteps(onEvent: ((event: DeployEvent) => void) | undefined) { failOpen(): void { for (const { step } of [...open.values()].reverse()) finish(step, 'failed'); }, + isOpen(step: DeployStep): boolean { + return open.has(keyOf(step)); + }, + message(severity: 'info' | 'warn' | 'verbose', text: string): void { + emit({ kind: 'message', severity, text }); + }, }; } @@ -124,6 +142,8 @@ interface StackPipelineOptions { /** Deploy only: where to write the run report, already resolved. */ readonly reportPath: string | undefined; readonly steps: DeploySteps; + /** Deploy only: interrupts the plan or apply. */ + readonly signal: AbortSignal | undefined; } export async function executeDeploy( @@ -142,6 +162,7 @@ export async function executeDeploy( reportId: input.reportId, reportPath: resolveRunReportPath(input.reportPath, process.env[RUN_REPORT_FILE_ENV], cwd), steps: deploySteps(input.onEvent), + signal: input.signal, }); if (!outcome.ok) return outcome; return ok({ summary: outcome.value }); @@ -163,6 +184,7 @@ export async function executeDestroy( reportId: undefined, reportPath: undefined, steps: deploySteps(undefined), + signal: undefined, }); if (!outcome.ok) return outcome; return okVoid(); @@ -224,7 +246,7 @@ async function attachReporters( ); } -/** Every session's contribution to the alchemy child's environment, so reporting that happens inside the apply can find the run. */ +/** Every session's contribution to the apply's environment, so reporting that happens inside the apply can find the run. */ function reporterChildEnv(reporters: readonly ExtensionReporter[]): Record { const env: Record = {}; for (const { extensionId, reporter } of reporters) { @@ -245,9 +267,9 @@ function truncate(value: string, limit: number): string { return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`; } -/** An interrupted converge (the engine settled a Ctrl-C) — reported as `cancelled`, never as `failed`. */ +/** An interrupted converge — reported as `cancelled`, never as `failed`. */ function wasInterrupted(failure: CliStructuredError): boolean { - return typeof failure.meta?.['signal'] === 'string'; + return failure.code === 'DEPLOY.INTERRUPTED' || typeof failure.meta?.['signal'] === 'string'; } /** @@ -347,9 +369,10 @@ async function runStackPipeline( return outcome; } -/** The pipeline both actions share: validate, resolve containers, preflight, - * write the stack file, run alchemy against it, then the destroy-only - * teardown/removal suffix. The value is only ever a summary for deploy. */ +/** The pipeline both actions share: validate, resolve containers, then for + * deploy preflight and the in-process converge, and for destroy the stack + * file, the alchemy child and the teardown/removal suffix. The value is only + * ever a summary for deploy. */ async function runStackPipelineInner( action: 'deploy' | 'destroy', opts: StackPipelineOptions, @@ -378,11 +401,9 @@ async function runStackPipelineInner( let pipeline: PipelineResult; let containers: Map; let alchemyStage: string; - // What each preflight hands back, on its way to the alchemy child: preflight - // runs here, in the parent, and the child re-imports the config from scratch, - // so anything it learned reaches the lowering only through this transport. + // What each deploy preflight hands back, for the lowering. const preflightPayloads = new Map(); - let preflightTransportEnv: Record = {}; + const preflightTransportEnv: Record = {}; try { // The shared prefix (pipeline.ts): entry load, Load, registry coverage, @@ -507,8 +528,8 @@ async function runStackPipelineInner( // Preflight (deploy only): each extension verifies its platform // prerequisites — e.g. that every secret env var in the provision manifest - // exists for the resolved environment (ADR-0029) — BEFORE any stack file is written - // or Alchemy runs, so a missing secret fails fast with nothing side-effected. + // exists for the resolved environment (ADR-0029) — BEFORE Alchemy runs, so + // a missing secret fails fast with nothing side-effected. if (action === 'deploy') { steps.start({ name: 'preflight' }); // Summed across extensions: each reports counts of its own checks. @@ -534,20 +555,12 @@ async function runStackPipelineInner( throw toStructured('DEPLOY.PREFLIGHT_FAILED', error); } } - // Serialized HERE, not at the alchemy invocation below: a transport - // collision (two extension ids mapping to one env var) is a preflight - // failure, and must surface before the stack file is written. - try { - preflightTransportEnv = preflightEnv(preflightPayloads); - } catch (error) { - throw toStructured('DEPLOY.PREFLIGHT_FAILED', error); - } steps.finish({ name: 'preflight' }, counts); } - // The child inherits this process's environment, so every transport var - // this run did NOT produce is blanked — a stale value exported into the - // shell (a re-exported child env) must not read as this run's payload. - // The reader treats an empty var as absent. + // Destroy's child inherits this process's environment and runs no + // preflight, so every transport var is blanked — a stale value exported + // into the shell must not read as this run's payload. The reader treats + // an empty var as absent. for (const extension of config.extensions) { preflightTransportEnv[preflightEnvVarName(extension.id)] ??= ''; } @@ -556,6 +569,15 @@ async function runStackPipelineInner( throw error; } + if (action === 'deploy') { + return convergeDeploy(opts, pipeline, { + containers, + stage: alchemyStage, + preflight: preflightPayloads, + env: reporterChildEnv(reporters), + }); + } + // Generate .prisma-composer/alchemy.run.ts (tool state lives where you run // the tool). Inside the try: a stray `.prisma-composer` FILE, a read-only or // full disk, or a permissions problem must come back as a failure result — @@ -728,9 +750,6 @@ async function runStackPipelineInner( throw error; } - if (action === 'deploy') { - return ok(readDeploymentSummary(resultFilePath)); - } return ok(undefined); } finally { try { @@ -741,3 +760,115 @@ async function runStackPipelineInner( } } } + +/** One Alchemy progress event as the deploy's step events: each resource is a step inside `apply`. */ +function reportConvergeEvent(steps: DeploySteps, event: ConvergeEvent): void { + if (event.kind === 'log') { + steps.message(event.severity, withComposerAdvice(event.text)); + return; + } + const step: DeployStep = { + name: 'apply-resource', + fqn: event.fqn, + resourceType: event.resourceType, + status: event.status, + }; + switch (event.phase) { + case 'running': + if (!steps.isOpen(step)) steps.start(step); + return; + case 'done': + if (!steps.isOpen(step)) steps.start(step); + steps.finish(step); + return; + case 'failed': + if (!steps.isOpen(step)) steps.start(step); + steps.fail(step); + if (event.message !== undefined) { + steps.message('warn', `${event.fqn}: ${withComposerAdvice(event.message)}`); + } + return; + case 'pending': + return; + } +} + +/** + * Deploy's converge: lowers the app with the containers and preflight + * payloads this run resolved, then plans and applies it in this process. + * The report hook runs inside the apply, here, so the summary comes back as + * a value. + */ +async function convergeDeploy( + opts: StackPipelineOptions, + pipeline: PipelineResult, + resolved: { + readonly containers: ReadonlyMap; + readonly stage: string; + readonly preflight: ReadonlyMap; + readonly env: Readonly>; + }, +): Promise> { + const { cwd, environment, deps, steps } = opts; + const config = pipeline.configSource.value; + let summary: DeploymentSummary | undefined; + + const root = pipeline.graph.root.node; + if (root.kind !== 'module') + throw new InternalError('runPipeline admitted a root that is not a module'); + + steps.start({ name: 'apply' }); + let stack: LoweredStack; + try { + stack = lower(root, config, { + name: pipeline.name, + environment, + configPool: pipeline.pools.config, + bundles: pipeline.assembled.bundles, + containers: resolved.containers, + preflight: resolved.preflight, + report: (result) => { + summary = toDeploymentSummary(result); + }, + }); + } catch (error) { + // An extension's state or providers factory threw while the stack was built. + return notOk(toStructured('DEPLOY.ENGINE_FAILED', error)); + } + const env = { ...resolved.env, ...deps.convergeEnv }; + const outcome = await (deps.converge ?? convergeInProcess)({ + stack, + stage: resolved.stage, + cwd, + env, + signal: opts.signal, + onEvent: (event) => reportConvergeEvent(steps, event), + }); + + switch (outcome.kind) { + case 'applied': + steps.finish({ name: 'apply' }); + return ok(summary); + case 'interrupted': + return notOk( + new CliStructuredError('DEPLOY.INTERRUPTED', 'The deploy was interrupted.', { + why: 'It stopped while Alchemy planned or applied it. Resources it had already changed stay as they are.', + fix: 'Deploy again to finish.', + }), + ); + case 'failed': { + const cause = convergeFailureCause( + outcome.cause, + { ...process.env, ...env }, + poolSecrets(pipeline.pools, config.secrets !== undefined), + ); + return notOk( + new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + `Alchemy could not deploy the app.\n${cause}`, + { meta: { engineCause: cause } }, + ), + ); + } + } +} diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index 45bde7261..05535e5bb 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -12,6 +12,7 @@ import type { } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { CliStructuredError } from '@internal/foundation/errors'; +import type { Converge } from '../converge-in-process.ts'; import type { startWatch } from '../dev/watch.ts'; import type { RunAlchemy } from '../run-alchemy.ts'; @@ -39,11 +40,19 @@ export interface ServiceEndpoint { export interface OperationDeps { readonly runAssembler?: RunAssembler | undefined; /** - * Starts the converge child. The CLI passes one backed by the engine's - * `ctx.spawn`, so the terminal reaches the child natively and the engine - * owns signal policy; hosts get the default `spawnAlchemy`. + * Starts the converge child for `destroy` and `dev`. The CLI passes one + * backed by the engine's `ctx.spawn`, so the terminal reaches the child + * natively and the engine owns signal policy; hosts get the default `spawnAlchemy`. */ readonly alchemy?: RunAlchemy | undefined; + /** Plans and applies `deploy`'s lowered stack; defaults to `convergeInProcess`. Tests substitute it. */ + readonly converge?: Converge | undefined; + /** + * Set in `process.env` while `deploy`'s plan and apply run, and restored + * after. The CLI puts the credential here, because Alchemy reads its + * credentials only from the environment. + */ + readonly convergeEnv?: Readonly> | undefined; /** Starts a file watch for `dev`; defaults to chokidar-backed `startWatch`. Tests substitute it to drive changes directly. */ readonly watch?: typeof startWatch | undefined; /** diff --git a/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts b/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts index 73230d19a..aad0dcaed 100644 --- a/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts +++ b/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts @@ -17,14 +17,13 @@ * control implementation, which scripts/check-family-static-graph.mjs proves * against the packed tarball. */ -import { CliStructuredError } from '@internal/foundation/errors'; -import { notOk, ok, type Result } from '@internal/foundation/result'; +import type { CliStructuredError } from '@internal/foundation/errors'; +import { ok, type Result } from '@internal/foundation/result'; import type { ComposerOperations } from '../family/family.ts'; import type { DeployEvent, DeployInput, DeploySuccess } from '../operations/deploy.ts'; import type { DevEvent, DevInput, DevSession } from '../operations/dev.ts'; import type { DevStopInput, DevStopped, DevUrl, DevUrlInput } from '../operations/dev-current.ts'; import type { OperationDeps, ServiceEndpoint } from '../operations/shared.ts'; -import { type AlchemyInvocation, reproduceCommand } from '../run-alchemy.ts'; export interface OperationsDoubleFixtures { /** Returned as-is; pass notOk(error) for a failing deploy. Default: ok with no summary and a zero duration. */ @@ -48,8 +47,8 @@ export interface OperationsDoubleFixtures { /** * The deps each call carried, in the same order as the inputs beside them. - * These are the operation's SECOND argument — the seam the config path, the - * converge spawn adapter and the credentials ride in on — so a host that + * These are the operation's SECOND argument — the seam the credentials and + * the converge environment ride in on — so a host that * drops one of those is caught here rather than by its absence downstream. */ export interface OperationsDoubleDeps { @@ -97,72 +96,6 @@ function devSessionDouble( }; } -/** - * Runs the caller's converge adapter, when it supplied one, against a - * synthetic invocation — no alchemy bin is resolved and no real child is - * started; the adapter decides what the child does. - * - * This is what makes a host's tests cover the settlement rules rather than - * just the grammar: a scripted fake child that exits non-zero, or is killed - * by a signal, produces the same failure shape the real executor produces, so - * the handler's settlement — and the engine's own child-status settlement - * behind it — is genuinely exercised. Returns undefined when no adapter was - * injected, leaving the fixture result untouched. - */ -async function runConverge( - deps: OperationDeps, - cwd: string | undefined, -): Promise | undefined> { - if (deps.alchemy === undefined) return undefined; - const stackFilePath = '.prisma-composer/alchemy.run.ts'; - const workingDirectory = cwd ?? '.'; - const invocation: AlchemyInvocation = { - action: 'deploy', - stackFileRelativePath: stackFilePath, - cwd: workingDirectory, - stage: 'test', - env: {}, - }; - const outcome = await deps.alchemy(invocation); - const reproduce = reproduceCommand(invocation, outcome); - - if (outcome.signal !== null) { - return notOk( - new CliStructuredError( - 'DEPLOY.ENGINE_FAILED', - `alchemy deploy was interrupted by ${outcome.signal}.`, - { - meta: { - signal: outcome.signal, - diagnostics: { - exitCode: undefined, - signal: outcome.signal, - stackFilePath, - reproduceCommand: reproduce, - cwd: workingDirectory, - }, - }, - }, - ), - ); - } - const status = outcome.exitCode ?? 1; - if (status === 0) return undefined; - return notOk( - new CliStructuredError('DEPLOY.ENGINE_FAILED', `alchemy deploy exited with status ${status}.`, { - meta: { - exitCode: status, - diagnostics: { - exitCode: status, - stackFilePath, - reproduceCommand: reproduce, - cwd: workingDirectory, - }, - }, - }), - ); -} - export function createOperationsDouble(fixtures: OperationsDoubleFixtures = {}): OperationsDouble { const deployCalls: DeployInput[] = []; const devCalls: DevInput[] = []; @@ -190,8 +123,6 @@ export function createOperationsDouble(fixtures: OperationsDoubleFixtures = {}): // The real deploy ignores a throwing onEvent, so the double does too. } } - const converge = await runConverge(operationDeps, input.cwd); - if (converge !== undefined) return converge; return fixtures.deploy ?? ok({ summary: undefined, durationMs: 0 }); }, dev: async (input, operationDeps) => { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/custom-domain-provider.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/custom-domain-provider.test.ts index 321c34d99..85d1d74ae 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/custom-domain-provider.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/custom-domain-provider.test.ts @@ -33,8 +33,8 @@ describe('a custom domain under the local providers', () => { const options = { providers: localTargetProviders(input), dev: false }; const stack = scratchStack(options, 'local-custom-domain'); - const first = await runScratch(stack.deploy(program), options); - const second = await runScratch(stack.deploy(program), options); + const first = await runScratch(stack.deploy(program)); + const second = await runScratch(stack.deploy(program)); expect(first).toMatchObject({ customDomainId: 'local-shop.site.domain.shop.com', @@ -52,8 +52,8 @@ describe('a custom domain under the local providers', () => { const options = { providers: localTargetProviders(input), dev: false }; const stack = scratchStack(options, 'local-custom-domain-change'); - await runScratch(stack.deploy(programFor('shop.com')), options); - const changed = await runScratch(stack.deploy(programFor('new.shop.com')), options); + await runScratch(stack.deploy(programFor('shop.com'))); + const changed = await runScratch(stack.deploy(programFor('new.shop.com'))); expect(changed).toMatchObject({ customDomainId: 'local-shop.site.domain.new.shop.com', diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts index f8beb75aa..8cb310ea9 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-environment-lifecycle.test.ts @@ -289,7 +289,6 @@ test.each(scenarios)( yield* stack.deploy(program(input, 2, addVariable)); expect(snapshots).toHaveLength(2); }), - options, ); }, ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/scratch-stack.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/scratch-stack.ts index 3877ce72a..cc36de2f6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/scratch-stack.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/scratch-stack.ts @@ -1,30 +1,17 @@ -import { AdoptPolicy } from 'alchemy/AdoptPolicy'; -import { AlchemyContext, AlchemyContextLive } from 'alchemy/AlchemyContext'; -import { type ArtifactStore, provideFreshArtifactStore } from 'alchemy/Artifacts'; -import { AuthProviders } from 'alchemy/Auth/AuthProvider'; -import { CredentialsStoreLive } from 'alchemy/Auth/Credentials'; -import { ProfileStoreLive } from 'alchemy/Auth/Profile'; -import { LoggingCli } from 'alchemy/Cli/LoggingCli'; -import * as Deploy from 'alchemy/Deploy'; -import * as Interaction from 'alchemy/Interaction'; -import * as Plan from 'alchemy/Plan'; -import { make as makeStack, type StackEffect, type StackServices } from 'alchemy/Stack'; -import { Stage } from 'alchemy/Stage'; +import { blindCast } from '@internal/foundation/casts'; +import * as Alchemy from 'alchemy'; +import * as Alchemist from 'alchemy/Alchemist'; +import type { StackServices } from 'alchemy/Stack'; import * as State from 'alchemy/State'; import { InMemoryService } from 'alchemy/State/InMemoryState'; -import { kindOf } from 'alchemy/Util/LogRecord'; -import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import type { ConfigError } from 'effect/Config'; import * as Effect from 'effect/Effect'; -import * as FetchHttpClient from 'effect/http/FetchHttpClient'; import * as Layer from 'effect/Layer'; -import * as Logger from 'effect/Logger'; /** - * Runs Alchemy's real plan and apply engine in-process for Composer's own - * provider tests; not re-exported by any public package. Each scratch stack - * owns a private in-memory state, exposed as `state`. A port of the harness - * Alchemy kept in `alchemy/Test/Core` up to 2.0.0-beta.83, without its - * emulator and sidecar support. + * Runs Alchemy's real plan and apply in-process, through `alchemy/Alchemist`, + * for Composer's own provider tests; not re-exported by any public package. + * Each scratch stack owns a private in-memory state, exposed as `state`. */ export interface ScratchStackOptions { readonly providers: Layer.Layer; @@ -36,85 +23,39 @@ const STAGE = 'test'; export const scratchStack = (options: ScratchStackOptions, name: string) => { const stateLayer: Layer.Layer = Layer.succeed(State.State, InMemoryService({})); - const compile = (effect: Effect.Effect) => - effect.pipe( - makeStack({ name, providers: options.providers, state: stateLayer }), - Effect.provideService(Stage, STAGE), - ); + const providers = blindCast< + Layer.Layer, + "the program's provider requirements are exactly what these providers supply; Alchemy.Stack ties the two through one inferred type that a generic ROut cannot name" + >(options.providers); + + const planned = (effect: Effect.Effect) => + Alchemist.plan({ + stack: Alchemy.Stack( + name, + { providers, state: stateLayer }, + blindCast< + Effect.Effect, + 'its requirements are the providers above and the stack services Alchemy.Stack supplies' + >(effect), + ), + operation: 'deploy', + stage: STAGE, + dev: options.dev ?? false, + }); return { name, stage: STAGE, state: stateLayer, - deploy: (effect: Effect.Effect) => - compile(effect).pipe( - Effect.flatMap((compiled) => - Plan.make(compiled).pipe( - Effect.flatMap(Deploy.applyAndRender), - Effect.provide(compiled.services), - Effect.provide(stateLayer), - ), - ), - provideFreshArtifactStore, - ), - plan: (effect: Effect.Effect) => - compile(effect).pipe( - Effect.flatMap((compiled) => - Plan.make(compiled).pipe(Effect.provide(compiled.services), Effect.provide(stateLayer)), - ), - provideFreshArtifactStore, - ), + /** Plans and applies `effect` as this stack's program; returns its output. */ + deploy: (effect: Effect.Effect) => + Effect.scoped(Effect.flatMap(planned(effect), Alchemist.apply)), + /** Plans `effect` as this stack's program; returns the engine plan. */ + plan: (effect: Effect.Effect) => + Effect.scoped(Effect.map(planned(effect), (plan) => plan.native)), }; }; -const platformLayer = Layer.mergeAll( - PlatformServices, - FetchHttpClient.layer, - Layer.provide(ProfileStoreLive, PlatformServices), - Layer.provide(CredentialsStoreLive, PlatformServices), -); - -const alchemyLayer = Layer.mergeAll( - LoggingCli, - Interaction.layerNonInteractive(), - AlchemyContextLive, -); - -const overrideDev = (dev: boolean) => - Layer.effect(AlchemyContext, AlchemyContext.pipe(Effect.map((context) => ({ ...context, dev })))); - -/** Drops the typed event records; the `LoggingCli` lines already report progress. */ -const withoutEventRecords = (effect: Effect.Effect) => - Effect.flatMap(Logger.CurrentLoggers, (loggers) => - effect.pipe( - Effect.provideService( - Logger.CurrentLoggers, - new Set( - [...loggers].map((logger) => - Logger.make((options) => { - if (kindOf(options) !== 'event') logger.log(options); - }), - ), - ), - ), - ), - ); - -/** What a scratch-stack program may require: the stack supplies its own state. */ -type ScratchRequirements = Exclude>, State.State>; - -/** Runs a scratch-stack program with the services the Alchemy CLI would provide. */ -export const runScratch = ( - effect: Effect.Effect, - options: ScratchStackOptions, -): Promise => - Effect.runPromise( - provideFreshArtifactStore>(effect).pipe( - withoutEventRecords, - Effect.provideService(AdoptPolicy, false), - Effect.provide(overrideDev(options.dev ?? false)), - Effect.provideService(AuthProviders, {}), - Effect.provide(Layer.provideMerge(alchemyLayer, platformLayer)), - Effect.scoped, - ), - ); +/** Runs a scratch-stack program with the services `Alchemist.layer()` provides. */ +export const runScratch = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(Alchemist.layer()))); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index d2080e18b..dc36150cd 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -25,6 +25,7 @@ import * as Effect from 'effect/Effect'; import * as Option from 'effect/Option'; import * as Redacted from 'effect/Redacted'; import { PRISMA_CLOUD_EXTENSION_ID, PrismaCloudContainer } from '../container.ts'; +import { serializeDevPreflightPayload } from '../control/preflight-payload.ts'; import type { ComputeProvisioned, ComputeSerialized } from '../descriptors/compute.ts'; import { computeDescriptor } from '../descriptors/compute.ts'; import type { S3StoreSerialized } from '../descriptors/s3-store.ts'; @@ -2731,7 +2732,6 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis const o: ResolvedCloudOptions = { workspaceId: 'ws_1', providerParams, - preflight: { pointerVersion: () => undefined, absentSecrets: new Set() }, }; const node = compute({ name: 'multi', deps: {}, build, expose: { any: anyContract } }); const ctx = { @@ -2787,10 +2787,6 @@ describe('a secret the prisma dev preflight found unset', () => { const o: ResolvedCloudOptions = { workspaceId: 'ws_1', providerParams: new Map(), - preflight: { - pointerVersion: () => undefined, - absentSecrets: new Set(['STRIPE_WEBHOOK_SECRET']), - }, }; const node = compute({ name: 'billing', @@ -2827,6 +2823,10 @@ describe('a secret the prisma dev preflight found unset', () => { }, provisioned: new Map(), opts: {}, + preflight: serializeDevPreflightPayload({ + absentSecrets: ['STRIPE_WEBHOOK_SECRET'], + pointerVersions: {}, + }), } as unknown as LowerContext; const provisioned = { serviceId: 'billing-svc#cloud-id', diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index f4a9bd2cc..6c76a4d54 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -127,8 +127,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { // Why each file reads the environment: serializer.ts (params, the input // document's env-sourced leaves and boot reads), control/extension.ts - // (optional PRISMA_WORKSPACE_ID and PRISMA_REGION, and the preflight - // transport), container.ts (PRISMA_WORKSPACE_ID, PRISMA_SERVICE_TOKEN), + // (optional PRISMA_WORKSPACE_ID and PRISMA_REGION), container.ts (PRISMA_WORKSPACE_ID, PRISMA_SERVICE_TOKEN), // preflight.ts (PRISMA_SERVICE_TOKEN; values come from the pools), local-target/attach.ts (the shell environment a dev // server keeps under its Composer rows, ADR-0056), compute.ts (run() // applies stashRows to the environment), testing.ts (bootstrapService). @@ -145,7 +144,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'compute.ts', count: 2 }, { file: 'container.ts', count: 3 }, - { file: 'control/extension.ts', count: 3 }, + { file: 'control/extension.ts', count: 2 }, { file: 'local-target/attach.ts', count: 1 }, { file: 'preflight.ts', count: 1 }, { file: 'serializer.ts', count: 9 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts index a8d96a2c6..85ba0b68d 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/local-target-preflight.test.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { isSecretPointer, Load, module, resolve, type ServiceNode } from '@internal/core'; -import { preflightEnv } from '@internal/core/config'; +import { preflightEnv, readPreflightPayload } from '@internal/core/config'; import { isSecretString } from '@internal/foundation/secret'; import { rawHttpContract } from '@internal/http'; import type { StandardSchemaV1 } from '@standard-schema/spec'; @@ -194,7 +194,9 @@ function billingDocument( ): unknown { const node = graph.nodes.find((entry) => entry.id === 'app.billing')?.node as ServiceNode; const binding = graph.inputBindings.find((b) => b.serviceAddress === 'app.billing')?.binding; - const { absentSecrets } = transportedPreflight(new Map(), childEnv); + const { absentSecrets } = transportedPreflight( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, childEnv), + ); const row = serializeInput(node, 'app.billing', binding, { env: {}, absentSecrets }); return JSON.parse(row?.value ?? 'null'); } @@ -307,8 +309,7 @@ describe('prisma dev preflight', () => { const memberFor = async (value: string) => { secrets['STRIPE_SECRET_KEY'] = value; const { pointerVersion } = transportedPreflight( - new Map(), - transported(await preflight(graph)), + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, transported(await preflight(graph))), ); return pointerVersion('STRIPE_SECRET_KEY'); }; @@ -325,7 +326,9 @@ describe('prisma dev preflight', () => { const childEnv = transported(await preflight(billingGraph(requiresSecrets('stripeKey')))); expect( - transportedPreflight(new Map(), childEnv).pointerVersion('STRIPE_WEBHOOK_SECRET'), + transportedPreflight( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, childEnv), + ).pointerVersion('STRIPE_WEBHOOK_SECRET'), ).toBeUndefined(); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts index caf603d82..a5706a22f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-postgres-ordering.test.ts @@ -28,7 +28,6 @@ const registered = (effect: Effect.Effect): Effect.Effec const lowering = postgresDescriptor(() => ({ workspaceId: 'ws_1', providerParams: new Map(), - preflight: { pointerVersion: () => undefined, absentSecrets: new Set() }, })); if (lowering.kind !== 'resource') throw new Error('postgres must lower as a resource'); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight-payload.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight-payload.test.ts index 2134455fe..2bb071fcf 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight-payload.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight-payload.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, test } from 'bun:test'; import { Load, module } from '@internal/core'; -import { preflightEnv } from '@internal/core/config'; +import { preflightEnv, readPreflightPayload } from '@internal/core/config'; import type { ManagementApiClient } from '@internal/lowering'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import { PRISMA_CLOUD_EXTENSION_ID, PrismaCloudContainer } from '../container.ts'; @@ -93,7 +93,9 @@ async function cliProcess(updatedAt: string): Promise> { * fingerprint moves on. */ function alchemyProcessPointerTriggers(env: Record): Record { - const { pointerVersion } = transportedPreflight(new Map(), env); + const { pointerVersion } = transportedPreflight( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, env), + ); return Object.fromEntries( pointers.map((name) => [`${name}:updatedAt`, pointerVersion(name) ?? '?']), ); @@ -188,9 +190,11 @@ describe('one transport variable, two kinds of payload', () => { test('a deploy payload names no absent secret and reads as timestamps', () => { const preflight = transportedPreflight( - new Map(), - childEnv( - serializeDeployPreflightPayload(new Map([['STRIPE_SECRET_KEY', '2026-10-01T00:00:00Z']])), + readPreflightPayload( + PRISMA_CLOUD_EXTENSION_ID, + childEnv( + serializeDeployPreflightPayload(new Map([['STRIPE_SECRET_KEY', '2026-10-01T00:00:00Z']])), + ), ), ); @@ -200,12 +204,14 @@ describe('one transport variable, two kinds of payload', () => { test('a dev payload carries the absent secrets and the value versions', () => { const preflight = transportedPreflight( - new Map(), - childEnv( - serializeDevPreflightPayload({ - absentSecrets: ['STRIPE_WEBHOOK_SECRET'], - pointerVersions: { STRIPE_SECRET_KEY: 'hmac-of-value' }, - }), + readPreflightPayload( + PRISMA_CLOUD_EXTENSION_ID, + childEnv( + serializeDevPreflightPayload({ + absentSecrets: ['STRIPE_WEBHOOK_SECRET'], + pointerVersions: { STRIPE_SECRET_KEY: 'hmac-of-value' }, + }), + ), ), ); @@ -214,7 +220,9 @@ describe('one transport variable, two kinds of payload', () => { }); test('with no payload nothing is absent and every version is unknown', () => { - const preflight = transportedPreflight(new Map(), childEnv(undefined)); + const preflight = transportedPreflight( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, childEnv(undefined)), + ); expect([...preflight.absentSecrets]).toEqual([]); expect(preflight.pointerVersion('STRIPE_SECRET_KEY')).toBeUndefined(); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/public-url-apply.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/public-url-apply.test.ts index b25a2e43b..276308431 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/public-url-apply.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/public-url-apply.test.ts @@ -192,7 +192,7 @@ describe('a hostname public URL under the Alchemy apply engine', () => { const { platform, options } = fakePlatform(['shop.com']); const stack = scratchStack(options, 'public-url-refused'); - const exit = await runScratch(Effect.exit(stack.deploy(program('shop.com'))), options); + const exit = await runScratch(Effect.exit(stack.deploy(program('shop.com')))); expect(Exit.isFailure(exit)).toBe(true); expect(failureText(exit)).toContain( @@ -206,12 +206,12 @@ describe('a hostname public URL under the Alchemy apply engine', () => { const { platform, options } = fakePlatform(['new.shop.com']); const stack = scratchStack(options, 'public-url-change'); - const first = await runScratch(Effect.exit(stack.deploy(program('shop.com'))), options); + const first = await runScratch(Effect.exit(stack.deploy(program('shop.com')))); expect(Exit.isSuccess(first)).toBe(true); expect([...platform.domains.values()].map((d) => d.hostname)).toEqual(['shop.com']); platform.requests.length = 0; - const second = await runScratch(Effect.exit(stack.deploy(program('new.shop.com'))), options); + const second = await runScratch(Effect.exit(stack.deploy(program('new.shop.com')))); expect(Exit.isFailure(second)).toBe(true); expect(failureText(second)).toContain( @@ -226,9 +226,9 @@ describe('a hostname public URL under the Alchemy apply engine', () => { const { platform, options } = fakePlatform([]); const stack = scratchStack(options, 'public-url-replace'); - await runScratch(stack.deploy(program('shop.com')), options); + await runScratch(stack.deploy(program('shop.com'))); platform.requests.length = 0; - await runScratch(stack.deploy(program('new.shop.com')), options); + await runScratch(stack.deploy(program('new.shop.com'))); const create = platform.requests.indexOf(`POST /v1/apps/${APP_ID}/domains`); const remove = platform.requests.indexOf('DELETE /v1/domains/dom_1'); @@ -241,12 +241,9 @@ describe('a hostname public URL under the Alchemy apply engine', () => { const { platform, options } = fakePlatform([]); const stack = scratchStack(options, 'public-url-move'); - await runScratch(stack.deploy(program('shop.com')), options); + await runScratch(stack.deploy(program('shop.com'))); platform.requests.length = 0; - const moved = await runScratch( - Effect.exit(stack.deploy(program('shop.com', OTHER_APP_ID))), - options, - ); + const moved = await runScratch(Effect.exit(stack.deploy(program('shop.com', OTHER_APP_ID)))); expect(Exit.isFailure(moved)).toBe(true); expect(failureText(moved)).toContain( diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index 3a4b73601..3c4425483 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -43,11 +43,7 @@ import { resourceId } from '../resource-ids.ts'; import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; import type { ProviderParamEntry } from '../serializer.ts'; import { STREAMS_API_KEY } from '../streams-keys.ts'; -import { - serializeDeployPreflightPayload, - type TransportedPreflight, - transportedPreflight, -} from './preflight-payload.ts'; +import { serializeDeployPreflightPayload } from './preflight-payload.ts'; /** * ADR-0031's registered provisioner for RPC_PEER_KEY: mints one `ServiceKey` @@ -287,7 +283,7 @@ export const PROVIDER_PARAMS: ReadonlyMap { +function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { const workspaceId = opts.workspaceId ?? process.env['PRISMA_WORKSPACE_ID'] ?? ''; if (opts.region !== undefined) { @@ -316,26 +312,17 @@ function resolveOptions(opts: PrismaCloudOptions): Omit ResolvedCloudOptions { +function lazyOptions(opts: PrismaCloudOptions): () => ResolvedCloudOptions { let cached: ResolvedCloudOptions | undefined; return () => { - cached ??= { ...resolveOptions(opts), preflight }; + cached ??= resolveOptions(opts); return cached; }; } /** The Prisma Cloud extension descriptor — the `composer` section of `prisma.config.ts` lists it under `extensions`. */ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor => { - // When each pointed-at platform variable was last written — filled by the - // deploy preflight, read by the environment fingerprint. A closure, not a - // module variable, so two `prismaCloud()` extensions cannot share it. In - // the alchemy process (no preflight) the lookup falls back to what the CLI - // transported (preflight-payload.ts). - const preflightTimestamps = new Map(); - const o = lazyOptions(opts, transportedPreflight(preflightTimestamps, process.env)); + const o = lazyOptions(opts); return { id: PRISMA_CLOUD_EXTENSION_ID, @@ -356,16 +343,12 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // Deploy-time prerequisite check (ADR-0029): verify every pointer secret in // the provision manifest exists for the resolved environment, filling absent-but- - // in-shell names via a direct API POST — before any stack file or Alchemy. - // Timestamps are kept for this process AND serialized onto the preflight - // transport for the alchemy process. The parameter annotation is what - // recovers this extension's own client type from the framework's erased - // one; without it `input.credentials` arrives as `unknown`. + // in-shell names via a direct API POST — before Alchemy runs. The + // timestamps reach the lowering as its `preflight` payload. The parameter + // annotation is what recovers this extension's own client type from the + // framework's erased one; without it `input.credentials` arrives as `unknown`. preflight: (input: PrismaCloudPreflightInput) => - runPreflight(input).then((timestamps) => { - for (const [name, updatedAt] of timestamps) preflightTimestamps.set(name, updatedAt); - return serializeDeployPreflightPayload(timestamps); - }), + runPreflight(input).then(serializeDeployPreflightPayload), // Records the deploy as a Build so it appears in the Console, and passes // the build's id into the apply so the state store can report what the diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/preflight-payload.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/preflight-payload.ts index 1072130a7..89ca0d84a 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/preflight-payload.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/preflight-payload.ts @@ -1,7 +1,6 @@ /** - * What this extension's preflight hands the alchemy process, which re-imports - * the config from scratch, on the framework's preflight transport. One - * variable carries either kind: + * What this extension's preflight hands the lowering, which reads it as + * `LowerContext.preflight`. One payload carries either kind: * * - deploy: a JSON object mapping each pointed platform variable to its * `updatedAt`, as read from the Management API; @@ -11,8 +10,6 @@ * A deploy payload always starts with `{`, so the prefix tells them apart. * Names, times and keyed hashes only, never a value. */ -import { readPreflightPayload } from '@internal/core/config'; -import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; /** A value that changes when the pointed variable's value changes: the platform `updatedAt` on deploy, an HMAC of the value under `prisma dev`. Undefined when unknown. */ export type PointerVersion = (name: string) => string | undefined; @@ -121,31 +118,21 @@ const devPayloadError = (reason: string): Error => `alchemy process — ${reason}. This is a framework bug; re-running \`prisma dev\` will not fix it.`, ); -/** - * The lowering's view of what the preflight sent. `own` holds what the deploy - * preflight read in this same process (the CLI process); the alchemy process - * has none and reads the transported payload, decoded once on first use. - */ -export function transportedPreflight( - own: ReadonlyMap, - env: Readonly>, -): TransportedPreflight { +/** The lowering's view of what this run's preflight sent (`LowerContext.preflight`), decoded once on first use. */ +export function transportedPreflight(payload: string | undefined): TransportedPreflight { let decoded: DecodedPreflightPayload | undefined; - const decode = () => - (decoded ??= decodePreflightPayload(readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, env))); + const decode = () => (decoded ??= decodePreflightPayload(payload)); const noSecrets: ReadonlySet = new Set(); return { pointerVersion: (name) => { - const mine = own.get(name); - if (mine !== undefined) return mine; - const payload = decode(); - if (payload.kind === 'deploy') return payload.updatedAt.get(name); - if (payload.kind === 'dev') return payload.pointerVersions.get(name); + const sent = decode(); + if (sent.kind === 'deploy') return sent.updatedAt.get(name); + if (sent.kind === 'dev') return sent.pointerVersions.get(name); return undefined; }, get absentSecrets() { - const payload = decode(); - return payload.kind === 'dev' ? payload.absentSecrets : noSecrets; + const sent = decode(); + return sent.kind === 'dev' ? sent.absentSecrets : noSecrets; }, }; } diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts index 8cbfddedf..eebe2302f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/compute.ts @@ -11,6 +11,7 @@ import { import * as Output from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; +import { transportedPreflight } from '../control/preflight-payload.ts'; import { GeneratedParam } from '../generated-param-resource.ts'; import { fromLegacyId, @@ -257,7 +258,7 @@ export function computeDescriptor( address, graph.inputBindings.find((b) => b.serviceAddress === address)?.binding, { - absentSecrets: o().preflight.absentSecrets, + absentSecrets: transportedPreflight(ctx.preflight).absentSecrets, ...(ctx.opts.configPool !== undefined ? { env: ctx.opts.configPool } : {}), }, ); @@ -419,9 +420,9 @@ export function computeDescriptor( }), ), - deploy: ({ address, graph }, provisioned, artifact, serialized) => + deploy: ({ address, graph, preflight }, provisioned, artifact, serialized) => Effect.gen(function* () { - const { pointerVersion } = o().preflight; + const { pointerVersion } = transportedPreflight(preflight); // Env keys never contain ':', so a `:updatedAt` member cannot // collide with a row's own trigger member. The key name is historical: // under `prisma dev` the member holds an HMAC of the value, and diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts index 59fbd6ea0..d0a42bfc9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts @@ -6,7 +6,6 @@ import { adopt } from 'alchemy/AdoptPolicy'; import * as Output from 'alchemy/Output'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; -import type { TransportedPreflight } from '../control/preflight-payload.ts'; import { fromLegacyId, legacyNodeResourceId } from '../legacy-resource-ids.ts'; import { resourceId } from '../resource-ids.ts'; import type { ProviderParamEntry } from '../serializer.ts'; @@ -81,12 +80,6 @@ export interface ResolvedCloudOptions { * place a brand is named). */ readonly providerParams: ReadonlyMap; - /** - * What the preflight of this run sent, not options the user gave: a version - * of each variable a row points at (the rotation signal the compute deploy - * hook folds into its triggers), and the secrets `prisma dev` found unset. - */ - readonly preflight: TransportedPreflight; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20876e07a..2f93e7164 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -866,6 +866,9 @@ importers: alchemy: specifier: 'catalog:' version: https://pkg.alchemy.run/alchemy/972f294ae0c7982a8ebcb551fadd07d29c9c0fca(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + effect: + specifier: 'catalog:' + version: 4.1.0 tsdown: specifier: ^0.22.7 version: 0.22.12(typescript@6.0.3) @@ -3227,6 +3230,7 @@ packages: '@smithy/core@3.35.0': resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to a bug in custom logger data redaction. See https://github.com/aws/aws-sdk-js-v3/issues/8393. '@smithy/credential-provider-imds@4.4.9': resolution: {integrity: sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg==} diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 6d3097417..4d5635743 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -407,9 +407,10 @@ complete pattern. Deploy compares the declared topology against recorded deploy state and applies only the difference. Re-deploying with nothing changed is a no-op; -removing a node removes its deployed resource. `prisma deploy` needs a signed-in -identity: `prisma auth login` stores a session on a developer machine, and -`PRISMA_SERVICE_TOKEN` overrides it in CI. The `/control` operations, and so +removing a node removes its deployed resource. `prisma deploy` needs +`PRISMA_SERVICE_TOKEN` in the environment, on a developer machine too: it runs +the deploy engine in its own process, and a `prisma auth login` session cannot +reach it yet (`DEPLOY.SERVICE_TOKEN_REQUIRED`). The `/control` operations, and so a destroy script, never use that session: `deploy` and `destroy` read `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID` from environment variables (both in the workspace's Console settings); `dev` and `log` read neither. The @@ -506,22 +507,27 @@ environment as production, so deploy and destroy refuse it with **The engine underneath is alchemy.** Convergence is executed by [alchemy](https://alchemy.run), a third-party infrastructure-as-code engine that arrives as an ordinary, exactly-pinned npm dependency of `@prisma/composer` (a preview build of 2.0.0-beta.84, commit `972f294`, at this library version). Your code never imports or configures it; consult alchemy's own docs for the engine itself. What matters operationally: -Composer runs the alchemy package installed beside the app's +`prisma deploy` runs alchemy inside the `prisma` process through alchemy's +programmatic API: no generated file and no child process. Its progress prints +as one step per resource, and a failure is `DEPLOY.ENGINE_FAILED` carrying the +engine's own errors (credentials redacted, capped at 1000 characters), or +`DEPLOY.INTERRUPTED` after Ctrl-C, which releases the environment's lease. +For `prisma dev` and `destroy`, Composer runs the alchemy package installed beside the app's `@prisma/composer` and starts it with Node (the first `node` on PATH when `prisma` runs under Bun; `DEPLOY.NODE_MISSING` if none), so the app needs no direct `alchemy` dependency and no `.bin` link. No global Alchemy installation is needed. -1. The deploy and destroy operations write the pipeline's results to a generated, gitignored - stack file at `.prisma-composer/alchemy.run.ts`, then run the alchemy CLI +1. The destroy operation writes the pipeline's results to a generated, gitignored + stack file at `.prisma-composer/alchemy.run.ts`, then runs the alchemy CLI against it as a child process; `dev` does the same at `.prisma-composer/dev//alchemy.run.ts` with local providers. The file carries the computed values as literals but reads credentials via `fromEnv()`, so nothing sensitive lands on disk, and it is regenerated every run: output, not configuration, never edited. -2. Failures are bisectable through that file. A failing deploy names its +2. Failures are bisectable through that file. A failing destroy or dev converge names its path and prints a reproduce command: Node running the bin of the `alchemy` - Composer depends on, with `deploy .prisma-composer/alchemy.run.ts --yes + Composer depends on, with `destroy .prisma-composer/alchemy.run.ts --yes --stage ` (Alchemy's own flag; on Prisma Cloud its value is the environment's Branch id). Running it directly separates "the framework computed the wrong thing" from "the engine or platform rejected the right thing". An engine failure surfaces as diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index b5657529f..cb3fc35ff 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -12,7 +12,7 @@ * Composer family through the root pnpm override, the same way the example * apps do, rather than importing the CLI's internals. * - * `deploy` declares `needs: { credentials: 'child' }`, so the engine refuses + * `deploy` declares `needs: { credentials: true }`, so the engine refuses * the run before the handler when nothing is signed in — which would stop * these invocations short of the resolution they exist to exercise. Both cases * therefore supply an environment credential, and the second one supplies a From bfa4539764917fae8f937523fb9868bf6114a769 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 31 Oct 2026 10:48:05 +0100 Subject: [PATCH 02/14] Address slice 2 review: app install, values-only lower, safe process state - Load lower and convergeInProcess from the app's own @prisma/composer (new @prisma/composer/internal/converge entry), resolved from the app directory; DEPS.ENGINE_UNRESOLVABLE when it cannot be loaded. - lower() takes containers and preflight payloads as required values; transportedLowerInputs() reads both transports for the generated stack files. Core invariant 4 pins process.env to that helper. - convergeInProcess refuses a second concurrent run (DEPLOY.CONVERGE_RUNNING) and a worker thread that would need chdir (DEPLOY.WORKER_THREAD); every process state change is inside the try; Alchemy interaction output goes to stderr; it takes the operation and returns typed failure reasons. - The console redirect moved to the prisma deploy command. - Messages, the engine failure and stack-building errors are redacted. - The service-token check moved into the operation, before preflight and ensure. The command passes a token only for an environment credential, with that credential's workspace, and PRISMA_WORKSPACE_ID is removed when it names none. credentials carries the token; convergeEnv is gone. - family/converge.ts is now family/dev-converge-child.ts. - Docs: ADR-0044 registry, ADR-0064, ADR-0043, ADR-0049, ADR-0062, build-reporting.md, deploy-cli.md and the deploying guide. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 6 + .../design/05-prisma-cloud/build-reporting.md | 12 +- docs/design/10-domains/deploy-cli.md | 8 +- ...path-is-the-programmatic-deploy-surface.md | 2 +- ...l-envelopes-with-dotted-namespace-codes.md | 13 +- ...s-the-composer-section-of-prisma-config.md | 2 + ...s-come-from-resolve-and-config-adapters.md | 2 + ...ADR-0064-deploy-runs-alchemy-in-process.md | 19 +- docs/design/90-decisions/README.md | 6 +- docs/guides/deploying.md | 2 +- .../core/src/__tests__/invariants.test.ts | 4 +- .../core/src/__tests__/lowering.test.ts | 301 +++++++++++------- .../1-core/core/src/control/deploy.ts | 37 ++- .../0-framework/3-tooling/cli/package.json | 1 + .../cli/src/__tests__/app-engine.test.ts | 89 ++++++ .../src/__tests__/converge-in-process.test.ts | 135 +++++++- .../fixtures/stack-import/stub-composer.ts | 6 +- .../cli/src/__tests__/generate-stack.test.ts | 8 +- .../3-tooling/cli/src/app-engine.ts | 64 ++++ .../3-tooling/cli/src/converge-in-process.ts | 110 ++++--- .../3-tooling/cli/src/deployment-summary.ts | 23 +- .../dev/__tests__/generate-dev-stack.test.ts | 16 +- .../cli/src/dev/generate-dev-stack.ts | 8 +- .../3-tooling/cli/src/exports/converge.ts | 11 + .../cli/src/family/__tests__/deploy.test.ts | 87 +++-- ...awn.test.ts => dev-converge-child.test.ts} | 2 +- .../cli/src/family/commands/deploy.ts | 69 ++-- .../3-tooling/cli/src/family/commands/dev.ts | 2 +- .../cli/src/family/console-to-stderr.ts | 22 ++ .../{converge.ts => dev-converge-child.ts} | 0 .../3-tooling/cli/src/generate-stack.ts | 3 +- .../operations/__tests__/operations.test.ts | 278 +++++++++++++--- .../src/operations/execute-deploy-destroy.ts | 210 +++++++++--- .../3-tooling/cli/src/operations/shared.ts | 25 +- .../3-tooling/cli/tsdown.config.ts | 1 + .../src/__tests__/control-lowering.test.ts | 53 ++- packages/9-public/composer/package.json | 3 +- .../composer/src/exports/internal-converge.ts | 1 + packages/9-public/composer/tsdown.config.ts | 1 + test/integration/test/control.deploy.test.ts | 6 + tsconfig.depcruise.json | 4 + 41 files changed, 1229 insertions(+), 423 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts create mode 100644 packages/0-framework/3-tooling/cli/src/app-engine.ts create mode 100644 packages/0-framework/3-tooling/cli/src/exports/converge.ts rename packages/0-framework/3-tooling/cli/src/family/__tests__/{converge-spawn.test.ts => dev-converge-child.test.ts} (98%) create mode 100644 packages/0-framework/3-tooling/cli/src/family/console-to-stderr.ts rename packages/0-framework/3-tooling/cli/src/family/{converge.ts => dev-converge-child.ts} (100%) create mode 100644 packages/9-public/composer/src/exports/internal-converge.ts diff --git a/architecture.config.json b/architecture.config.json index 4e2999f0d..6870cb71c 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -762,6 +762,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer/src/exports/internal-converge.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer-cli/src/exports/family.ts", "domain": "public", diff --git a/docs/design/05-prisma-cloud/build-reporting.md b/docs/design/05-prisma-cloud/build-reporting.md index aaab265a5..4f6c1b49b 100644 --- a/docs/design/05-prisma-cloud/build-reporting.md +++ b/docs/design/05-prisma-cloud/build-reporting.md @@ -46,8 +46,7 @@ The CLI calls the hook three times: `state` (`succeeded`, `failed` or `cancelled`), `failingStep` (Composer's error code, capped at 500 characters) and `errorMessage` (capped at 5000). -The session also hands the alchemy child its environment through -`childEnv()`: `PRISMA_BUILD_ID`, so reporting inside the apply finds the run. +The session also hands the apply its environment through `childEnv()`: `PRISMA_BUILD_ID`, so reporting inside the apply finds the run. `deploy` runs the apply in its own process and sets these variables in `process.env` for the length of the apply ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). The reporter installs no signal handlers. The CLI engine owns signals, and an interrupt reaches the reporter as `cancelled` on the ordinary `finish` path. @@ -170,9 +169,7 @@ has no resource row; the Build's `projectId` carries the association. `--report ` or `PRISMA_COMPOSER_REPORT_FILE` writes a versioned JSON file with the app, its environment, its nodes and entities (including preview URLs), and -the failure cause. It is written on failure too. It is separate from the -internal summary the alchemy child hands the CLI, which is deleted after every -run so resource ids and URLs do not accumulate on disk. The Prisma GitHub +the failure cause. It is written on failure too. It is separate from the deployment summary `deploy` returns as a value, which is never written to disk. The Prisma GitHub Action reads it to comment preview links on a pull request. It is transitional: it can go once the Action reads the Build, topology and versions from the platform. @@ -199,10 +196,7 @@ from the platform. builds. An interrupt the engine catches is reported as `cancelled`; SIGKILL and a torn-down CI runner cannot be. CI cancellation is common, so this is the normal case, not a defect. -- **A failed apply reports `DEPLOY.ENGINE_FAILED` as its `failingStep`.** Every - failure inside the alchemy child shares that code. The `errorMessage` - carries the exit status and, when the child recorded one on its way out, the - underlying cause. +- **A failed apply reports `DEPLOY.ENGINE_FAILED` as its `failingStep`.** Every failure Alchemy's plan or apply ends with shares that code. The `errorMessage` carries the errors Alchemy failed with, with every credential and pool secret removed. - **`branchId` stays empty on a `production` deploy.** Only the Branch of another environment is attached to the Build. diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 9ca6ff778..23d181060 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -96,7 +96,7 @@ that relative imports inside the entry graph may use `./x.js` or extensionless bun APIs can only deploy under bun, since loading the graph imports that module — the app's choice, not a CLI limit. -**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`), using the `alchemy` that `@prisma/composer-cli` depends on ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. +**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`) ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). It loads core's `lower()` and the converge from `@prisma/composer/internal/converge`, resolved from the app directory, so they use the app's own install of `alchemy` and `effect`, the same one its extensions and providers come from; the `prisma` host's pinned Composer version does not matter here. An app whose `@prisma/composer` cannot be resolved, or predates that entry, fails with `DEPS.ENGINE_UNRESOLVABLE` before anything is created. Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. **The Alchemy child's runtime (`destroy` and `prisma dev`).** These still start an Alchemy child. Composer runs the `bin` of the `alchemy` package that the app's `@prisma/composer` depends on, found from the app @@ -185,12 +185,12 @@ user can rerun it by hand (ADR-0007). On `deploy`, an older extension's `ensure` may already have created the app's Project by then; the check stops everything after that. 9. **Lower and apply.** `deploy` calls core's `lower()` with the pipeline's results as values: the name, the environment, the config pool, the assembled bundles, every resolved container, every extension's preflight payload, and the report hook. It passes the stack value `lower()` returns to `Alchemist.plan` and then `Alchemist.apply`, in the `prisma` process, with Alchemy's stage set as § CLI behavior notes describes ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). No stack file is written and no child is started. - - **Progress.** Alchemy's typed events become step events under the `deploy.apply` step: one step per resource, labelled with Alchemy's status word (`creating shop.api`, then `created shop.api`), and Alchemy's log lines become messages. Human and JSON output keep one format. Alchemy's console output is replaced, and `console.log` goes to stderr while Alchemy runs, so nothing but the engine writes to stdout. + - **Progress.** Alchemy's typed events become step events under the `deploy.apply` step: one step per resource, labelled with Alchemy's status word (`creating shop.api`, then `created shop.api`), and Alchemy's log lines become messages, with credentials and pool secrets removed. Human and JSON output keep one format. Alchemy's console output is replaced, its interaction output goes to stderr, and the `prisma deploy` command sends `console`'s stdout methods to stderr while the operation runs. A provider that writes to `process.stdout` directly is out of reach. - **Result.** The report hook runs inside the apply, in the same process, so the deployment summary comes back as a value. - **Failure.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`. Its message carries the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's (`--adopt`) and every credential and pool secret removed. - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI exits 130. - - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them after. - - **Credentials.** The service token must be in the environment as `PRISMA_SERVICE_TOKEN`. The engine gives a command no way to read a `prisma login` session's token, so a signed-in session without it fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before anything runs. + - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them on every exit path. One process runs one converge at a time (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). + - **Credentials.** Alchemy needs a service token. The engine gives a command no way to read a `prisma login` session's token, so the command passes a token only when the active credential came from `PRISMA_SERVICE_TOKEN`, with that credential's workspace. The operation checks for it before preflight and before any container is ensured, and fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` without one. `destroy` (and `prisma dev`) write the pipeline's results as a runnable stack module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one environment variable per extension — content the CLI writes but never reads (ADR-0038). `alchemy destroy` re-imports and re-evaluates the same stack, so its target reconstruction needs the containers. The generated file and Alchemy's state live in the process's working directory — tool state lives where you run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. diff --git a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md index 66a38e74d..aa9cbbac8 100644 --- a/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md +++ b/docs/design/90-decisions/ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md @@ -27,7 +27,7 @@ if (result.ok) { switch (result.failure.code) { case 'DEPLOY.STAGE_INVALID': // e.g. a stage name git would reject case 'ASSEMBLE.BUILD_FAILED': // the app's built output is missing - case 'DEPLOY.ENGINE_FAILED': // alchemy ran and exited nonzero + case 'DEPLOY.ENGINE_FAILED': // Alchemy's plan or apply failed report(result.failure.message); } } diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index d0a48206f..89079f07a 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -1,6 +1,6 @@ # ADR-0044: Errors are structural envelopes with dotted namespace codes -Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED` are added. +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD` and `DEPS.ENGINE_UNRESOLVABLE` are added. Amended 2026-10-23: the `CONFIG` registry gains ENTRY_MISSING, for a command or operation that has no entry module from the config or an override. @@ -102,21 +102,24 @@ Closed subcode registry: CONTAINER_FAILED, PREFLIGHT_FAILED, ENGINE_FAILED, TEARDOWN_FAILED, CONTAINER_REMOVE_FAILED, BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED, - INTERRUPTED, SERVICE_TOKEN_REQUIRED + INTERRUPTED, SERVICE_TOKEN_REQUIRED, CONVERGE_RUNNING, WORKER_THREAD *(STAGE_INVALID, STAGE_UNVALIDATABLE and SCOPE_MISSING retired by [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md))*. ENVIRONMENT_REQUIRED: a `destroy` call names no environment. + ENGINE_FAILED: Alchemy failed. `deploy` runs Alchemy in process and exits 2; `destroy` and `prisma dev` pass the Alchemy child's exit status through (see § Exit codes). INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. - SERVICE_TOKEN_REQUIRED: `prisma deploy` has no `PRISMA_SERVICE_TOKEN` in - the environment ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). + SERVICE_TOKEN_REQUIRED: `deploy`'s credential is not a service token, such as a `prisma login` session, or there is none in the environment ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). Temporary: it is retired when `@prisma/cli-engine` gives a command the active access token (the in-process credentials need, `needs: { credentials: 'process' }`, asked of `@prisma/cli-engine`), so a session can deploy. + CONVERGE_RUNNING: another in-process converge holds this process's environment and working directory. + WORKER_THREAD: `deploy` was called from a worker thread with a `cwd` other than the process's working directory, which a worker cannot change. `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. - `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. - `LOG` — PLATFORM_UNSUPPORTED, ATTACH_FAILED, ADDRESS_UNKNOWN -- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. +- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE, ENGINE_UNRESOLVABLE *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. EXTENSION_INCOMPATIBLE: an installed extension is older than the Composer running the deploy or destroy, so the container it resolved, rebuilt through its own `deserialize`, does not name the environment that was asked for. + ENGINE_UNRESOLVABLE: `deploy` cannot load `@prisma/composer/internal/converge` from the app's own install, or that install predates it ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). Adding a subcode is an edit to this list; adding a namespace is an amendment to this ADR. diff --git a/docs/design/90-decisions/ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md b/docs/design/90-decisions/ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md index 5deff83ea..fb413acf3 100644 --- a/docs/design/90-decisions/ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md +++ b/docs/design/90-decisions/ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md @@ -1,5 +1,7 @@ # ADR-0049: Composer's configuration is the `composer` section of `prisma.config.ts` +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` no longer starts an Alchemy child or imports the declaring file in one, so the child-process reason below now holds for `destroy` and `prisma dev` only. The decision is unchanged: the section still comes from one file, and `file` is still returned. + Amended by [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md): the section gains `config` (and later `secrets`), each an array of adapters. Amended after acceptance: the Alternatives section adds four rejected ways to keep a Composer-owned effect check, and the Consequences state the exit code of `CLI.CONFIG_UNREADABLE`. The decision is unchanged. diff --git a/docs/design/90-decisions/ADR-0062-environment-values-come-from-resolve-and-config-adapters.md b/docs/design/90-decisions/ADR-0062-environment-values-come-from-resolve-and-config-adapters.md index 7250ba97c..1002665c4 100644 --- a/docs/design/90-decisions/ADR-0062-environment-values-come-from-resolve-and-config-adapters.md +++ b/docs/design/90-decisions/ADR-0062-environment-values-come-from-resolve-and-config-adapters.md @@ -1,5 +1,7 @@ # ADR-0062: A value that differs per environment is computed with `resolve()`; values from outside the code come through config adapters +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` runs Alchemy in its first process and passes the config pool to `lower()` as a value, so it starts no second process and writes no configuration file. What § Which commands run the adapters says about a second process now holds for `prisma dev` and `destroy` only. + ```ts // module.ts import { type } from 'arktype'; diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index dad3f545f..59e1a5871 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -5,6 +5,7 @@ `prisma deploy`, and the `deploy` operation on `@prisma/composer/control`, run Alchemy's plan and apply in the process that runs the deploy, through Alchemy's programmatic API (`alchemy/Alchemist`). Deploy writes no `.prisma-composer/alchemy.run.ts` and starts no Alchemy child. ```ts +const { lower, convergeInProcess } = await import(/* the app's @prisma/composer/internal/converge */); const stack = lower(root, config, { name, environment, configPool, bundles, containers, // each extension's resolved container @@ -17,12 +18,15 @@ yield* Alchemist.apply(plan); This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. -- **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Without them `lower()` reads the environment transports, which the generated stack file still needs. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. +- **The app's install runs the deploy.** The executor loads `lower` and `convergeInProcess` from `@prisma/composer/internal/converge`, resolved from the app directory, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from the app's install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the engine from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entry fails with `DEPS.ENGINE_UNRESOLVABLE` before anything is created. +- **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as required values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Only the generated stack files for `destroy` and `prisma dev` read the environment transports, through one core helper, `transportedLowerInputs(config)`. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. - **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. -- **Failures are structured.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`, carrying the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's and secrets removed. An interrupted one is `DEPLOY.INTERRUPTED`. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. +- **Failures are structured.** The converge returns the errors Alchemy failed with as typed reasons (tag and message), and the deploy maps them to `DEPLOY.ENGINE_FAILED`, with Composer's advice in place of the Alchemy CLI's and secrets removed. An interrupted one is `DEPLOY.INTERRUPTED`. Every message event, and an error raised while the stack is built, is redacted the same way. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. - **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. -- **The credential is an environment variable.** Alchemy reads its configuration only from `process.env` and `.env`. `deploy` sets `PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID` and each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both after. `@prisma/cli-engine` 0.9.0 gives a command no way to read a `prisma login` session's token, so `prisma deploy` requires `PRISMA_SERVICE_TOKEN` in the environment and otherwise fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before anything runs. +- **The credential is an environment variable.** Alchemy reads its configuration only from `process.env` and `.env`. `deploy` sets `PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID` and each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. Only Alchemy's credentials and working directory need process state; the reporter variables ride the same overlay until a reporter can read them as values. `@prisma/cli-engine` 0.9.0 gives a command no way to read a `prisma login` session's token. So the operation checks for a service token before preflight and before any container is ensured, and refuses with `DEPLOY.SERVICE_TOKEN_REQUIRED`. The command passes a token only when the engine's active credential came from the environment, with that same credential's workspace; when the credential names no workspace, `PRISMA_WORKSPACE_ID` is removed for the apply. A token from one credential is never paired with a workspace from another. +- **One converge per process.** Because of that process state, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`, and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. +- **Stdout stays the engine's.** Alchemy's log lines and events go to the caller as values, and Alchemy's non-interactive `Interaction` writes to stderr. The `prisma deploy` command, not the operation, sends `console`'s stdout methods to stderr while the operation runs, so a provider's `console.log` cannot corrupt JSON output; a host calling the operation keeps its own `console`, as ADR-0043 requires. A provider that writes to `process.stdout` directly is out of reach. ## Reasoning @@ -33,9 +37,10 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy ## Consequences - `deploy` no longer resolves Alchemy's bin or needs Node on PATH under Bun. `DEPLOY.ALCHEMY_BIN_MISSING`, `DEPLOY.NODE_MISSING` and `DEPLOY.STACK_WRITE_FAILED` remain for `destroy` and `prisma dev`. -- New codes: `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED`. +- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD` and `DEPS.ENGINE_UNRESOLVABLE`. +- The converge relies on two Alchemy modules outside `alchemy/Alchemist`: `alchemy/Util/LogRecord` (to read log records) and `alchemy/Interaction` (to send interaction output to stderr). - `prisma deploy` declares `needs: { credentials: true }` and no longer `maySpawn`. -- Setting `process.env` and the working directory for the length of the apply is process-wide state. Two deploys in one process at once would see each other's values. Neither the CLI nor any known host does this; the upstream asks below remove the need. +- Setting `process.env` and the working directory for the length of the apply is process-wide state, so one process runs one converge at a time. The upstream asks below remove the need. - Interactive deploys signed in with `prisma login` need `PRISMA_SERVICE_TOKEN` until `@prisma/cli-engine` gives a command the active access token, or Alchemy lets a caller pass configuration to `plan` instead of reading only the environment. ## Alternatives considered @@ -47,5 +52,7 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy ## Related -- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md), [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md), [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md), [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) +- Amends: [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) (superseded for `deploy`), [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md), [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md), [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md), [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) (its child-process reason narrows to `destroy` and `prisma dev`), [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) (deploy has no second process). +- [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md), [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) +- [`../05-prisma-cloud/build-reporting.md`](../05-prisma-cloud/build-reporting.md) - [`../10-domains/deploy-cli.md`](../10-domains/deploy-cli.md) § The pipeline, step 9 diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 473fa94a2..ffe5f1678 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -70,7 +70,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma ORM 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. - [ADR-0048](ADR-0048-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md) — The six Management-API resource families (project, database, connection, app, deployment, environment variable) are the upstream Prisma provider's (`@alchemy.run/prisma`, formerly `alchemy/Prisma`) classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where the upstream provider has no support yet (buckets) or no Management API exists behind them. Compute binds the low-level App/Deployment/EnvironmentVariable trio (the `COMPOSER_*_ORIGIN` self-edge and ADR-0005 rule out composite `Compute`); the env→deployment ordering edge rides the deployment's `app` prop, and a deployment is replaced exactly when its artifact or environment fingerprint changes — unchanged services are reused. Legacy rows in the platform state API (retired type-ids and shapes) migrate on read — the older retired SQL state stores stay destroy-only under ADR-0045 — branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. *(Amended by ADR-0057: resource IDs are the node's full address, or the address plus `.` and a role, each declaring its previous ID with `renamedFrom`.)* -- [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) — Composer's configuration is the `composer` section of `prisma.config.ts`, loaded by the Prisma CLI engine. The validator checks only the fields that identify extension and state descriptors, passes descriptors through as the file's own objects, rejects unknown keys, and takes the nearest declaring file's section whole so the generated stack imports exactly one file. The old `prisma-composer.config.ts` and the `configPath` pointer are refused with `CONFIG.SECTION_MISSING`, `CONFIG.FIELD_RETIRED` and `CONFIG.FILE_RETIRED`, never migrated. The validator returns the section with its declaring file, and the programmatic operations take the same `ComposerConfigSource` (`{ value, file }`) and run the same checks. The Alchemy child evaluates the whole `prisma.config.ts`. The effect pre-flight and `DEPS.EFFECT_VERSION_CONFLICT` are retired: a broken tree fails evaluation of `prisma.config.ts` with the engine's `CLI.CONFIG_UNREADABLE`. *(Amended: the alternatives section lists the rejected ways to keep a Composer-owned effect check. Amended 2026-10-23: the section takes an optional `entry`, resolved against the declaring file's directory; `--entry` or an operation's `entry` overrides it; neither gives `CONFIG.ENTRY_MISSING`.)* *(Amended by ADR-0062: the section gains `config` adapters.)* *(Amended 2026-10-30: the section takes `config` and `secrets` adapter lists, defaulting to `[environmentVariables()]`; the stack files read the pools again in the child.)* +- [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md) — Composer's configuration is the `composer` section of `prisma.config.ts`, loaded by the Prisma CLI engine. The validator checks only the fields that identify extension and state descriptors, passes descriptors through as the file's own objects, rejects unknown keys, and takes the nearest declaring file's section whole so the generated stack imports exactly one file. The old `prisma-composer.config.ts` and the `configPath` pointer are refused with `CONFIG.SECTION_MISSING`, `CONFIG.FIELD_RETIRED` and `CONFIG.FILE_RETIRED`, never migrated. The validator returns the section with its declaring file, and the programmatic operations take the same `ComposerConfigSource` (`{ value, file }`) and run the same checks. The Alchemy child evaluates the whole `prisma.config.ts`. The effect pre-flight and `DEPS.EFFECT_VERSION_CONFLICT` are retired: a broken tree fails evaluation of `prisma.config.ts` with the engine's `CLI.CONFIG_UNREADABLE`. *(Amended: the alternatives section lists the rejected ways to keep a Composer-owned effect check. Amended 2026-10-23: the section takes an optional `entry`, resolved against the declaring file's directory; `--entry` or an operation's `entry` overrides it; neither gives `CONFIG.ENTRY_MISSING`.)* *(Amended by ADR-0062: the section gains `config` adapters.)* *(Amended 2026-10-30: the section takes `config` and `secrets` adapter lists, defaulting to `[environmentVariables()]`; the stack files read the pools again in the child.)* *(Amended by ADR-0064: the child-process reason for one declaring file now holds for `destroy` and `prisma dev`; the decision stands.)* - [ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md) — Composer has no binary of its own: the `prisma` CLI mounts exactly two Composer commands, `prisma deploy` and `prisma dev`, mounted at the root as bare verbs on purpose rather than under a noun. `destroy` and `log` are operations on `@prisma/composer/control`, called from a script; a teardown script reads `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. Where teardown and logs belong in the `prisma` command tree is not decided; until it is, agents and users need a script for both. A `destroy` command was rejected: the CLI's destructive verb is `delete`, and no Composer command takes `--production`. Retires the `prisma-composer` binary; a CI lint keeps its name out of user-facing files. *(Amended by ADR-0053: the family also ships `dev url`, `dev stop` and `emulator list`, and `prisma dev` gains `--detach`. Amended 2026-10-23: neither command takes a positional entry; both take `--entry` over the config's `entry`. Amended 2026-10-27: the family also ships `deploy list`.)* *(Amended by ADR-0062: the flag is `--environment`, defaulting per command; `destroy` targets an environment by name.)* - [ADR-0051](ADR-0051-a-nodes-address-is-its-logical-id-on-the-platform.md) — A node's address is its identity on Prisma Cloud: Composer writes it, byte for byte, as the `logicalId` of the node's one platform row (Project, App, Database, Bucket) and as the node's `logicalId` in the application topology, so the platform joins topology nodes to rows, and one node across branches, by string equality. A provision ID defaults to the node's name, so changing a name changes identity. The value is never an Alchemy resource ID, a display name or a platform `id`; App, Database and Bucket rows get it as a prop on the upstream resource that creates them, and the Project from container resolution; no separate resource writes it. Changing an address changes the entity's identity; changing a display name changes nothing. *(Amended 2026-10-16: on the app's own Project and Branch, the row holding a node's `logicalId` is that node's row, so a deploy resuming an interrupted create uses it. Superseded by ADR-0057: addresses start with the app name, and resource IDs, env keys and `serviceOrigin()` are built from them; the identity rules here are carried forward.)* - [ADR-0052](ADR-0052-composer-maps-its-nodes-to-emulator-instances-per-directory.md) — The local emulators know nothing about Composer: the Compute, Postgres and bucket emulators key their instances by ids they generate and store labels they never read. Composer records per working directory, in `.prisma-composer/dev/emulator-instances.json`, which instance backs each node of each app run there, and nothing else maps Composer's names to instances, so two directories running one app get separate services, databases and buckets. An instance keeps its port until it is deleted, because the port is in the deployment's environment and its dependents' URLs and a warm start does not redeploy; a remembered port is requested, as a preference, only when an instance is re-created. When `prisma dev` ends it stops the app's services and database servers, keeping their data. Running a different app from a directory stops the previous app's instances and keeps them; `--fresh` deletes only the current app's. Amends ADR-0041's app-name local identity. *(Amended by ADR-0057: record keys are full addresses; an older record is rewritten on the first `prisma dev`. Amended 2026-10-28: each environment runs its own local copy, with its files in `.prisma-composer/dev//`; the record is keyed by app, environment and node and has no `current` app; running another app or environment stops nothing; `--fresh` deletes only that app's copy in that environment; earlier local state is moved into the `dev` environment.)* @@ -83,6 +83,6 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md) — A place the app runs is an **environment**: a target-neutral concept of Composer's core, used on every public surface (`--environment`, `destroy`'s target, guides, errors, skill), replacing "stage". Every environment has a name and none is special: `prisma deploy` defaults to `production`, `prisma dev` to `dev`. On Prisma Cloud, `production` is the Project's default Branch and any other environment is the Branch of that `gitName`; `dev` has no Branch. "Branch" is Prisma Cloud's term and its git-shaped workflow, and appears only where Composer reports what the target did. - [ADR-0060](ADR-0060-public-traffic-reaches-a-service-through-a-public-url.md) — Traffic from the internet reaches a service only through a **public URL** the author declares: `publicUrl({ name, url, target })`, a driver wired to the HTTP connection a service exposes, as `cron` is wired to what it calls. `url` is one of two explicit strategies, a named hostname or the prisma.build URL the platform gives every service, usually computed per environment with `resolve()`. One hostname per public URL. A reusable module decides what can be public by exposing connections; the app decides what is. Services stay publicly reachable underneath until the platform can address them privately. On Prisma Cloud a named hostname lowers to the platform's RoutingUrl record, covered by a domain ownership claim the workspace has verified; a prisma.build URL lowers to nothing, because every service keeps the platform's own hostname. *(Amended 2026-10-29: the public URL node is a resource by kind and a driver by role, with a slot wired at construction and `props`; see the note at its top.)* - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its configuration as one Standard Schema, `input`, exactly as a compute service does; its body receives opaque per-key references it can only pass into children's inputs or derive from with `resolve()`, so the graph cannot branch on configuration. Module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, filled by config adapters. *(Amended 2026-10-29: an input field is a `Value`; the module's schema validates its binding during Load's second pass; a truthiness check on a field is not caught, but always goes the same way.)* *(Amended 2026-10-29, third slice: `paramSource()` and `secretSource()` return values and take an options argument.)* *(Amended 2026-10-30: a root's input is the config pool.)* *(Amended 2026-10-30, fifth slice: the slots are removed; `ModuleNode`.)* *(Amended 2026-10-30, seventh slice: a module's schema checks each runtime value as its pointer; `paramSource()` loses `sentinel`.)* *(Amended 2026-10-30, eighth slice: only a plain field can be a `resolve()` dependency.)* -- [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. +- [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. *(Amended by ADR-0064: `deploy` runs Alchemy in its first process and writes no configuration file; the second process applies to `prisma dev` and `destroy`.)* - [ADR-0063](ADR-0063-a-node-depends-on-an-interface-or-exposes-one.md) — The two ends of a connection are named by what the node does with them: a node **depends on** an interface (`deps`) or **exposes** one (`expose`); an interface is a `Contract` in code; Input and Output are retired as port words, because the dataflow and ports-and-adapters traditions give the same two ends opposite names; the module body's `inputs` becomes `deps`; the topology's `in`/`out` stay, defined as the dependency end and the exposed end. -- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043 and ADR-0044. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 980f7d918..ecd5fe9aa 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -742,7 +742,7 @@ What to know before embedding it: a step fails, it and the steps around it finish as `failed`; `record` still runs. An `onEvent` that throws does not fail the deploy. A successful result's `durationMs` is the whole deploy's total. -- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env`, changes to `cwd` when your process is elsewhere, and sends `console.log` to stderr, then restores all three, so run one deploy at a time per process. Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. +- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env` and changes to `cwd` when your process is elsewhere, then restores both, so a process runs one deploy at a time (`DEPLOY.CONVERGE_RUNNING`), and from a worker thread `cwd` must be the process's working directory (`DEPLOY.WORKER_THREAD`). Deploy uses the lowering and the deploy engine from the app's own install of `@prisma/composer`. Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. - **`dev` resolves to `{ ok: true, value: session }` or a failure** — never an exit code. The session is `{ endpoints, stop(), closed }`, with progress (`ready`, `converge-failed`, `config-changed`, `watch-error`, …) delivered through diff --git a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts index f489a46aa..cc6dd372d 100644 --- a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts @@ -141,7 +141,7 @@ describe("invariant 2: the '.' authoring entry bundles lean", () => { }); describe('invariant 4: core touches the environment only to read back the transports it wrote', () => { - test("the process-env token appears only in control/deploy.ts (the container and preflight transports a generated stack file reads back — never a target's own vars)", () => { + test("the process-env token appears only in control/deploy.ts's transportedLowerInputs (the container and preflight transports a generated stack file reads back — never a target's own vars)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -151,7 +151,7 @@ describe('invariant 4: core touches the environment only to read back the transp return count > 0 ? [{ file, count }] : []; }); - expect(hits).toEqual([{ file: 'control/deploy.ts', count: 2 }]); + expect(hits).toEqual([{ file: 'control/deploy.ts', count: 1 }]); }); }); diff --git a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts index 03516b216..be87b466a 100644 --- a/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/lowering.test.ts @@ -29,6 +29,7 @@ import { type ProvisionerDescriptor, resolveStateLayer, type ServiceLowering, + transportedLowerInputs, } from '../control/deploy.ts'; import { Load } from '../graph.ts'; import { @@ -93,6 +94,8 @@ const stateSentinel = (tag: string): AlchemyStateLayer => const opts = (extra: Partial = {}): LowerOptions => ({ name: 'hello', bundles: {}, + containers: new Map(), + preflight: new Map(), ...extra, }); @@ -863,13 +866,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); expect(calls.filter((c) => c.phase === 'application')).toHaveLength(1); @@ -893,13 +900,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const auth = calls.find((c) => c.phase === 'serialize' && c.id === 'shop.auth'); @@ -916,13 +927,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const authProvision = calls.find((c) => c.phase === 'provision' && c.id === 'shop.auth'); @@ -937,13 +952,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const authSerialize = calls.find((c) => c.phase === 'serialize' && c.id === 'shop.auth'); @@ -956,13 +975,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const storefrontSerialize = calls.find( @@ -977,13 +1000,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const authDeploy = calls.find((c) => c.phase === 'deploy' && c.id === 'shop.auth'); @@ -1015,13 +1042,17 @@ describe('lowering a module root — a provisioned resource and two connected se }); run( - lowering(root, config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + root, + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); const authDeploy = calls.findIndex((c) => c.phase === 'deploy' && c.id === 'shop.auth'); @@ -1043,10 +1074,14 @@ describe('lowering a module root — a provisioned resource and two connected se const { config } = fakeExtension(); const error = runError( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' } }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' } }, + }), + ), ); expect(error).toBeInstanceOf(LowerError); @@ -1057,13 +1092,17 @@ describe('lowering a module root — a provisioned resource and two connected se const { config } = fakeExtension(); const result = run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { - 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, - 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, - }, - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { + 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, + 'shop.storefront': { dir: 'modules/storefront/dist/bundle', entry: 'server.js' }, + }, + }), + ), ); expect(result).toBeUndefined(); @@ -1083,7 +1122,7 @@ describe('lowering a module root — a provisioned resource and two connected se return {}; }); - const error = runError(lowering(root, config, { name: 'shop', bundles: {} })); + const error = runError(lowering(root, config, opts({ name: 'shop' }))); expect(error).toBeInstanceOf(LowerError); expect(error.message).toContain('fake/unknown'); @@ -1103,13 +1142,13 @@ describe('lowering a module root — one resource shared by two consumers', () = return {}; }); - const sharedOpts = { + const sharedOpts = opts({ name: 'shop', bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, 'shop.billing': { dir: 'modules/billing/dist/bundle', entry: 'server.js' }, }, - }; + }); test('the resource is lowered exactly once, regardless of consumer count', () => { const { config, calls } = fakeExtension(); @@ -1161,13 +1200,13 @@ describe('provision phase (ADR-0031): resolving a provisioned param against the return { descriptor, calls }; } - const provisionBundles = { + const provisionBundles = opts({ name: 'shop', bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, 'shop.consumer': { dir: 'modules/consumer/dist/bundle', entry: 'server.js' }, }, - }; + }); test("a resolved edge mints once and fills the consumer's param in buildConfig", () => { const provisioner = fakeProvisioner((edge) => `minted:${edge.edgeId}`); @@ -1535,47 +1574,34 @@ describe('container threading through lowering() (the parent→child transport r return {}; }); - test("a stub extension's application and resource contexts observe ctx.container as the deserialized instance", () => { + test("a stub extension's application and resource contexts observe ctx.container as the instance passed in", () => { const { descriptor, config, seen } = fakeExtensionWithContainer(); - const instanceInput = { appName: 'shop', environment: 'production' }; + const passed = new FakeContainerInstance({ appName: 'shop', environment: 'staging' }); - withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify(instanceInput) }, () => { - run(lowering(dbModule(), config, opts())); + withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify({ stale: true }) }, () => { + run(lowering(dbModule(), config, opts({ containers: new Map([[descriptor.id, passed]]) }))); }); expect(seen).toEqual([ - { phase: 'application', container: new FakeContainerInstance(instanceInput) }, - { phase: 'resource', container: new FakeContainerInstance(instanceInput) }, - ]); - }); - - test('the same extension with its transport var unset sees ctx.container as undefined', () => { - const { config, seen } = fakeExtensionWithContainer(); - - run(lowering(dbModule(), config, opts())); - - expect(seen).toEqual([ - { phase: 'application', container: undefined }, - { phase: 'resource', container: undefined }, + { phase: 'application', container: passed }, + { phase: 'resource', container: passed }, ]); }); - test('containers passed as values win over the transport in the environment', () => { + test('an extension with no container passed sees ctx.container as undefined, whatever the environment holds', () => { const { descriptor, config, seen } = fakeExtensionWithContainer(); - const passed = new FakeContainerInstance({ appName: 'shop', environment: 'staging' }); - const stale = { appName: 'shop', environment: 'production' }; - withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify(stale) }, () => { - run(lowering(dbModule(), config, opts({ containers: new Map([[descriptor.id, passed]]) }))); + withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify({ appName: 'shop' }) }, () => { + run(lowering(dbModule(), config, opts())); }); expect(seen).toEqual([ - { phase: 'application', container: passed }, - { phase: 'resource', container: passed }, + { phase: 'application', container: undefined }, + { phase: 'resource', container: undefined }, ]); }); - test("each context carries its own extension's preflight payload: passed as values, else read from the environment", () => { + test("each context carries its own extension's preflight payload from the values passed in", () => { const seen: unknown[] = []; const descriptor: ExtensionDescriptor = { id: 'test/preflight-pack', @@ -1595,11 +1621,32 @@ describe('container threading through lowering() (the parent→child transport r withEnv({ [preflightEnvVarName(descriptor.id)]: 'from-env' }, () => { run(lowering(root, config, opts({ preflight: new Map([[descriptor.id, 'passed']]) }))); - run(lowering(root, config, opts({ preflight: new Map() }))); run(lowering(root, config, opts())); }); - expect(seen).toEqual(['passed', undefined, 'from-env']); + expect(seen).toEqual(['passed', undefined]); + }); + + test('transportedLowerInputs reads both transports a generated stack file was given', () => { + const { descriptor, config } = fakeExtensionWithContainer(); + const instanceInput = { appName: 'shop', environment: 'production' }; + + const inputs = withEnv( + { + [containerEnvVarName(descriptor.id)]: JSON.stringify(instanceInput), + [preflightEnvVarName(descriptor.id)]: 'payload', + }, + () => transportedLowerInputs(config), + ); + const blanked = withEnv( + { [containerEnvVarName(descriptor.id)]: undefined, [preflightEnvVarName(descriptor.id)]: '' }, + () => transportedLowerInputs(config), + ); + + expect(inputs.containers.get(descriptor.id)).toEqual(new FakeContainerInstance(instanceInput)); + expect([...inputs.preflight]).toEqual([[descriptor.id, 'payload']]); + expect(blanked.containers.size).toBe(0); + expect(blanked.preflight.size).toBe(0); }); test('an extension declaring no container descriptor at all sees ctx.container as undefined', () => { @@ -1793,11 +1840,15 @@ describe('lowering a service that runs a dev server (ADR-0056)', () => { const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: storefrontBundle, - unassembled: ['shop.auth'], - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: storefrontBundle, + unassembled: ['shop.auth'], + }), + ), ); const order = calls.map((c) => (c.phase === 'application' ? c.phase : `${c.phase}:${c.id}`)); @@ -1821,11 +1872,15 @@ describe('lowering a service that runs a dev server (ADR-0056)', () => { const { config, calls } = fakeExtension(); run( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: storefrontBundle, - unassembled: ['shop.auth'], - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: storefrontBundle, + unassembled: ['shop.auth'], + }), + ), ); expect(calls.find((c) => c.phase === 'serialize' && c.id === 'shop.storefront')).toMatchObject({ @@ -1837,11 +1892,15 @@ describe('lowering a service that runs a dev server (ADR-0056)', () => { const { config } = fakeExtension({ noReserve: true }); const error = runError( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: storefrontBundle, - unassembled: ['shop.auth'], - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: storefrontBundle, + unassembled: ['shop.auth'], + }), + ), ); expect(error).toBeInstanceOf(LowerError); @@ -1854,11 +1913,15 @@ describe('lowering a service that runs a dev server (ADR-0056)', () => { const { config } = fakeExtension(); const error = runError( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { ...storefrontBundle, 'shop.auth': { dir: 'x', entry: 'y' } }, - unassembled: ['shop.auth'], - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { ...storefrontBundle, 'shop.auth': { dir: 'x', entry: 'y' } }, + unassembled: ['shop.auth'], + }), + ), ); expect(error).toBeInstanceOf(LowerError); @@ -1869,11 +1932,15 @@ describe('lowering a service that runs a dev server (ADR-0056)', () => { const { config } = fakeExtension(); const error = runError( - lowering(twoServiceModule(), config, { - name: 'shop', - bundles: { ...storefrontBundle, 'shop.auth': { dir: 'x', entry: 'y' } }, - unassembled: ['shop.db'], - }), + lowering( + twoServiceModule(), + config, + opts({ + name: 'shop', + bundles: { ...storefrontBundle, 'shop.auth': { dir: 'x', entry: 'y' } }, + unassembled: ['shop.db'], + }), + ), ); expect(error).toBeInstanceOf(LowerError); diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index f25cf1b24..da31cb05e 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -236,10 +236,10 @@ export interface LowerOptions { readonly report?: (result: DeploymentResult) => void; /** Addresses lowered without an artifact: `package` and `deploy` are skipped and `reserve` runs instead (ADR-0056). Only the dev stack file sets it. */ readonly unassembled?: readonly string[]; - /** Each extension's resolved container, by extension id. Absent means read them from the container transport in the environment (the generated stack file's path). */ - readonly containers?: ReadonlyMap; - /** Each extension's preflight payload, by extension id. Absent means read them from the preflight transport in the environment (the generated stack file's path). */ - readonly preflight?: ReadonlyMap; + /** Each extension's resolved container, by extension id. A generated stack file reads them with `transportedLowerInputs`. */ + readonly containers: ReadonlyMap; + /** Each extension's preflight payload, by extension id. A generated stack file reads them with `transportedLowerInputs`. */ + readonly preflight: ReadonlyMap; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -668,11 +668,8 @@ export function lowering( ...(opts.configPool !== undefined ? { configPool: opts.configPool } : {}), }); const extensions = yield* extensionsById(config); - const containers = containersOf(config, opts); - const preflightOf = (extensionId: string): PreflightPayload => - opts.preflight === undefined - ? readPreflightPayload(extensionId, process.env) - : opts.preflight.get(extensionId); + const containers = opts.containers; + const preflightOf = (extensionId: string): PreflightPayload => opts.preflight.get(extensionId); const lowered = new Map(); // Each node's reported entities, in topo order — the loop is the only // party that holds both the node's identity and what it became. Collected @@ -882,12 +879,21 @@ export function lowering( }) as Effect.Effect; } -/** The containers `opts` carries, else the ones the generated stack file's process reads from the container transport. */ -function containersOf( +/** + * The containers and preflight payloads the CLI wrote into a generated stack + * file's environment, for `lower()`. Only the generated stack files that + * `destroy` and `prisma dev` run in an Alchemy child call it. + */ +export function transportedLowerInputs( config: PrismaAppConfig, - opts: LowerOptions, -): ReadonlyMap { - return opts.containers ?? deserializeContainers(config.extensions, process.env); +): Pick { + const env = process.env; + const preflight = new Map(); + for (const { id } of config.extensions) { + const payload = readPreflightPayload(id, env); + if (payload !== undefined) preflight.set(id, payload); + } + return { containers: deserializeContainers(config.extensions, env), preflight }; } /** @@ -899,12 +905,11 @@ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOpti // A LowerError at deploy is fatal; orDie moves it off the error channel to // match what Alchemy.Stack accepts. const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; - const containers = containersOf(config, opts); const providers = opts.providers ?? mergedProviders(config); return Alchemy.Stack( opts.name, - { providers, state: resolveStateLayer(opts, config, containers) }, + { providers, state: resolveStateLayer(opts, config, opts.containers) }, stackEffect, ); } diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 37276795b..61fb0b979 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -7,6 +7,7 @@ "./app-identity": "./dist/app-identity.mjs", "./config": "./dist/config.mjs", "./control": "./dist/control.mjs", + "./converge": "./dist/converge.mjs", "./family": "./dist/family.mjs", "./pools": "./dist/pools.mjs", "./report": "./dist/report.mjs", diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts new file mode 100644 index 000000000..0ad497357 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { CliStructuredError } from '@internal/foundation/errors'; +import { loadAppEngine } from '../app-engine.ts'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** An app whose node_modules holds a @prisma/composer with the given exports and entry source. */ +function appWith( + composer: { exports: Record; files: Record } | undefined, +) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-app-engine-'))); + dirs.push(dir); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'app' })); + if (composer !== undefined) { + const pkg = path.join(dir, 'node_modules', '@prisma', 'composer'); + fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ name: '@prisma/composer', type: 'module', exports: composer.exports }), + ); + for (const [file, source] of Object.entries(composer.files)) { + fs.writeFileSync(path.join(pkg, file), source); + } + } + return dir; +} + +async function failureOf(dir: string): Promise { + try { + await loadAppEngine(dir); + } catch (error) { + if (CliStructuredError.is(error)) return error; + throw error; + } + throw new Error('expected loadAppEngine to fail'); +} + +describe('loadAppEngine', () => { + test("loads lower and convergeInProcess from the app's own @prisma/composer", async () => { + const dir = appWith({ + exports: { './internal/converge': './dist/internal-converge.mjs' }, + files: { + 'dist/internal-converge.mjs': + "export const lower = () => {}; export const convergeInProcess = async () => ({ kind: 'applied' });", + }, + }); + + const engine = await loadAppEngine(dir); + const stub = await import( + pathToFileURL(path.join(dir, 'node_modules/@prisma/composer/dist/internal-converge.mjs')).href + ); + + expect(engine.lower).toBe(stub.lower); + expect(engine.converge).toBe(stub.convergeInProcess); + }); + + test('an app without @prisma/composer is DEPS.ENGINE_UNRESOLVABLE', async () => { + const failure = await failureOf(appWith(undefined)); + + expect(failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + expect(failure.message).toContain('@prisma/composer/internal/converge does not resolve'); + }); + + test('a @prisma/composer older than the in-process converge is DEPS.ENGINE_UNRESOLVABLE', async () => { + const failure = await failureOf( + appWith({ exports: { './deploy': './dist/deploy.mjs' }, files: { 'dist/deploy.mjs': '' } }), + ); + + expect(failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + }); + + test('an entry that exports neither function is DEPS.ENGINE_UNRESOLVABLE', async () => { + const failure = await failureOf( + appWith({ + exports: { './internal/converge': './dist/internal-converge.mjs' }, + files: { 'dist/internal-converge.mjs': 'export const other = 1;' }, + }), + ); + + expect(failure.message).toContain('exports no lower and convergeInProcess'); + }); +}); diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts index 51591b21f..fe097cd2e 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -3,13 +3,14 @@ * an in-memory state store and a fake extension whose one resource node * creates nothing on any platform. */ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { type Contract, module, resource } from '@internal/core'; import type { ExtensionDescriptor, PrismaAppConfig } from '@internal/core/config'; import { type DeploymentResult, type LowerOptions, lower } from '@internal/core/deploy'; +import * as Interaction from 'alchemy/Interaction'; import * as State from 'alchemy/State'; import { InMemoryService } from 'alchemy/State/InMemoryState'; import * as Cause from 'effect/Cause'; @@ -20,7 +21,7 @@ import { type ConvergeEvent, type ConvergeRequest, convergeInProcess, - failureText, + failureReasons, toConvergeEvent, } from '../converge-in-process.ts'; import { deployWithDeps } from '../operations/deploy.ts'; @@ -84,7 +85,14 @@ function request( overrides: Partial = {}, ): ConvergeRequest { return { - stack: lower(app(), config, { name: 'shop', bundles: {}, ...extra }), + operation: 'deploy', + stack: lower(app(), config, { + name: 'shop', + bundles: {}, + containers: new Map(), + preflight: new Map(), + ...extra, + }), stage: 'test', cwd: tmpDir(), env: {}, @@ -124,6 +132,103 @@ describe('convergeInProcess', () => { ); }); + const observing = ( + seen: { token?: string | undefined; cwd?: string }, + end: Effect.Effect | undefined, + ) => + fixture( + Object.assign( + () => + Effect.sync(() => { + seen.token = process.env['COMPOSER_TEST_TOKEN']; + seen.cwd = process.cwd(); + }).pipe(Effect.andThen(end ?? Effect.succeed({ outputs: {}, entities: [] }))), + { kind: 'resource' as const }, + ), + ); + + test('restores the environment and working directory after a failed or interrupted run, and when it cannot change directory', async () => { + const before = process.cwd(); + process.env['COMPOSER_TEST_KEEP'] = 'shell'; + const env = { COMPOSER_TEST_TOKEN: 'tok', COMPOSER_TEST_KEEP: undefined }; + const controller = new AbortController(); + const failedSeen: { token?: string | undefined; cwd?: string } = {}; + const failing = observing( + failedSeen, + Effect.fail(new Error('no')) as Effect.Effect, + ); + const interrupting = observing( + {}, + Effect.sync(() => controller.abort()).pipe(Effect.andThen(Effect.never)), + ); + const cwd = tmpDir(); + + const failed = await convergeInProcess(request(failing.config, {}, { cwd, env })); + const interrupted = await convergeInProcess( + request(interrupting.config, {}, { cwd, env, signal: controller.signal }), + ); + const missingDir = await convergeInProcess( + request(failing.config, {}, { cwd: path.join(cwd, 'gone'), env }), + ); + + expect(failedSeen).toEqual({ token: 'tok', cwd }); + expect(failed.kind).toBe('failed'); + expect(interrupted.kind).toBe('interrupted'); + expect(missingDir.kind).toBe('failed'); + expect(process.env['COMPOSER_TEST_TOKEN']).toBeUndefined(); + expect(process.env['COMPOSER_TEST_KEEP']).toBe('shell'); + expect(process.cwd()).toBe(before); + delete process.env['COMPOSER_TEST_KEEP']; + }); + + test('refuses a second converge while one runs, and runs again once it ends', async () => { + const controller = new AbortController(); + let started!: () => void; + const running = new Promise((resolve) => { + started = resolve; + }); + const { config } = observing( + {}, + Effect.sync(() => started()).pipe(Effect.andThen(Effect.never)), + ); + + const first = convergeInProcess(request(config, {}, { signal: controller.signal })); + await running; + const second = await convergeInProcess(request(fixture(reportsThing).config)); + controller.abort(); + await first; + const third = await convergeInProcess(request(fixture(reportsThing).config)); + + expect(second).toEqual({ kind: 'refused', reason: 'already-running' }); + expect(third).toEqual({ kind: 'applied' }); + }); + + test("Alchemy's interaction output goes to stderr, never stdout", async () => { + const { config } = fixture( + Object.assign( + () => + Effect.gen(function* () { + const interaction = yield* Interaction.Interaction; + yield* interaction.output.info('state store bootstrapped'); + return { outputs: {}, entities: [] }; + }), + { kind: 'resource' as const }, + ), + ); + const stdout = spyOn(process.stdout, 'write'); + const stderr = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + expect(await convergeInProcess(request(config))).toEqual({ kind: 'applied' }); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain( + 'state store bootstrapped', + ); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + test('sets the requested environment and working directory only while it runs', async () => { const seen: { token?: string | undefined; cwd?: string } = {}; const { config } = fixture( @@ -157,7 +262,10 @@ describe('convergeInProcess', () => { const outcome = await convergeInProcess(request(config)); - expect(outcome).toEqual({ kind: 'failed', cause: 'ThingRefused: the platform said no' }); + expect(outcome).toEqual({ + kind: 'failed', + reasons: [{ tag: 'ThingRefused', message: 'the platform said no' }], + }); expect(released()).toBe(true); }); @@ -242,7 +350,11 @@ describe('deploy with the real in-process converge', () => { environment: 'test', cwd: dir, }, - { runAssembler: async () => ({ dir, entry: 'server.js' }) }, + { + runAssembler: async () => ({ dir, entry: 'server.js' }), + engine: { lower, converge: convergeInProcess }, + credentials: { workspaceId: 'ws_1', client: {}, serviceToken: 'tok' }, + }, ); if (!result.ok) throw result.failure; @@ -295,8 +407,8 @@ describe('toConvergeEvent', () => { }); }); -describe('failureText', () => { - test('one line per distinct error, each with its tag', () => { +describe('failureReasons', () => { + test('one reason per distinct error, each with its tag', () => { class Refused extends Data.TaggedError('Refused')<{ readonly message: string }> {} const cause = Cause.combine( Cause.fail(new Refused({ message: 'quota exceeded' })), @@ -306,10 +418,13 @@ describe('failureText', () => { ), ); - expect(failureText(cause)).toBe('Refused: quota exceeded\nboom'); + expect(failureReasons(cause)).toEqual([ + { tag: 'Refused', message: 'quota exceeded' }, + { tag: 'Error', message: 'boom' }, + ]); }); - test('a non-error failure is printed as text', () => { - expect(failureText(Cause.fail('plain'))).toBe('plain'); + test('a non-error failure is kept as text', () => { + expect(failureReasons(Cause.fail('plain'))).toEqual([{ tag: 'Unknown', message: 'plain' }]); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/fixtures/stack-import/stub-composer.ts b/packages/0-framework/3-tooling/cli/src/__tests__/fixtures/stack-import/stub-composer.ts index 08f38f321..6f6fcaa17 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/fixtures/stack-import/stub-composer.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/fixtures/stack-import/stub-composer.ts @@ -14,17 +14,13 @@ plugin({ build.module( '@prisma/composer/deploy', js( - 'export const lower = (app, config, opts) => { console.log(JSON.stringify({ app: app.name, extensions: config.extensions.map((e) => e.id), state: config.state.extension, environment: opts.environment, configPool: opts.configPool, ...(globalThis.stubPools === undefined ? {} : { pools: globalThis.stubPools }) })); return {}; };', + 'export const lower = (app, config, opts) => { console.log(JSON.stringify({ app: app.name, extensions: config.extensions.map((e) => e.id), state: config.state.extension, environment: opts.environment, configPool: opts.configPool, ...(globalThis.stubPools === undefined ? {} : { pools: globalThis.stubPools }) })); return {}; }; export const transportedLowerInputs = () => ({ containers: new Map(), preflight: new Map() });', ), ); build.module( '@prisma/composer/report', js('export const deploymentReport = () => {}; export const captureEngineFailure = () => {};'), ); - build.module( - '@prisma/composer/config', - js('export const deserializeContainers = () => new Map();'), - ); build.module( '@prisma/composer/internal/pools', js( diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts index 3422c7a2c..339dca67a 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/generate-stack.test.ts @@ -21,7 +21,9 @@ describe('renderStackFile() — a module root', () => { }, }); - expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); + expect(content).toContain( + "import { lower, transportedLowerInputs } from '@prisma/composer/deploy';", + ); expect(content).toContain('import prismaConfig from "../prisma.config.ts";'); expect(content).toContain('const config = prismaConfig.composer;'); expect(content).toContain('import app from "../module.ts";'); @@ -152,7 +154,9 @@ describe('renderStackFile() — a module root', () => { // The import lines (and everything after the header) must survive intact // — a block-comment header would have truncated the file right after the // embedded "*/" in the cwd, before these lines are even reached. - expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); + expect(content).toContain( + "import { lower, transportedLowerInputs } from '@prisma/composer/deploy';", + ); expect(content).toContain('name: "app"'); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/app-engine.ts b/packages/0-framework/3-tooling/cli/src/app-engine.ts new file mode 100644 index 000000000..b59230c53 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/app-engine.ts @@ -0,0 +1,64 @@ +/** + * Which copy of Composer's lowering and Alchemy runs `deploy`: the one the + * app installs, found from the app directory the way the generated stack + * file's own `@prisma/composer` imports are. The extension descriptors, + * providers and state store come from that install too, through + * `prisma.config.ts`, so the stack, the engine and the providers share one + * copy of `alchemy` and `effect` even when the `prisma` host pins another + * Composer version (ADR-0050, ADR-0064). + */ +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { lower } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; +import { CliStructuredError } from '@internal/foundation/errors'; +import type { Converge } from './converge-in-process.ts'; + +const ENTRY = '@prisma/composer/internal/converge'; + +/** Lowers the app and converges the stack: one install's `lower` and `convergeInProcess`. */ +export interface DeployEngine { + readonly lower: typeof lower; + readonly converge: Converge; +} + +function unresolvable(appDir: string, detail: string, cause?: unknown): CliStructuredError { + return new CliStructuredError( + 'DEPS.ENGINE_UNRESOLVABLE', + `Could not load the deploy engine from the app's @prisma/composer in "${appDir}": ${detail}`, + { + why: "`prisma deploy` runs the lowering and Alchemy from the app's own install of @prisma/composer, so the app, its extensions and the engine share one copy of alchemy.", + fix: 'Install @prisma/composer in the app, at the same version as the Composer the `prisma` CLI runs, and deploy from the app directory.', + ...(cause === undefined ? {} : { cause }), + }, + ); +} + +/** The app's deploy engine. Raises DEPS.ENGINE_UNRESOLVABLE when the app's @prisma/composer is missing or predates the in-process converge. */ +export async function loadAppEngine(appDir: string): Promise { + let file: string; + try { + file = createRequire(path.join(appDir, 'package.json')).resolve(ENTRY); + } catch (error) { + throw unresolvable(appDir, `${ENTRY} does not resolve.`, error); + } + let loaded: Record; + try { + loaded = await import(pathToFileURL(file).href); + } catch (error) { + throw unresolvable( + appDir, + `${file} failed to load: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + const { lower: appLower, convergeInProcess } = loaded; + if (typeof appLower !== 'function' || typeof convergeInProcess !== 'function') { + throw unresolvable(appDir, `${file} exports no lower and convergeInProcess.`); + } + return blindCast< + DeployEngine, + "the typeof checks above prove both are functions; the entry is this package's own src/exports/converge.ts, built into the app's @prisma/composer" + >({ lower: appLower, converge: convergeInProcess }); +} diff --git a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts index 1fd58ccc7..1665bbc20 100644 --- a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts +++ b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts @@ -1,14 +1,16 @@ /** * Runs a lowered stack's plan and apply in this process through Alchemy's * programmatic API (`alchemy/Alchemist`). Alchemy's typed events and log - * lines come back as {@link ConvergeEvent}s, so the caller renders them in - * its own output format; nothing here writes to stdout. + * lines come back as {@link ConvergeEvent}s, and Alchemy's own output goes to + * stderr, so the caller renders progress in its own format. * - * Reached only behind an operation's lazy executor import: it loads alchemy - * and effect. + * Published as `@prisma/composer/internal/converge`: the CLI loads it from + * the app's install, so it runs on the app's copy of alchemy and effect. */ +import { isMainThread } from 'node:worker_threads'; import type { lower } from '@internal/core/deploy'; import * as Alchemist from 'alchemy/Alchemist'; +import * as Interaction from 'alchemy/Interaction'; import { recordOf } from 'alchemy/Util/LogRecord'; import * as Cause from 'effect/Cause'; import * as Effect from 'effect/Effect'; @@ -38,20 +40,30 @@ export type ConvergeEvent = }; export interface ConvergeRequest { + /** What to plan and apply. Only `deploy` today; `prisma dev` adds its own. */ + readonly operation: 'deploy'; readonly stack: LoweredStack; readonly stage: string; /** The directory Alchemy runs in: where it reads `.env` and keeps `.alchemy`. */ readonly cwd: string; - /** Set in `process.env` while Alchemy plans and applies, and restored after. Alchemy reads its configuration only from there. */ - readonly env: Readonly>; + /** Set in `process.env` while Alchemy plans and applies, and restored after; `undefined` removes the variable for that time. Alchemy reads its configuration only from there. */ + readonly env: Readonly>; readonly signal?: AbortSignal | undefined; readonly onEvent?: ((event: ConvergeEvent) => void) | undefined; } +/** One error a failed converge ended with: the error's tag or class name, and its message. */ +export interface ConvergeFailureReason { + readonly tag: string; + readonly message: string; +} + export type ConvergeOutcome = | { readonly kind: 'applied' } | { readonly kind: 'interrupted' } - | { readonly kind: 'failed'; readonly cause: string }; + | { readonly kind: 'failed'; readonly reasons: readonly ConvergeFailureReason[] } + /** Nothing ran: another converge holds this process's environment and working directory, or this is a worker thread that cannot change directory. */ + | { readonly kind: 'refused'; readonly reason: 'already-running' | 'worker-thread' }; /** Plans and applies a lowered stack. The deploy executor's default is {@link convergeInProcess}. */ export type Converge = (request: ConvergeRequest) => Promise; @@ -120,47 +132,44 @@ function logEvents(emit: (event: ConvergeEvent) => void) { }); } -/** The failure a converge ended with, as one line per error: the error's name when it has one, then its message. */ -export function failureText(cause: Cause.Cause): string { - const lines = cause.reasons.flatMap((reason) => { +/** The errors a converge failed with, one per distinct error, interruptions left out. */ +export function failureReasons(cause: Cause.Cause): readonly ConvergeFailureReason[] { + const reasons = cause.reasons.flatMap((reason): ConvergeFailureReason[] => { if (Cause.isInterruptReason(reason)) return []; const error = Cause.isFailReason(reason) ? reason.error : reason.defect; if (error instanceof Error) { const tag = '_tag' in error && typeof error._tag === 'string' ? error._tag : error.name; - return [ - tag === 'Error' || error.message.startsWith(tag) - ? error.message - : `${tag}: ${error.message}`, - ]; + return [{ tag, message: error.message }]; } - return [typeof error === 'string' ? error : String(error)]; + return [{ tag: 'Unknown', message: typeof error === 'string' ? error : String(error) }]; }); - return lines.length > 0 ? [...new Set(lines)].join('\n') : Cause.pretty(cause); + const unique = new Map(reasons.map((reason) => [`${reason.tag}\0${reason.message}`, reason])); + return unique.size > 0 + ? [...unique.values()] + : [{ tag: 'Unknown', message: Cause.pretty(cause) }]; } +let running = false; + /** * Sets the process state Alchemy reads for the length of `run`, and restores - * it after: the variables in `env`, the working directory, and `console`'s - * stdout methods, which go to stderr so a provider's `console.log` cannot - * corrupt structured output. + * it on every exit path: the variables in `env` and the working directory. */ async function withProcessState( - env: Readonly>, + env: Readonly>, cwd: string, run: () => Promise, ): Promise { const previousEnv = Object.keys(env).map((name) => [name, process.env[name]] as const); const previousCwd = process.cwd(); - const { log, info, debug } = console; - Object.assign(process.env, env); - if (previousCwd !== cwd) process.chdir(cwd); - console.log = console.error; - console.info = console.error; - console.debug = console.error; try { + for (const [name, value] of Object.entries(env)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + if (previousCwd !== cwd) process.chdir(cwd); return await run(); } finally { - Object.assign(console, { log, info, debug }); if (process.cwd() !== previousCwd) process.chdir(previousCwd); for (const [name, value] of previousEnv) { if (value === undefined) delete process.env[name]; @@ -172,9 +181,15 @@ async function withProcessState( /** * Plans and applies `stack` in this process, as `alchemy deploy --yes` would. * The plan's scope, and the state-store lease it holds, closes before this - * returns, including when `signal` interrupts it. + * returns, including when `signal` interrupts it. One converge runs at a time + * per process: a second call while one runs is refused. */ -export const convergeInProcess: Converge = (request) => { +export const convergeInProcess: Converge = async (request) => { + if (running) return { kind: 'refused', reason: 'already-running' }; + if (!isMainThread && process.cwd() !== request.cwd) { + return { kind: 'refused', reason: 'worker-thread' }; + } + running = true; const emit = (event: ConvergeEvent): void => { try { request.onEvent?.(event); @@ -185,7 +200,7 @@ export const convergeInProcess: Converge = (request) => { const program = Effect.gen(function* () { const plan = yield* Alchemist.plan({ stack: request.stack, - operation: 'deploy', + operation: request.operation, stage: request.stage, updateStateStore: true, }); @@ -199,16 +214,31 @@ export const convergeInProcess: Converge = (request) => { ), Effect.scoped, Effect.provide(Logger.layer([logEvents(emit)])), + Effect.provide(Interaction.layerNonInteractive({ stdout: process.stderr })), Effect.provide(Alchemist.layer()), ); - return withProcessState(request.env, request.cwd, async () => { - const exit = await Effect.runPromiseExit( - program, - request.signal === undefined ? undefined : { signal: request.signal }, - ); - if (Exit.isSuccess(exit)) return { kind: 'applied' }; - if (Cause.hasInterruptsOnly(exit.cause)) return { kind: 'interrupted' }; - return { kind: 'failed', cause: failureText(exit.cause) }; - }); + try { + return await withProcessState(request.env, request.cwd, async () => { + const exit = await Effect.runPromiseExit( + program, + request.signal === undefined ? undefined : { signal: request.signal }, + ); + if (Exit.isSuccess(exit)) return { kind: 'applied' } as const; + if (Cause.hasInterruptsOnly(exit.cause)) return { kind: 'interrupted' } as const; + return { kind: 'failed', reasons: failureReasons(exit.cause) } as const; + }); + } catch (error) { + return { + kind: 'failed', + reasons: [ + { + tag: error instanceof Error ? error.name : 'Unknown', + message: error instanceof Error ? error.message : String(error), + }, + ], + }; + } finally { + running = false; + } }; diff --git a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts index d61dd8c0f..37ad169cf 100644 --- a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts +++ b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts @@ -263,20 +263,19 @@ export function engineFailureCause( : `${cause.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; } -/** - * The cause an in-process converge failed with, fit for a failure message - * that may leave the machine: Composer's advice in place of Alchemy CLI - * advice, credentials and pool secrets removed, capped. - */ -export function convergeFailureCause( - cause: string, +/** Removes from text every credential `redactSecrets` finds in `env`, and every pool secret. For text an in-process converge produces, which may leave the machine. */ +export function convergeRedactor( env: Readonly>, secrets: readonly string[], -): string { - const cleaned = redactValues(redactSecrets(withComposerAdvice(cause), env), secrets); - return cleaned.length <= ENGINE_FAILURE_CAUSE_LIMIT - ? cleaned - : `${cleaned.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; +): (text: string) => string { + return (text) => redactValues(redactSecrets(text, env), secrets); +} + +/** A failure cause capped to fit the platform build's error message. */ +export function capFailureCause(cause: string): string { + return cause.length <= ENGINE_FAILURE_CAUSE_LIMIT + ? cause + : `${cause.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; } /** Replaces each value of 8 characters or more, longest first, so a one-letter placeholder does not blank every matching letter. */ diff --git a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts index f9770d190..9a40f2c34 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/__tests__/generate-dev-stack.test.ts @@ -19,8 +19,10 @@ describe('renderDevStackFile()', () => { }, }); - expect(content).toContain("import { lower } from '@prisma/composer/deploy';"); - expect(content).toContain("import { deserializeContainers } from '@prisma/composer/config';"); + expect(content).toContain( + "import { lower, transportedLowerInputs } from '@prisma/composer/deploy';", + ); + expect(content).not.toContain('deserializeContainers'); expect(content).toContain( "import { readConfigPool, readSecretsPool } from '@prisma/composer/internal/pools';", ); @@ -37,15 +39,13 @@ describe('renderDevStackFile()', () => { // deserialized, `localTarget` thunks resolved by top-level await, // providers + state passed explicitly — and no `dev` flag, which no // longer exists on LowerOptions. - expect(content).toContain( - 'const containers = deserializeContainers(config.extensions, process.env);', - ); + expect(content).toContain('const transported = transportedLowerInputs(config);'); expect(content).toContain('const resolved = await resolveLocalTargets(config);'); expect(content).toContain('const devDir = path.join(process.cwd(), devEnvironmentDir("dev"));'); expect(content).toContain('lower(app, config, {'); expect(content).toContain('name: "app"'); expect(content).toContain( - 'providers: localTargetProviders(resolved, containers, devDir, pools)', + 'providers: localTargetProviders(resolved, transported.containers, devDir, pools)', ); expect(content).toContain('state: devState()'); expect(content).not.toContain('dev: true'); @@ -108,7 +108,9 @@ describe('renderDevStackFile() — the environment', () => { 'const pools = { config: await readConfigPool(config, poolInput), secrets: await readSecretsPool(config, poolInput) };', ); expect(content).toContain(' environment: "test",\n configPool: pools.config,\n'); - expect(content).toContain('localTargetProviders(resolved, containers, devDir, pools)'); + expect(content).toContain( + 'localTargetProviders(resolved, transported.containers, devDir, pools)', + ); }); test('no secret value in the process environment reaches either rendered stack file', () => { diff --git a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts index f6a0dfc11..c78971fbe 100644 --- a/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/dev/generate-dev-stack.ts @@ -70,7 +70,8 @@ function renderOptions(input: DevStackFileInput): string { lines.push(` unassembled: [${unassembled.map(quote).join(', ')}],`); } - lines.push(' providers: localTargetProviders(resolved, containers, devDir, pools),'); + lines.push(' ...transported,'); + lines.push(' providers: localTargetProviders(resolved, transported.containers, devDir, pools),'); lines.push(' state: devState(),'); return lines.join('\n'); @@ -86,9 +87,8 @@ export function renderDevStackFile(input: DevStackFileInput): string { // module's own imports, and this renderer ships in the testing entry. const imports = [ "import * as path from 'node:path';", - "import { deserializeContainers } from '@prisma/composer/config';", "import { readConfigPool, readSecretsPool } from '@prisma/composer/internal/pools';", - "import { lower } from '@prisma/composer/deploy';", + "import { lower, transportedLowerInputs } from '@prisma/composer/deploy';", "import { devEnvironmentDir, devState, localTargetProviders, resolveLocalTargets } from '@prisma/composer/local-target';", `import prismaConfig from ${quote(configImport)};`, `import app from ${quote(appImport)};`, @@ -109,7 +109,7 @@ if (config === undefined) { throw new Error(${quote(noComposerExportMessage(input.configFile))}); } -const containers = deserializeContainers(config.extensions, process.env); +const transported = transportedLowerInputs(config); const resolved = await resolveLocalTargets(config); const devDir = path.join(process.cwd(), devEnvironmentDir(${quote(input.environment)})); const poolInput = { environment: ${quote(input.environment)}, configFile: ${quote(input.configFile)} }; diff --git a/packages/0-framework/3-tooling/cli/src/exports/converge.ts b/packages/0-framework/3-tooling/cli/src/exports/converge.ts new file mode 100644 index 000000000..a5f81a056 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/converge.ts @@ -0,0 +1,11 @@ +/** The in-process converge the CLI loads from the app's install. Not public API: published as `@prisma/composer/internal/converge`, it may change in any release. */ +export { lower } from '@internal/core/deploy'; +export type { + Converge, + ConvergeEvent, + ConvergeFailureReason, + ConvergeOutcome, + ConvergeRequest, + LoweredStack, +} from '../converge-in-process.ts'; +export { convergeInProcess } from '../converge-in-process.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index 41fffbd16..4e58a8512 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -3,7 +3,7 @@ * argument validation, the real credential check, the real settlement rules — * with the published operations double standing in for alchemy. */ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -21,11 +21,11 @@ type Fixtures = Parameters[0]; const CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-family-'))); /** A credential that is signed in and nowhere near expiry. */ -function activeCredential() { +function activeCredential(workspaceId = 'ws_1') { return { token: mintTestJwt({ sub: 'user_1', - workspace_id: 'ws_1', + workspace_id: workspaceId, exp: Math.floor(Date.now() / 1000) + 3600, }), refreshToken: undefined, @@ -43,7 +43,7 @@ function composerCli( spec: { readonly fixtures?: Fixtures; /** How the run is signed in: a service token in the environment (the default), a `prisma login` session, or not at all. */ - readonly signedIn?: 'environment' | 'session' | false; + readonly signedIn?: 'environment' | 'session' | 'both' | false; } = {}, ): Harness { const double = createOperationsDouble(spec.fixtures ?? {}); @@ -59,7 +59,12 @@ function composerCli( ? {} : spec.signedIn === 'session' ? { credential: activeCredential() } - : { environmentCredential: activeCredential() }), + : spec.signedIn === 'both' + ? { + credential: activeCredential('ws_session'), + environmentCredential: activeCredential('ws_env'), + } + : { environmentCredential: activeCredential() }), }); return { cli, double }; } @@ -205,36 +210,76 @@ describe('credentials', () => { expect(double.calls.deploy).toEqual([]); }); - test('a `prisma login` session without PRISMA_SERVICE_TOKEN is refused before the operation runs', async () => { + test('a `prisma login` session reaches the operation with no service token, so the operation refuses it', async () => { const { cli, double } = composerCli({ signedIn: 'session' }); - const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); - expect(result.exitCode).toBe(2); - expect(plain(result.stderr)).toContain('DEPLOY.SERVICE_TOKEN_REQUIRED'); - expect(plain(result.stderr)).toContain('set PRISMA_SERVICE_TOKEN'); - expect(double.calls.deploy).toEqual([]); + const credentials = double.calls.deps.deploy[0]?.credentials; + expect(credentials?.workspaceId).toBe('ws_1'); + expect(credentials?.serviceToken).toBeUndefined(); }); /** * Alchemy runs in this process and reads its credential from - * `process.env`, so the handler hands the operation the token and the - * credential's workspace to set there for the apply. + * `process.env`, so the handler hands the operation the environment token + * and that token's workspace, for the operation to set there for the apply. */ - test('the service token and the workspace reach the converge environment, and no child is spawned', async () => { + test('an environment token reaches the operation with its own workspace, and no child is spawned', async () => { const { cli, double } = composerCli(); const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); expect(result.exitCode).toBe(0); expect(result.spawns).toEqual([]); - const deps = double.calls.deps.deploy[0]; - expect(Object.keys(deps?.convergeEnv ?? {}).sort()).toEqual([ - 'PRISMA_SERVICE_TOKEN', - 'PRISMA_WORKSPACE_ID', - ]); - expect(deps?.convergeEnv?.['PRISMA_WORKSPACE_ID']).toBe('ws_1'); - expect(deps?.credentials?.workspaceId).toBe('ws_1'); + const credentials = double.calls.deps.deploy[0]?.credentials; + expect(credentials?.serviceToken).toEqual(expect.any(String)); + expect(credentials?.workspaceId).toBe('ws_1'); expect(double.calls.deploy[0]?.signal).toBeInstanceOf(AbortSignal); }); + + test('with both a session and an environment token, the token and the workspace come from one credential', async () => { + const { cli, double } = composerCli({ signedIn: 'both' }); + await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + + const credentials = double.calls.deps.deploy[0]?.credentials; + expect( + credentials?.serviceToken === undefined + ? credentials?.workspaceId + : `${credentials.workspaceId} with a token`, + ).toBeOneOf(['ws_session', 'ws_env with a token']); + }); + + test("a provider's console.log during the operation goes to stderr, and console is restored after", async () => { + const log = console.log; + const stdoutWrite = spyOn(process.stdout, 'write'); + const consoleError = spyOn(console, 'error').mockImplementation(() => {}); + const operations = createOperationsDouble({}); + const deploy = operations.operations.deploy; + try { + const family = createComposerFamily({ + operations: { + ...operations.operations, + deploy: async (input, deps) => { + console.log('provider chatter'); + console.table([{ a: 1 }]); + return deploy(input, deps); + }, + }, + }); + const probe = createTestCli({ + commands: { deploy: commandAt(family, 'deploy') }, + config: { composer: validComposerSection() }, + environmentCredential: activeCredential(), + }); + await probe.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + + expect(stdoutWrite).not.toHaveBeenCalled(); + expect(consoleError.mock.calls.map((call) => call[0])).toContain('provider chatter'); + } finally { + stdoutWrite.mockRestore(); + consoleError.mockRestore(); + } + expect(console.log).toBe(log); + }); }); describe('settlement', () => { diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/converge-spawn.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-converge-child.test.ts similarity index 98% rename from packages/0-framework/3-tooling/cli/src/family/__tests__/converge-spawn.test.ts rename to packages/0-framework/3-tooling/cli/src/family/__tests__/dev-converge-child.test.ts index dd6ca2552..5711a1958 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/converge-spawn.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/dev-converge-child.test.ts @@ -13,7 +13,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { ChildResult } from '@prisma/cli-engine'; import { alchemyInvocation } from '../../run-alchemy.ts'; -import { type ConvergeContext, convergeSpawn } from '../converge.ts'; +import { type ConvergeContext, convergeSpawn } from '../dev-converge-child.ts'; const tmpDirs: string[] = []; diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts index 6154d2dcb..fafe9faf3 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts @@ -6,7 +6,6 @@ * valid with destroy"), so no invocation using it could ever have succeeded; * deploy targets the environment production when no `--environment` is given. */ -import { CliStructuredError } from '@internal/foundation/errors'; import type { EngineEvent } from '@prisma/cli-engine'; import { defineCommand, flag, SERVICE_TOKEN_ENV_VAR } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; @@ -15,11 +14,11 @@ import { type DeployEvent, type DeployStep, } from '../../operations/deploy.ts'; +import { withConsoleOnStderr } from '../console-to-stderr.ts'; import { dataLine } from '../data-lines.ts'; import type { ComposerOperations } from '../family.ts'; import { composerSection } from '../section.ts'; import { toEngineError } from '../translate-error.ts'; -import { workspaceIdOf } from '../workspace.ts'; const STEP_LABELS = { prepare: 'load config and app', @@ -150,30 +149,34 @@ export const createDeployCommand = (operations: ComposerOperations) => }, needs: { config: composerSection, credentials: true }, handler: async (args, ctx) => { - const token = ctx.env[SERVICE_TOKEN_ENV_VAR]; - if (token === undefined || token.length === 0) { - return notOk(toEngineError(serviceTokenRequired())); - } - const workspaceId = await workspaceIdOf(ctx); - const result = await operations.deploy( - { - entry: args.flags.entry, - config: ctx.config, - name: args.flags.name, - environment: args.flags.environment, - cwd: ctx.cwd, - reportPath: args.flags.report, - reportId: args.flags.buildId, - onEvent: (event) => ctx.report(toEngineEvent(event)), - signal: ctx.signal, - }, - { - credentials: { workspaceId, client: ctx.api }, - convergeEnv: { - [SERVICE_TOKEN_ENV_VAR]: token, - ...(workspaceId === undefined ? {} : { PRISMA_WORKSPACE_ID: workspaceId }), + // The engine resolved which credential this run acts as. Only a service + // token from the environment can reach Alchemy, and its token and + // workspace travel together; a stored session carries no token, so the + // operation refuses it with DEPLOY.SERVICE_TOKEN_REQUIRED. + const credential = await ctx.activeCredential(); + const serviceToken = + credential?.origin.source === 'environment' ? ctx.env[SERVICE_TOKEN_ENV_VAR] : undefined; + const result = await withConsoleOnStderr(() => + operations.deploy( + { + entry: args.flags.entry, + config: ctx.config, + name: args.flags.name, + environment: args.flags.environment, + cwd: ctx.cwd, + reportPath: args.flags.report, + reportId: args.flags.buildId, + onEvent: (event) => ctx.report(toEngineEvent(event)), + signal: ctx.signal, }, - }, + { + credentials: { + workspaceId: credential?.workspaceId, + client: ctx.api, + serviceToken, + }, + }, + ), ); if (!result.ok) return notOk(toEngineError(result.failure)); @@ -221,19 +224,3 @@ export const createDeployCommand = (operations: ComposerOperations) => ); }, }); - -/** - * Alchemy runs in this process and reads its Prisma credential only from the - * environment, and the engine gives a command no way to read a `prisma login` - * session's token. So deploy needs the service token in the environment. - */ -function serviceTokenRequired(): CliStructuredError { - return new CliStructuredError( - 'DEPLOY.SERVICE_TOKEN_REQUIRED', - `\`prisma deploy\` needs ${SERVICE_TOKEN_ENV_VAR} in the environment.`, - { - why: 'Alchemy applies the deploy in this process and reads its Prisma credential from the environment. A `prisma login` session cannot reach it yet.', - fix: `Create a service token for the workspace in the Prisma Console and set ${SERVICE_TOKEN_ENV_VAR} to it, then deploy again.`, - }, - ); -} diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts index 5d9e80327..17ab7159c 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/dev.ts @@ -20,7 +20,7 @@ import { ok } from '@prisma/cli-engine/protocol'; import type { DevEvent, DevInput } from '../../operations/dev.ts'; import { DEV_ENVIRONMENT, type ServiceEndpoint } from '../../operations/shared.ts'; import type { AlchemyInvocation, AlchemyOutcome, RunAlchemy } from '../../run-alchemy.ts'; -import { convergeSpawn, settleConvergeFailure } from '../converge.ts'; +import { convergeSpawn, settleConvergeFailure } from '../dev-converge-child.ts'; import type { ComposerOperations } from '../family.ts'; import { composerSection } from '../section.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/family/console-to-stderr.ts b/packages/0-framework/3-tooling/cli/src/family/console-to-stderr.ts new file mode 100644 index 000000000..9fa7e2b36 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/family/console-to-stderr.ts @@ -0,0 +1,22 @@ +/** + * Runs `run` with `console`'s stdout methods writing to stderr, and restores + * them after. `prisma deploy` wraps its operation in this: stdout carries the + * command's data lines and JSON frames, and a provider's `console.log` + * during the apply must not land among them. A host calling the operation + * directly keeps its own `console`. + */ +const STDOUT_METHODS = ['log', 'info', 'debug', 'dir', 'table'] as const; + +export async function withConsoleOnStderr(run: () => Promise): Promise { + const saved = STDOUT_METHODS.map((method) => [method, console[method]] as const); + try { + for (const method of STDOUT_METHODS) { + console[method] = (...args: unknown[]) => console.error(...args); + } + return await run(); + } finally { + for (const [method, original] of saved) { + Object.assign(console, { [method]: original }); + } + } +} diff --git a/packages/0-framework/3-tooling/cli/src/family/converge.ts b/packages/0-framework/3-tooling/cli/src/family/dev-converge-child.ts similarity index 100% rename from packages/0-framework/3-tooling/cli/src/family/converge.ts rename to packages/0-framework/3-tooling/cli/src/family/dev-converge-child.ts diff --git a/packages/0-framework/3-tooling/cli/src/generate-stack.ts b/packages/0-framework/3-tooling/cli/src/generate-stack.ts index 5ffafb284..e13af4074 100644 --- a/packages/0-framework/3-tooling/cli/src/generate-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/generate-stack.ts @@ -39,6 +39,7 @@ function renderOptions(input: StackFileInput): string { lines.push(` name: ${quote(input.name)},`); lines.push(` environment: ${quote(input.environment)},`); lines.push(' configPool,'); + lines.push(' ...transportedLowerInputs(config),'); lines.push(' bundles: {'); for (const [id, bundle] of Object.entries(input.assembled.bundles)) { @@ -73,7 +74,7 @@ export function renderStackFile(input: StackFileInput): string { // // bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). // It reads the config pool again here, from the same adapters, so an adapter must return the same values for the same environment every time. -import { lower } from '@prisma/composer/deploy'; +import { lower, transportedLowerInputs } from '@prisma/composer/deploy'; import * as report from '@prisma/composer/report'; import { readConfigPool } from '@prisma/composer/internal/pools'; import prismaConfig from ${quote(configImport)}; diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index fa740e15a..15b52f081 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -5,7 +5,7 @@ * test if the operation itself writes to the console: rendering belongs to * the host. */ -import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -20,6 +20,7 @@ import type { PrismaAppConfig, RunOutcome, } from '@internal/core/config'; +import { lower } from '@internal/core/deploy'; import type { LocalTargetAttachment, LocalTargetCurrentApp, @@ -27,6 +28,7 @@ import type { } from '@internal/core/local-target'; import { CliStructuredError } from '@internal/foundation/errors'; import * as Layer from 'effect/Layer'; +import type { DeployEngine } from '../../app-engine.ts'; import type { ComposerConfigSource } from '../../composer-config.ts'; import { dotenv } from '../../config-adapters.ts'; import type { Converge, ConvergeOutcome, ConvergeRequest } from '../../converge-in-process.ts'; @@ -200,6 +202,21 @@ const fakeAssembler = async (node: ServiceNode) => ({ const APPLIED: ConvergeOutcome = { kind: 'applied' }; +/** Core's own `lower` with a stand-in for Alchemy's plan and apply: deploy tests never run the real engine here. */ +function engineWith(converge: Converge): DeployEngine { + return { lower, converge }; +} + +// Deploy needs a service token; a caller passing no credentials leaves it to the environment. +const previousServiceToken = process.env['PRISMA_SERVICE_TOKEN']; +beforeAll(() => { + process.env['PRISMA_SERVICE_TOKEN'] = 'test-service-token'; +}); +afterAll(() => { + if (previousServiceToken === undefined) delete process.env['PRISMA_SERVICE_TOKEN']; + else process.env['PRISMA_SERVICE_TOKEN'] = previousServiceToken; +}); + /** Stands in for Alchemy's plan and apply: deploy tests never run the real engine here. */ const applied: Converge = async () => APPLIED; @@ -229,12 +246,16 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: recordingConverge(requests), + engine: engineWith(recordingConverge(requests)), alchemy: async () => { spawned = true; return { exitCode: 0, signal: null }; }, - convergeEnv: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler' }, + credentials: { + workspaceId: 'ws_handler', + client: {}, + serviceToken: 'token-from-the-handler', + }, }, ), ); @@ -244,7 +265,7 @@ describe('deploy()', () => { expect(requests[0]).toMatchObject({ stage: 'ci-7', cwd: app.dir, - env: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler' }, + env: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler', PRISMA_WORKSPACE_ID: 'ws_handler' }, }); expect(requests[0]?.stack.stackName).toBe('hello-ops'); expect(spawned).toBe(false); @@ -272,10 +293,10 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: async () => { + engine: engineWith(async () => { ran = true; return APPLIED; - }, + }), }, ), ); @@ -301,7 +322,7 @@ describe('deploy()', () => { ...(input.entry === undefined ? {} : { entry: input.entry }), cwd: app.dir, }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ), ); @@ -333,10 +354,10 @@ describe('deploy()', () => { { config: composerConfig(fakeConfig()), cwd: app.dir }, { runAssembler: fakeAssembler, - converge: async () => { + engine: engineWith(async () => { ran = true; return APPLIED; - }, + }), }, ), ); @@ -363,7 +384,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: recordingConverge(requests), + engine: engineWith(recordingConverge(requests)), }, ), ); @@ -390,7 +411,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -421,10 +442,10 @@ describe('deploy()', () => { assembled = true; return fakeAssembler(node); }, - converge: async () => { + engine: engineWith(async () => { convergeRan = true; return APPLIED; - }, + }), }, ), ); @@ -448,10 +469,10 @@ describe('deploy()', () => { { config, entry: app.entryPath, cwd: app.dir }, { runAssembler: fakeAssembler, - converge: async () => { + engine: engineWith(async () => { convergeRan = true; return APPLIED; - }, + }), }, ), ); @@ -567,10 +588,10 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: async () => { + engine: engineWith(async () => { convergeRan = true; return APPLIED; - }, + }), }, ), ); @@ -595,11 +616,16 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: async () => ({ + engine: engineWith(async () => ({ kind: 'failed', - cause: - 'PrismaApiError: the bucket is held by another app. Re-run with `--adopt` (or wrap the effect in `adopt(true)`) to take it over.', - }), + reasons: [ + { + tag: 'PrismaApiError', + message: + 'the bucket is held by another app. Re-run with `--adopt` (or wrap the effect in `adopt(true)`) to take it over.', + }, + ], + })), }, ), ); @@ -640,10 +666,15 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: async () => ({ + engine: engineWith(async () => ({ kind: 'failed', - cause: `error: rejected ${onlyInDotenvFile}, ${inBothPools} and ${secretNamedConfigValue}`, - }), + reasons: [ + { + tag: 'Error', + message: `error: rejected ${onlyInDotenvFile}, ${inBothPools} and ${secretNamedConfigValue}`, + }, + ], + })), }, ), ); @@ -674,7 +705,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - converge: recordingConverge(requests, { kind: 'interrupted' }), + engine: engineWith(recordingConverge(requests, { kind: 'interrupted' })), }, ), ); @@ -765,6 +796,148 @@ function stepLine(event: DeployEvent): string { return event.kind === 'step-started' ? `started ${name}` : `finished ${name} ${event.outcome}`; } +describe('deploy() credentials, engine and redaction', () => { + const deployWith = ( + app: { dir: string; entryPath: string }, + deps: Parameters[1], + config: PrismaAppConfig = fakeConfig(), + onEvent?: (event: DeployEvent) => void, + ) => + silently(() => + deployWithDeps( + { + config: composerConfig(config), + entry: app.entryPath, + environment: 'ci-7', + cwd: app.dir, + ...(onEvent === undefined ? {} : { onEvent }), + }, + { runAssembler: fakeAssembler, ...deps }, + ), + ); + + test('a credential that is not a service token is refused before any container is ensured', async () => { + const app = makeAppDir(); + const containerCalls: ContainerCall[] = []; + const requests: ConvergeRequest[] = []; + + const result = await deployWith( + app, + { + engine: engineWith(recordingConverge(requests)), + credentials: { workspaceId: 'ws_1', client: {} }, + }, + fakeConfig({}, { calls: containerCalls }), + ); + + if (result.ok) throw new Error('expected a failure'); + expect(result.failure.code).toBe('DEPLOY.SERVICE_TOKEN_REQUIRED'); + expect(containerCalls).toEqual([]); + expect(requests).toEqual([]); + }); + + test('without credentials or PRISMA_SERVICE_TOKEN in the environment, deploy is refused', async () => { + const app = makeAppDir(); + const saved = process.env['PRISMA_SERVICE_TOKEN']; + delete process.env['PRISMA_SERVICE_TOKEN']; + try { + const result = await deployWith(app, { engine: engineWith(applied) }); + if (result.ok) throw new Error('expected a failure'); + expect(result.failure.code).toBe('DEPLOY.SERVICE_TOKEN_REQUIRED'); + } finally { + process.env['PRISMA_SERVICE_TOKEN'] = saved; + } + }); + + test('a credential that names no workspace removes PRISMA_WORKSPACE_ID for the apply', async () => { + const app = makeAppDir(); + const requests: ConvergeRequest[] = []; + + await deployWith(app, { + engine: engineWith(recordingConverge(requests)), + credentials: { workspaceId: undefined, client: {}, serviceToken: 'tok-1234567890' }, + }); + + expect(requests[0]?.env).toEqual({ + PRISMA_SERVICE_TOKEN: 'tok-1234567890', + PRISMA_WORKSPACE_ID: undefined, + }); + }); + + test('an app with no installed @prisma/composer fails with DEPS.ENGINE_UNRESOLVABLE before any container is ensured', async () => { + const app = makeAppDir(); + const containerCalls: ContainerCall[] = []; + + const result = await deployWith(app, {}, fakeConfig({}, { calls: containerCalls })); + + if (result.ok) throw new Error('expected a failure'); + expect(result.failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + expect(containerCalls).toEqual([]); + }); + + test('the token never reaches a message event, the engine failure, or a stack-building error', async () => { + const app = makeAppDir(); + const token = 'tok-SENTINEL-abcdef123456'; + const events: DeployEvent[] = []; + const credentials = { workspaceId: 'ws_1', client: {}, serviceToken: token }; + + const failed = await deployWith( + app, + { + credentials, + engine: engineWith(async (request) => { + request.onEvent?.({ kind: 'log', severity: 'warn', text: `retrying with ${token}` }); + request.onEvent?.({ + kind: 'resource', + fqn: 'app', + resourceType: 'Prisma.Deployment', + status: 'fail', + phase: 'failed', + message: `401 for Bearer ${token}`, + }); + return { kind: 'failed', reasons: [{ tag: 'PrismaApiError', message: `bad ${token}` }] }; + }), + }, + fakeConfig(), + (event) => events.push(event), + ); + const stateBroken = fakeConfig(); + const stackError = await deployWith( + app, + { + credentials, + engine: { + lower: () => { + throw new Error(`state store refused ${token}`); + }, + converge: applied, + }, + }, + stateBroken, + ); + + expect(JSON.stringify(events)).not.toContain('SENTINEL'); + expect(events.filter((event) => event.kind === 'message')).toHaveLength(2); + if (failed.ok || stackError.ok) throw new Error('expected failures'); + expect(JSON.stringify(failed.failure)).not.toContain('SENTINEL'); + expect(stackError.failure.code).toBe('DEPLOY.ENGINE_FAILED'); + expect(stackError.failure.message).toBe('state store refused [redacted]'); + }); + + test('a refused converge is DEPLOY.CONVERGE_RUNNING or DEPLOY.WORKER_THREAD', async () => { + const app = makeAppDir(); + const refused = (reason: 'already-running' | 'worker-thread') => + deployWith(app, { engine: engineWith(async () => ({ kind: 'refused', reason })) }); + + const running = await refused('already-running'); + const worker = await refused('worker-thread'); + + if (running.ok || worker.ok) throw new Error('expected failures'); + expect(running.failure.code).toBe('DEPLOY.CONVERGE_RUNNING'); + expect(worker.failure.code).toBe('DEPLOY.WORKER_THREAD'); + }); +}); + describe('deploy() step events', () => { test('a successful deploy reports every step in order, each with a duration and its data', async () => { const app = makeAppDir('hello-steps'); @@ -792,7 +965,7 @@ describe('deploy() step events', () => { report?.({ bytes: 4096 }); return fakeAssembler(node); }, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -850,13 +1023,13 @@ describe('deploy() step events', () => { }, { runAssembler: fakeAssembler, - converge: async (request) => { + engine: engineWith(async (request) => { request.onEvent?.(resource('pending', 'pending')); request.onEvent?.(resource('creating', 'running')); request.onEvent?.({ kind: 'log', severity: 'info', text: 'uploading 3 files' }); request.onEvent?.(resource('created', 'done')); return APPLIED; - }, + }), }, ), ); @@ -891,7 +1064,7 @@ describe('deploy() step events', () => { }, { runAssembler: fakeAssembler, - converge: async (request) => { + engine: engineWith(async (request) => { request.onEvent?.({ kind: 'resource', fqn: 'site', @@ -900,8 +1073,11 @@ describe('deploy() step events', () => { phase: 'failed', message: 'domain-ownership-required', }); - return { kind: 'failed', cause: 'PrismaApiError: domain-ownership-required' }; - }, + return { + kind: 'failed', + reasons: [{ tag: 'PrismaApiError', message: 'domain-ownership-required' }], + }; + }), }, ), ); @@ -947,7 +1123,7 @@ describe('deploy() step events', () => { cwd: app.dir, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ).catch((error: unknown) => error), ); @@ -980,6 +1156,7 @@ describe('deploy() step events', () => { onEvent: (event) => events.push(event), }, { + engine: engineWith(applied), runAssembler: async () => { throw new Error('no built entry'); }, @@ -1016,7 +1193,7 @@ describe('deploy() step events', () => { reportPath: path.join(app.dir, 'package.json', 'report.json'), onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ); } finally { warnSpy.mockRestore(); @@ -1040,7 +1217,7 @@ describe('deploy() step events', () => { throw new Error('renderer bug'); }, }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ), ); @@ -1247,10 +1424,10 @@ describe('destroy()', () => { ran.push('alchemy'); return { exitCode: 0, signal: null }; }, - converge: async () => { + engine: engineWith(async () => { ran.push('alchemy'); return APPLIED; - }, + }), }; const result = action === 'deploy' @@ -2784,7 +2961,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: recordingConverge(requests), + engine: engineWith(recordingConverge(requests)), }, ), ); @@ -2813,8 +2990,8 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: applied, - credentials: { workspaceId: 'ws-1', client: {} }, + engine: engineWith(applied), + credentials: { workspaceId: 'ws-1', client: {}, serviceToken: 'tok' }, }, ), ); @@ -2852,7 +3029,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -2878,7 +3055,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: async () => ({ kind: 'interrupted' }), + engine: engineWith(async () => ({ kind: 'interrupted' })), }, ), ); @@ -2907,7 +3084,10 @@ describe('deploy-run reporting', () => { { entry: app.entryPath, environment: 'ci-6', cwd: app.dir, config: composerConfig(config) }, { runAssembler: fakeAssembler, - converge: async () => ({ kind: 'failed', cause: 'PrismaApiError: 409 Conflict' }), + engine: engineWith(async () => ({ + kind: 'failed', + reasons: [{ tag: 'PrismaApiError', message: '409 Conflict' }], + })), }, ), ); @@ -2934,7 +3114,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -2981,7 +3161,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -3009,7 +3189,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -3358,7 +3538,7 @@ describe('the operations read the pools from the config adapters', () => { }, { runAssembler: fakeAssembler, - converge: applied, + engine: engineWith(applied), }, ), ); @@ -3388,7 +3568,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ), ); @@ -3454,7 +3634,7 @@ describe('the operations read the pools from the config adapters', () => { reportPath, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ), ); @@ -3481,7 +3661,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, converge: applied }, + { runAssembler: fakeAssembler, engine: engineWith(applied) }, ), ); diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index 20a46f9fc..e28f0b194 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -16,17 +16,19 @@ import type { RunReporter, } from '@internal/core/config'; import { containerEnv, preflightEnvVarName } from '@internal/core/config'; -import { lower } from '@internal/core/deploy'; import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; +import { type DeployEngine, loadAppEngine } from '../app-engine.ts'; import type { ComposerConfigSource } from '../composer-config.ts'; -import { - type ConvergeEvent, - convergeInProcess, - type LoweredStack, +import type { + ConvergeEvent, + ConvergeFailureReason, + ConvergeOutcome, + LoweredStack, } from '../converge-in-process.ts'; import { - convergeFailureCause, + capFailureCause, + convergeRedactor, DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary, engineFailureFilePath, @@ -390,6 +392,17 @@ async function runStackPipelineInner( throw error; } + let converge: ConvergeSetup | undefined; + if (action === 'deploy') { + const setup = await prepareConverge(deps, cwd); + if (!setup.ok) return setup; + converge = setup.value; + } + const platformCredentials = + deps.credentials === undefined + ? undefined + : { workspaceId: deps.credentials.workspaceId, client: deps.credentials.client }; + // Destroy-only guardrail — first, ahead of every other step, so it // surfaces even when the rest of the pipeline goes on to fail for an // unrelated reason (missing config, missing built output — both common @@ -463,7 +476,7 @@ async function runStackPipelineInner( environment, cwd, reportId: opts.reportId, - credentials: deps.credentials, + credentials: platformCredentials, })), ); } @@ -479,7 +492,7 @@ async function runStackPipelineInner( if (action === 'deploy') { const instance = await extension.container.ensure( { appName: resolvedName, environment }, - deps.credentials, + platformCredentials, ); requireContainerForEnvironment( action, @@ -492,7 +505,7 @@ async function runStackPipelineInner( } else { const instance = await extension.container.locate( { appName: resolvedName, environment }, - deps.credentials, + platformCredentials, ); if (instance === undefined) { throw new CliStructuredError( @@ -547,7 +560,7 @@ async function runStackPipelineInner( container: containers.get(extension.id), environment, pools: pipeline.pools, - credentials: deps.credentials, + credentials: platformCredentials, report, }); if (payload !== undefined) preflightPayloads.set(extension.id, payload); @@ -569,8 +582,8 @@ async function runStackPipelineInner( throw error; } - if (action === 'deploy') { - return convergeDeploy(opts, pipeline, { + if (converge !== undefined) { + return convergeDeploy(opts, pipeline, converge, { containers, stage: alchemyStage, preflight: preflightPayloads, @@ -739,7 +752,7 @@ async function runStackPipelineInner( const instance = containers.get(extension.id); if (instance === undefined) continue; try { - await extension.container.remove(instance, deps.credentials); + await extension.container.remove(instance, platformCredentials); } catch (error) { throw toStructured('DEPLOY.CONTAINER_REMOVE_FAILED', error); } @@ -761,10 +774,67 @@ async function runStackPipelineInner( } } +interface ConvergeSetup { + readonly engine: DeployEngine; + /** The credential variables Alchemy reads, set for the length of the apply; `undefined` removes one. */ + readonly credentialEnv: Readonly>; +} + +/** + * Alchemy runs in this process and reads its Prisma credential only from the + * environment, and the engine gives a command no way to read a `prisma login` + * session's token. So deploy needs a service token. + */ +function serviceTokenRequired(): CliStructuredError { + return new CliStructuredError( + 'DEPLOY.SERVICE_TOKEN_REQUIRED', + 'Deploy needs PRISMA_SERVICE_TOKEN in the environment.', + { + why: 'Alchemy applies the deploy in this process and reads its Prisma credential from the environment. A `prisma login` session cannot reach it yet.', + fix: 'Create a service token for the workspace in the Prisma Console and set PRISMA_SERVICE_TOKEN to it, then deploy again.', + }, + ); +} + +/** + * What deploy's converge needs before anything is created: the service token, + * and the app's own install of the engine. A caller that supplies credentials + * supplies the token with them, and the token and workspace always come from + * that one credential; a caller that supplies none leaves both to the + * environment. + */ +async function prepareConverge( + deps: OperationDeps, + cwd: string, +): Promise> { + const credentials = deps.credentials; + const token = + credentials === undefined ? process.env['PRISMA_SERVICE_TOKEN'] : credentials.serviceToken; + if (token === undefined || token.length === 0) return notOk(serviceTokenRequired()); + let engine: DeployEngine; + try { + engine = deps.engine ?? (await loadAppEngine(cwd)); + } catch (error) { + if (CliStructuredError.is(error)) return notOk(error); + throw error; + } + return ok({ + engine, + credentialEnv: + credentials === undefined + ? {} + : { PRISMA_SERVICE_TOKEN: token, PRISMA_WORKSPACE_ID: credentials.workspaceId }, + }); +} + /** One Alchemy progress event as the deploy's step events: each resource is a step inside `apply`. */ -function reportConvergeEvent(steps: DeploySteps, event: ConvergeEvent): void { +function reportConvergeEvent( + steps: DeploySteps, + event: ConvergeEvent, + redact: (text: string) => string, +): void { if (event.kind === 'log') { - steps.message(event.severity, withComposerAdvice(event.text)); + steps.message(event.severity, redact(withComposerAdvice(event.text))); return; } const step: DeployStep = { @@ -785,7 +855,7 @@ function reportConvergeEvent(steps: DeploySteps, event: ConvergeEvent): void { if (!steps.isOpen(step)) steps.start(step); steps.fail(step); if (event.message !== undefined) { - steps.message('warn', `${event.fqn}: ${withComposerAdvice(event.message)}`); + steps.message('warn', redact(`${event.fqn}: ${withComposerAdvice(event.message)}`)); } return; case 'pending': @@ -793,6 +863,61 @@ function reportConvergeEvent(steps: DeploySteps, event: ConvergeEvent): void { } } +function reasonsText(reasons: readonly ConvergeFailureReason[]): string { + return reasons + .map(({ tag, message }) => + tag === 'Error' || tag === 'Unknown' || message.startsWith(tag) + ? message + : `${tag}: ${message}`, + ) + .join('\n'); +} + +/** The structured failure a converge that did not apply settles as. */ +function convergeFailure( + outcome: Exclude, + redact: (text: string) => string, +): CliStructuredError { + switch (outcome.kind) { + case 'interrupted': + return new CliStructuredError('DEPLOY.INTERRUPTED', 'The deploy was interrupted.', { + why: 'It stopped while Alchemy planned or applied it. Resources it had already changed stay as they are.', + fix: 'Deploy again to finish.', + }); + case 'refused': + return outcome.reason === 'already-running' + ? new CliStructuredError( + 'DEPLOY.CONVERGE_RUNNING', + 'Another deploy is already running in this process.', + { + why: "A deploy sets the process's environment and working directory while Alchemy runs, so two cannot overlap.", + fix: 'Wait for the other deploy to finish, or run this one in its own process.', + }, + ) + : new CliStructuredError( + 'DEPLOY.WORKER_THREAD', + 'Deploy cannot run in a worker thread when its cwd is not the process working directory.', + { + why: 'Alchemy reads `.env` and keeps `.alchemy` in the working directory, and a worker thread cannot change it.', + fix: "Call deploy from the main thread, or with the process's working directory as its cwd.", + }, + ); + case 'failed': { + const cause = capFailureCause(redact(withComposerAdvice(reasonsText(outcome.reasons)))); + return new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + `Alchemy could not deploy the app.\n${cause}`, + { + meta: { + engineCause: cause, + reasons: outcome.reasons.map(({ tag, message }) => ({ tag, message: redact(message) })), + }, + }, + ); + } + } +} + /** * Deploy's converge: lowers the app with the containers and preflight * payloads this run resolved, then plans and applies it in this process. @@ -802,6 +927,7 @@ function reportConvergeEvent(steps: DeploySteps, event: ConvergeEvent): void { async function convergeDeploy( opts: StackPipelineOptions, pipeline: PipelineResult, + setup: ConvergeSetup, resolved: { readonly containers: ReadonlyMap; readonly stage: string; @@ -809,8 +935,13 @@ async function convergeDeploy( readonly env: Readonly>; }, ): Promise> { - const { cwd, environment, deps, steps } = opts; + const { cwd, environment, steps } = opts; const config = pipeline.configSource.value; + const env = { ...resolved.env, ...setup.credentialEnv }; + const redact = convergeRedactor( + { ...process.env, ...env }, + poolSecrets(pipeline.pools, config.secrets !== undefined), + ); let summary: DeploymentSummary | undefined; const root = pipeline.graph.root.node; @@ -820,7 +951,7 @@ async function convergeDeploy( steps.start({ name: 'apply' }); let stack: LoweredStack; try { - stack = lower(root, config, { + stack = setup.engine.lower(root, config, { name: pipeline.name, environment, configPool: pipeline.pools.config, @@ -833,42 +964,23 @@ async function convergeDeploy( }); } catch (error) { // An extension's state or providers factory threw while the stack was built. - return notOk(toStructured('DEPLOY.ENGINE_FAILED', error)); + return notOk( + new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + redact(error instanceof Error ? error.message : String(error)), + ), + ); } - const env = { ...resolved.env, ...deps.convergeEnv }; - const outcome = await (deps.converge ?? convergeInProcess)({ + const outcome = await setup.engine.converge({ + operation: 'deploy', stack, stage: resolved.stage, cwd, env, signal: opts.signal, - onEvent: (event) => reportConvergeEvent(steps, event), + onEvent: (event) => reportConvergeEvent(steps, event, redact), }); - - switch (outcome.kind) { - case 'applied': - steps.finish({ name: 'apply' }); - return ok(summary); - case 'interrupted': - return notOk( - new CliStructuredError('DEPLOY.INTERRUPTED', 'The deploy was interrupted.', { - why: 'It stopped while Alchemy planned or applied it. Resources it had already changed stay as they are.', - fix: 'Deploy again to finish.', - }), - ); - case 'failed': { - const cause = convergeFailureCause( - outcome.cause, - { ...process.env, ...env }, - poolSecrets(pipeline.pools, config.secrets !== undefined), - ); - return notOk( - new CliStructuredError( - 'DEPLOY.ENGINE_FAILED', - `Alchemy could not deploy the app.\n${cause}`, - { meta: { engineCause: cause } }, - ), - ); - } - } + if (outcome.kind !== 'applied') return notOk(convergeFailure(outcome, redact)); + steps.finish({ name: 'apply' }); + return ok(summary); } diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index 05535e5bb..f1e86baef 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -12,7 +12,7 @@ import type { } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { CliStructuredError } from '@internal/foundation/errors'; -import type { Converge } from '../converge-in-process.ts'; +import type { DeployEngine } from '../app-engine.ts'; import type { startWatch } from '../dev/watch.ts'; import type { RunAlchemy } from '../run-alchemy.ts'; @@ -45,14 +45,8 @@ export interface OperationDeps { * natively and the engine owns signal policy; hosts get the default `spawnAlchemy`. */ readonly alchemy?: RunAlchemy | undefined; - /** Plans and applies `deploy`'s lowered stack; defaults to `convergeInProcess`. Tests substitute it. */ - readonly converge?: Converge | undefined; - /** - * Set in `process.env` while `deploy`'s plan and apply run, and restored - * after. The CLI puts the credential here, because Alchemy reads its - * credentials only from the environment. - */ - readonly convergeEnv?: Readonly> | undefined; + /** Lowers and converges `deploy`'s stack; defaults to the app's own install (`loadAppEngine`). Tests substitute it. */ + readonly engine?: DeployEngine | undefined; /** Starts a file watch for `dev`; defaults to chokidar-backed `startWatch`. Tests substitute it to drive changes directly. */ readonly watch?: typeof startWatch | undefined; /** @@ -69,7 +63,18 @@ export interface OperationDeps { * call", and both commands are credential-free by contract: there is no * authenticated call on those paths for this to reach. */ - readonly credentials?: ContainerCredentials | undefined; + readonly credentials?: OperationCredentials | undefined; +} + +/** The caller's credential: what the in-process leg authenticates with, and the service token Alchemy applies `deploy` with. */ +export interface OperationCredentials extends ContainerCredentials { + /** + * The service token the credential is. Absent when it is not one, such as a + * `prisma login` session: `deploy` then refuses with + * DEPLOY.SERVICE_TOKEN_REQUIRED, because Alchemy reads its credential only + * from the environment. + */ + readonly serviceToken?: string | undefined; } /** diff --git a/packages/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index e25ac4771..3bb07ced3 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ config: 'src/exports/config.ts', pools: 'src/exports/pools.ts', control: 'src/exports/control.ts', + converge: 'src/exports/converge.ts', family: 'src/exports/family.ts', testing: 'src/exports/testing.ts', }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index dc36150cd..53d7ea7e9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -5,6 +5,7 @@ import type { Contract } from '@internal/core'; import type { NodeDescriptor } from '@internal/core/config'; import { type ContainerInstance, containerEnvVarName } from '@internal/core/config'; import type { LowerContext, LoweredResult, Outputs } from '@internal/core/deploy'; +import { transportedLowerInputs } from '@internal/core/deploy'; // Import the REAL modules the mocks below stub, so each mock can spread them. // This matters beyond convenience: `bun test` runs every test file in ONE // process and `mock.module` is process-global. When the real module is already @@ -2274,6 +2275,7 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, @@ -2367,6 +2369,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth': { dir: 'modules/auth/dist/bundle', entry: 'server.js' }, @@ -2418,6 +2421,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth2': { dir: 'modules/auth2/dist/bundle', entry: 'server.js' }, @@ -2458,6 +2462,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth3': { dir: 'modules/auth3/dist/bundle', entry: 'server.js' } }, }), @@ -2495,6 +2500,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth4': { dir: 'modules/auth4/dist/bundle', entry: 'server.js' }, @@ -2560,6 +2566,7 @@ describe("streams' provisioned bearer key — one value per PROVIDER, stored on run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.events': { dir: 'modules/events/dist/bundle', entry: 'server.js' }, @@ -2672,6 +2679,7 @@ describe("streams' provisioned bearer key — one value per PROVIDER, stored on run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.lonely': { dir: 'modules/lonely/dist/bundle', entry: 'server.js' } }, }), @@ -2895,7 +2903,13 @@ describe('name validation — fail fast on Prisma name constraints, before creat }); const before = recorded.db.length; - const error = lowerError(lowering(root, configFor(target), { name: 'shop', bundles })); + const error = lowerError( + lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), + name: 'shop', + bundles, + }), + ); // A framework authoring error naming the address and the limit — not a raw PrismaApiError. expect(error).toBeInstanceOf(Error); @@ -2916,7 +2930,13 @@ describe('name validation — fail fast on Prisma name constraints, before creat }); const before = recorded.conn.length; - run(lowering(root, configFor(target), { name: 'shop', bundles: {} })); + run( + lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), + name: 'shop', + bundles: {}, + }), + ); expect(recorded.conn[before]?.[1]).toHaveProperty('name', `shop.${id}`); }); @@ -2932,7 +2952,13 @@ describe('name validation — fail fast on Prisma name constraints, before creat }); const before = recorded.bucket.length; - const error = lowerError(lowering(root, configFor(target), { name: 'shop', bundles: {} })); + const error = lowerError( + lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), + name: 'shop', + bundles: {}, + }), + ); expect(error.message).toContain(tooLongMessage(id, 'Bucket', 1, 100)); expect(recorded.bucket.length).toBe(before); @@ -2949,7 +2975,13 @@ describe('name validation — fail fast on Prisma name constraints, before creat }); const before = recorded.bucket.length; - run(lowering(root, configFor(target), { name: 'shop', bundles: {} })); + run( + lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), + name: 'shop', + bundles: {}, + }), + ); expect(recorded.bucket[before]?.[1]).toHaveProperty('name', `shop.${id}`); }); @@ -2967,6 +2999,7 @@ describe('name validation — fail fast on Prisma name constraints, before creat const error = lowerError( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { [`shop.${id}`]: bundles['shop.auth'] }, }), @@ -2989,6 +3022,7 @@ describe('name validation — fail fast on Prisma name constraints, before creat run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { [`shop.${id}`]: bundles['shop.auth'] }, }), @@ -3015,7 +3049,13 @@ describe('name validation — fail fast on Prisma name constraints, before creat const before = recorded.db.length; expect(() => - run(lowering(root, configFor(target), { name: 'shop', bundles })), + run( + lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), + name: 'shop', + bundles, + }), + ), ).not.toThrow(); expect(recorded.db.length).toBe(before + 1); }); @@ -3136,6 +3176,7 @@ describe('platform identity: each row carries its topology node logicalId — th run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth.service': { dir: 'modules/auth/dist/bundle', entry: 'server.js' } }, }), @@ -3365,6 +3406,7 @@ describe('resource IDs are built from the full address, and each declares the ID run( lowering(root, configFor(target), { + ...transportedLowerInputs(configFor(target)), name: 'shop', bundles: { 'shop.auth': bundle, 'shop.events': bundle, 'shop.web': bundle }, }), @@ -3712,6 +3754,7 @@ describe("prismaCloud().nodes['public-url'] — the public URL descriptor", () = withEnv(containerEnv('shop-project#cloud-id'), () => { run( lowering(root, configFor(prismaCloud({ workspaceId: 'ws_1' })), { + ...transportedLowerInputs(configFor(prismaCloud({ workspaceId: 'ws_1' }))), name: 'shop', environment: 'production', bundles: Object.fromEntries( diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 9b8491386..ac62ef0a5 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -2,11 +2,12 @@ "name": "@prisma/composer", "version": "0.36.0", "type": "module", - "description": "Prisma Composer — build a Prisma App by composing Modules. Core authoring, deploy pipeline, and the service-rpc/http/node/nextjs authoring surfaces. The command family the `prisma` CLI mounts lives in @prisma/composer-cli.", + "description": "Prisma Composer \u2014 build a Prisma App by composing Modules. Core authoring, deploy pipeline, and the service-rpc/http/node/nextjs authoring surfaces. The command family the `prisma` CLI mounts lives in @prisma/composer-cli.", "exports": { ".": "./dist/index.mjs", "./config": "./dist/config.mjs", "./internal/pools": "./dist/internal-pools.mjs", + "./internal/converge": "./dist/internal-converge.mjs", "./control": "./dist/control.mjs", "./deploy": "./dist/deploy.mjs", "./local-target": "./dist/local-target.mjs", diff --git a/packages/9-public/composer/src/exports/internal-converge.ts b/packages/9-public/composer/src/exports/internal-converge.ts new file mode 100644 index 000000000..c6fcfb03c --- /dev/null +++ b/packages/9-public/composer/src/exports/internal-converge.ts @@ -0,0 +1 @@ +export * from '@internal/cli/converge'; diff --git a/packages/9-public/composer/tsdown.config.ts b/packages/9-public/composer/tsdown.config.ts index e8aa876c7..de308b967 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ index: 'src/exports/index.ts', config: 'src/exports/config.ts', 'internal-pools': 'src/exports/internal-pools.ts', + 'internal-converge': 'src/exports/internal-converge.ts', control: 'src/exports/control.ts', deploy: 'src/exports/deploy.ts', 'local-target': 'src/exports/local-target.ts', diff --git a/test/integration/test/control.deploy.test.ts b/test/integration/test/control.deploy.test.ts index 7bac5c115..6f102ae32 100644 --- a/test/integration/test/control.deploy.test.ts +++ b/test/integration/test/control.deploy.test.ts @@ -22,10 +22,16 @@ const fixtureEntry = path.join( describe('@prisma/composer/control — programmatic deploy over the real extension config', () => { test('resolves both /control entries for real and fails structurally at the missing built entry, not at resolution', async () => { + // Deploy checks for a service token before any work; this one never reaches the platform. + const savedToken = process.env['PRISMA_SERVICE_TOKEN']; + process.env['PRISMA_SERVICE_TOKEN'] = 'integration-test-token'; const result = await deploy({ entry: fixtureEntry, cwd: integrationDir, config: { value: prismaConfig.composer, file: path.join(integrationDir, 'prisma.config.ts') }, + }).finally(() => { + if (savedToken === undefined) delete process.env['PRISMA_SERVICE_TOKEN']; + else process.env['PRISMA_SERVICE_TOKEN'] = savedToken; }); expect(result.ok).toBe(false); diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 7544011a9..6f15c569c 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -60,6 +60,7 @@ ], "@internal/cli/config": ["./packages/0-framework/3-tooling/cli/src/exports/config.ts"], "@internal/cli/pools": ["./packages/0-framework/3-tooling/cli/src/exports/pools.ts"], + "@internal/cli/converge": ["./packages/0-framework/3-tooling/cli/src/exports/converge.ts"], "@internal/cli/control": ["./packages/0-framework/3-tooling/cli/src/exports/control.ts"], "@internal/cli/family": ["./packages/0-framework/3-tooling/cli/src/exports/family.ts"], "@internal/cli/testing": ["./packages/0-framework/3-tooling/cli/src/exports/testing.ts"], @@ -149,6 +150,9 @@ "@prisma/composer/internal/pools": [ "./packages/9-public/composer/src/exports/internal-pools.ts" ], + "@prisma/composer/internal/converge": [ + "./packages/9-public/composer/src/exports/internal-converge.ts" + ], "@prisma/composer/deploy": ["./packages/9-public/composer/src/exports/deploy.ts"], "@prisma/composer/local-target": ["./packages/9-public/composer/src/exports/local-target.ts"], "@prisma/composer/testing": ["./packages/9-public/composer/src/exports/testing.ts"], From 95d63db1fc119e0954798bc38f9cd3056ef9e0c4 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 31 Oct 2026 11:00:52 +0100 Subject: [PATCH 03/14] Address slice 2 round 2 review: version check, shared flag, exit 130 - Rename the app-loaded pair to AppConverge / loadAppConverge and the code to DEPS.CONVERGE_UNRESOLVABLE; add "converge" to the glossary. - loadAppConverge refuses an app @prisma/composer at a version other than the host's (DEPS.CONVERGE_VERSION_MISMATCH, naming both), and resolves the install from the directory of prisma.config.ts. - A rejected converge promise is DEPLOY.ENGINE_FAILED. - The one-converge-per-process flag lives on globalThis under a Symbol.for key, so every install shares it. - withProcessState restores the environment before the directory, and the running flag is set inside the try that clears it. - An interrupted prisma deploy reports DEPLOY.INTERRUPTED as a message and ends with the engine abort, so it exits 130. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/03-domain-model/glossary.md | 4 + docs/design/10-domains/deploy-cli.md | 6 +- ...l-envelopes-with-dotted-namespace-codes.md | 9 +- ...ADR-0064-deploy-runs-alchemy-in-process.md | 11 +- docs/design/90-decisions/README.md | 2 +- docs/guides/deploying.md | 2 +- ...pp-engine.test.ts => app-converge.test.ts} | 51 +++++-- .../src/__tests__/converge-in-process.test.ts | 77 +++++++++- .../3-tooling/cli/src/app-converge.ts | 105 +++++++++++++ .../3-tooling/cli/src/app-engine.ts | 64 -------- .../3-tooling/cli/src/converge-in-process.ts | 68 +++++---- .../cli/src/family/__tests__/deploy.test.ts | 56 +++++++ .../cli/src/family/commands/deploy.ts | 17 ++- .../operations/__tests__/operations.test.ts | 141 ++++++++++++------ .../src/operations/execute-deploy-destroy.ts | 52 ++++--- .../3-tooling/cli/src/operations/shared.ts | 6 +- 16 files changed, 486 insertions(+), 185 deletions(-) rename packages/0-framework/3-tooling/cli/src/__tests__/{app-engine.test.ts => app-converge.test.ts} (59%) create mode 100644 packages/0-framework/3-tooling/cli/src/app-converge.ts delete mode 100644 packages/0-framework/3-tooling/cli/src/app-engine.ts diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 49ad9add4..6a6cecef6 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -419,6 +419,10 @@ The compilation from one plane to the next: authoring topology → provisioning resource graph → hosting primitives. Analogous to Prisma ORM lowering a contract to a plan. +### Converge + +Making an environment match the lowered stack: Alchemy plans what to create, update, replace and delete, then applies it. `prisma deploy` converges in its own process through Alchemy's programmatic API; `prisma dev` and `destroy` converge in an Alchemy child process. The *app converge* is the app install's `lower` and in-process converge, which `deploy` loads from `@prisma/composer/internal/converge` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). Codes: `DEV.CONVERGE_FAILED`, `DEPLOY.CONVERGE_RUNNING`, `DEPS.CONVERGE_UNRESOLVABLE`, `DEPS.CONVERGE_VERSION_MISMATCH`. + ### Control plane / Execution plane Two the framework modes. **Control plane**: import the topology, validate, build the diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 23d181060..3d1e870d5 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -96,7 +96,7 @@ that relative imports inside the entry graph may use `./x.js` or extensionless bun APIs can only deploy under bun, since loading the graph imports that module — the app's choice, not a CLI limit. -**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`) ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). It loads core's `lower()` and the converge from `@prisma/composer/internal/converge`, resolved from the app directory, so they use the app's own install of `alchemy` and `effect`, the same one its extensions and providers come from; the `prisma` host's pinned Composer version does not matter here. An app whose `@prisma/composer` cannot be resolved, or predates that entry, fails with `DEPS.ENGINE_UNRESOLVABLE` before anything is created. Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. +**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`) ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). It loads core's `lower()` and the converge from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, so they use the app's own install of `alchemy` and `effect`, the same one its extensions and providers come from. The `prisma` host's Composer calls that install, so the two must be the same version: a different version fails with `DEPS.CONVERGE_VERSION_MISMATCH`, naming both. An app whose `@prisma/composer` cannot be resolved, or predates that entry, fails with `DEPS.CONVERGE_UNRESOLVABLE`. Both fail before anything is created. Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. **The Alchemy child's runtime (`destroy` and `prisma dev`).** These still start an Alchemy child. Composer runs the `bin` of the `alchemy` package that the app's `@prisma/composer` depends on, found from the app @@ -188,8 +188,8 @@ user can rerun it by hand (ADR-0007). - **Progress.** Alchemy's typed events become step events under the `deploy.apply` step: one step per resource, labelled with Alchemy's status word (`creating shop.api`, then `created shop.api`), and Alchemy's log lines become messages, with credentials and pool secrets removed. Human and JSON output keep one format. Alchemy's console output is replaced, its interaction output goes to stderr, and the `prisma deploy` command sends `console`'s stdout methods to stderr while the operation runs. A provider that writes to `process.stdout` directly is out of reach. - **Result.** The report hook runs inside the apply, in the same process, so the deployment summary comes back as a value. - **Failure.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`. Its message carries the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's (`--adopt`) and every credential and pool secret removed. - - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI exits 130. - - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them on every exit path. One process runs one converge at a time (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). + - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI reports that code as a message (a frame in JSON) and exits 130 through the engine's own abort (`CLI.ABORTED`). + - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them on every exit path. One process runs one converge at a time, across every install it loads (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). - **Credentials.** Alchemy needs a service token. The engine gives a command no way to read a `prisma login` session's token, so the command passes a token only when the active credential came from `PRISMA_SERVICE_TOKEN`, with that credential's workspace. The operation checks for it before preflight and before any container is ensured, and fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` without one. `destroy` (and `prisma dev`) write the pipeline's results as a runnable stack module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one environment variable per extension — content the CLI writes but never reads (ADR-0038). `alchemy destroy` re-imports and re-evaluates the same stack, so its target reconstruction needs the containers. The generated file and Alchemy's state live in the process's working directory — tool state lives where you run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index 89079f07a..4844e1b57 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -1,6 +1,6 @@ # ADR-0044: Errors are structural envelopes with dotted namespace codes -Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD` and `DEPS.ENGINE_UNRESOLVABLE` are added. +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_VERSION_MISMATCH` are added. Amended 2026-10-23: the `CONFIG` registry gains ENTRY_MISSING, for a command or operation that has no entry module from the config or an override. @@ -107,7 +107,7 @@ Closed subcode registry: [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md))*. ENVIRONMENT_REQUIRED: a `destroy` call names no environment. ENGINE_FAILED: Alchemy failed. `deploy` runs Alchemy in process and exits 2; `destroy` and `prisma dev` pass the Alchemy child's exit status through (see § Exit codes). - INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. + INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. `prisma deploy` exits 130 (143 for SIGTERM): it reports this code as a message event and ends with the engine's abort, `CLI.ABORTED`. SERVICE_TOKEN_REQUIRED: `deploy`'s credential is not a service token, such as a `prisma login` session, or there is none in the environment ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). Temporary: it is retired when `@prisma/cli-engine` gives a command the active access token (the in-process credentials need, `needs: { credentials: 'process' }`, asked of `@prisma/cli-engine`), so a session can deploy. CONVERGE_RUNNING: another in-process converge holds this process's environment and working directory. WORKER_THREAD: `deploy` was called from a worker thread with a `cwd` other than the process's working directory, which a worker cannot change. @@ -115,11 +115,12 @@ Closed subcode registry: raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. - `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. - `LOG` — PLATFORM_UNSUPPORTED, ATTACH_FAILED, ADDRESS_UNKNOWN -- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE, ENGINE_UNRESOLVABLE *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. +- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE, CONVERGE_UNRESOLVABLE, CONVERGE_VERSION_MISMATCH *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. EXTENSION_INCOMPATIBLE: an installed extension is older than the Composer running the deploy or destroy, so the container it resolved, rebuilt through its own `deserialize`, does not name the environment that was asked for. - ENGINE_UNRESOLVABLE: `deploy` cannot load `@prisma/composer/internal/converge` from the app's own install, or that install predates it ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). + CONVERGE_UNRESOLVABLE: `deploy` cannot load `@prisma/composer/internal/converge` from the app's own install, or that install predates it ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). + CONVERGE_VERSION_MISMATCH: the app's `@prisma/composer` is not the Composer version the `prisma` CLI runs; the message names both. Adding a subcode is an edit to this list; adding a namespace is an amendment to this ADR. diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index 59e1a5871..110d9f6e9 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -18,14 +18,15 @@ yield* Alchemist.apply(plan); This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. -- **The app's install runs the deploy.** The executor loads `lower` and `convergeInProcess` from `@prisma/composer/internal/converge`, resolved from the app directory, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from the app's install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the engine from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entry fails with `DEPS.ENGINE_UNRESOLVABLE` before anything is created. +- **The app's install runs the converge.** The executor loads `lower` and `convergeInProcess` (together, the app converge) from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from that install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the converge from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entry fails with `DEPS.CONVERGE_UNRESOLVABLE` before anything is created. +- **Host and app run one Composer version.** The host's operation code calls the app's `lower` and converge and reads back their results, so the two must agree on every shape between them. Composer's packages release in lockstep, so the host compares the app's `@prisma/composer` version with its own and refuses any difference with `DEPS.CONVERGE_VERSION_MISMATCH`, naming both versions. An exact match is simpler than a separate contract number, and lockstep releases make it cheap to meet. - **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as required values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Only the generated stack files for `destroy` and `prisma dev` read the environment transports, through one core helper, `transportedLowerInputs(config)`. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. - **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. -- **Failures are structured.** The converge returns the errors Alchemy failed with as typed reasons (tag and message), and the deploy maps them to `DEPLOY.ENGINE_FAILED`, with Composer's advice in place of the Alchemy CLI's and secrets removed. An interrupted one is `DEPLOY.INTERRUPTED`. Every message event, and an error raised while the stack is built, is redacted the same way. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. -- **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. +- **Failures are structured.** The converge returns the errors Alchemy failed with as typed reasons (tag and message), and the deploy maps them to `DEPLOY.ENGINE_FAILED`, with Composer's advice in place of the Alchemy CLI's and secrets removed. A converge that rejects instead of returning an outcome is `DEPLOY.ENGINE_FAILED` too. An interrupted one is `DEPLOY.INTERRUPTED`. Every message event, and an error raised while the stack is built, is redacted the same way. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. +- **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. The engine exits 130 (143 for SIGTERM) only when a handler ends with its abort, not when it returns an error, so `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message event, which JSON output carries as a frame with the code, and then ends with the engine's abort (`CLI.ABORTED`). - **The credential is an environment variable.** Alchemy reads its configuration only from `process.env` and `.env`. `deploy` sets `PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID` and each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. Only Alchemy's credentials and working directory need process state; the reporter variables ride the same overlay until a reporter can read them as values. `@prisma/cli-engine` 0.9.0 gives a command no way to read a `prisma login` session's token. So the operation checks for a service token before preflight and before any container is ensured, and refuses with `DEPLOY.SERVICE_TOKEN_REQUIRED`. The command passes a token only when the engine's active credential came from the environment, with that same credential's workspace; when the credential names no workspace, `PRISMA_WORKSPACE_ID` is removed for the apply. A token from one credential is never paired with a workspace from another. -- **One converge per process.** Because of that process state, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`, and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. +- **One converge per process.** Because of that process state, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`. The flag lives on `globalThis` under a `Symbol.for` key, so it holds across every install of the entry a host loads, such as two apps deployed from one script; and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. - **Stdout stays the engine's.** Alchemy's log lines and events go to the caller as values, and Alchemy's non-interactive `Interaction` writes to stderr. The `prisma deploy` command, not the operation, sends `console`'s stdout methods to stderr while the operation runs, so a provider's `console.log` cannot corrupt JSON output; a host calling the operation keeps its own `console`, as ADR-0043 requires. A provider that writes to `process.stdout` directly is out of reach. ## Reasoning @@ -37,7 +38,7 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy ## Consequences - `deploy` no longer resolves Alchemy's bin or needs Node on PATH under Bun. `DEPLOY.ALCHEMY_BIN_MISSING`, `DEPLOY.NODE_MISSING` and `DEPLOY.STACK_WRITE_FAILED` remain for `destroy` and `prisma dev`. -- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD` and `DEPS.ENGINE_UNRESOLVABLE`. +- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_VERSION_MISMATCH`. - The converge relies on two Alchemy modules outside `alchemy/Alchemist`: `alchemy/Util/LogRecord` (to read log records) and `alchemy/Interaction` (to send interaction output to stderr). - `prisma deploy` declares `needs: { credentials: true }` and no longer `maySpawn`. - Setting `process.env` and the working directory for the length of the apply is process-wide state, so one process runs one converge at a time. The upstream asks below remove the need. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index ffe5f1678..87a0b2829 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -85,4 +85,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its configuration as one Standard Schema, `input`, exactly as a compute service does; its body receives opaque per-key references it can only pass into children's inputs or derive from with `resolve()`, so the graph cannot branch on configuration. Module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, filled by config adapters. *(Amended 2026-10-29: an input field is a `Value`; the module's schema validates its binding during Load's second pass; a truthiness check on a field is not caught, but always goes the same way.)* *(Amended 2026-10-29, third slice: `paramSource()` and `secretSource()` return values and take an options argument.)* *(Amended 2026-10-30: a root's input is the config pool.)* *(Amended 2026-10-30, fifth slice: the slots are removed; `ModuleNode`.)* *(Amended 2026-10-30, seventh slice: a module's schema checks each runtime value as its pointer; `paramSource()` loses `sentinel`.)* *(Amended 2026-10-30, eighth slice: only a plain field can be a `resolve()` dependency.)* - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. *(Amended by ADR-0064: `deploy` runs Alchemy in its first process and writes no configuration file; the second process applies to `prisma dev` and `destroy`.)* - [ADR-0063](ADR-0063-a-node-depends-on-an-interface-or-exposes-one.md) — The two ends of a connection are named by what the node does with them: a node **depends on** an interface (`deps`) or **exposes** one (`expose`); an interface is a `Contract` in code; Input and Output are retired as port words, because the dataflow and ports-and-adapters traditions give the same two ends opposite names; the module body's `inputs` becomes `deps`; the topology's `in`/`out` stay, defined as the dependency end and the exposed end. -- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`, which must be the host's Composer version. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index ecd5fe9aa..1f5f634b6 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -742,7 +742,7 @@ What to know before embedding it: a step fails, it and the steps around it finish as `failed`; `record` still runs. An `onEvent` that throws does not fail the deploy. A successful result's `durationMs` is the whole deploy's total. -- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env` and changes to `cwd` when your process is elsewhere, then restores both, so a process runs one deploy at a time (`DEPLOY.CONVERGE_RUNNING`), and from a worker thread `cwd` must be the process's working directory (`DEPLOY.WORKER_THREAD`). Deploy uses the lowering and the deploy engine from the app's own install of `@prisma/composer`. Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. +- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env` and changes to `cwd` when your process is elsewhere, then restores both, so a process runs one deploy at a time (`DEPLOY.CONVERGE_RUNNING`), and from a worker thread `cwd` must be the process's working directory (`DEPLOY.WORKER_THREAD`). Deploy uses the lowering and the converge from the app's own install of `@prisma/composer`, found beside its `prisma.config.ts`, which must be the same version as the Composer running the deploy (`DEPS.CONVERGE_VERSION_MISMATCH` otherwise). Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. - **`dev` resolves to `{ ok: true, value: session }` or a failure** — never an exit code. The session is `{ endpoints, stop(), closed }`, with progress (`ready`, `converge-failed`, `config-changed`, `watch-error`, …) delivered through diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts similarity index 59% rename from packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts rename to packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts index 0ad497357..8b18366b3 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/app-engine.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { CliStructuredError } from '@internal/foundation/errors'; -import { loadAppEngine } from '../app-engine.ts'; +import { HOST_COMPOSER_VERSION, loadAppConverge } from '../app-converge.ts'; const dirs: string[] = []; afterEach(() => { @@ -13,9 +13,11 @@ afterEach(() => { /** An app whose node_modules holds a @prisma/composer with the given exports and entry source. */ function appWith( - composer: { exports: Record; files: Record } | undefined, + composer: + | { exports: Record; files: Record; version?: string } + | undefined, ) { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-app-engine-'))); + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-app-converge-'))); dirs.push(dir); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'app' })); if (composer !== undefined) { @@ -23,7 +25,12 @@ function appWith( fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); fs.writeFileSync( path.join(pkg, 'package.json'), - JSON.stringify({ name: '@prisma/composer', type: 'module', exports: composer.exports }), + JSON.stringify({ + name: '@prisma/composer', + version: composer.version ?? HOST_COMPOSER_VERSION, + type: 'module', + exports: { ...composer.exports, './package.json': './package.json' }, + }), ); for (const [file, source] of Object.entries(composer.files)) { fs.writeFileSync(path.join(pkg, file), source); @@ -34,15 +41,15 @@ function appWith( async function failureOf(dir: string): Promise { try { - await loadAppEngine(dir); + await loadAppConverge(dir); } catch (error) { if (CliStructuredError.is(error)) return error; throw error; } - throw new Error('expected loadAppEngine to fail'); + throw new Error('expected loadAppConverge to fail'); } -describe('loadAppEngine', () => { +describe('loadAppConverge', () => { test("loads lower and convergeInProcess from the app's own @prisma/composer", async () => { const dir = appWith({ exports: { './internal/converge': './dist/internal-converge.mjs' }, @@ -52,7 +59,7 @@ describe('loadAppEngine', () => { }, }); - const engine = await loadAppEngine(dir); + const engine = await loadAppConverge(dir); const stub = await import( pathToFileURL(path.join(dir, 'node_modules/@prisma/composer/dist/internal-converge.mjs')).href ); @@ -61,22 +68,22 @@ describe('loadAppEngine', () => { expect(engine.converge).toBe(stub.convergeInProcess); }); - test('an app without @prisma/composer is DEPS.ENGINE_UNRESOLVABLE', async () => { + test('an app without @prisma/composer is DEPS.CONVERGE_UNRESOLVABLE', async () => { const failure = await failureOf(appWith(undefined)); - expect(failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + expect(failure.code).toBe('DEPS.CONVERGE_UNRESOLVABLE'); expect(failure.message).toContain('@prisma/composer/internal/converge does not resolve'); }); - test('a @prisma/composer older than the in-process converge is DEPS.ENGINE_UNRESOLVABLE', async () => { + test('a @prisma/composer older than the in-process converge is DEPS.CONVERGE_UNRESOLVABLE', async () => { const failure = await failureOf( appWith({ exports: { './deploy': './dist/deploy.mjs' }, files: { 'dist/deploy.mjs': '' } }), ); - expect(failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + expect(failure.code).toBe('DEPS.CONVERGE_UNRESOLVABLE'); }); - test('an entry that exports neither function is DEPS.ENGINE_UNRESOLVABLE', async () => { + test('an entry that exports neither function is DEPS.CONVERGE_UNRESOLVABLE', async () => { const failure = await failureOf( appWith({ exports: { './internal/converge': './dist/internal-converge.mjs' }, @@ -86,4 +93,22 @@ describe('loadAppEngine', () => { expect(failure.message).toContain('exports no lower and convergeInProcess'); }); + + test("an app's @prisma/composer at another version is DEPS.CONVERGE_VERSION_MISMATCH, naming both versions", async () => { + const failure = await failureOf( + appWith({ + version: '0.0.1', + exports: { './internal/converge': './dist/internal-converge.mjs' }, + files: { + 'dist/internal-converge.mjs': + 'export const lower = () => {}; export const convergeInProcess = async () => ({});', + }, + }), + ); + + expect(failure.code).toBe('DEPS.CONVERGE_VERSION_MISMATCH'); + expect(failure.message).toContain('0.0.1'); + expect(failure.message).toContain(HOST_COMPOSER_VERSION); + expect(failure.fix).toContain(`Install @prisma/composer ${HOST_COMPOSER_VERSION}`); + }); }); diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts index fe097cd2e..dad81f0ed 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { type Contract, module, resource } from '@internal/core'; import type { ExtensionDescriptor, PrismaAppConfig } from '@internal/core/config'; import { type DeploymentResult, type LowerOptions, lower } from '@internal/core/deploy'; +import * as Alchemist from 'alchemy/Alchemist'; import * as Interaction from 'alchemy/Interaction'; import * as State from 'alchemy/State'; import { InMemoryService } from 'alchemy/State/InMemoryState'; @@ -229,6 +230,80 @@ describe('convergeInProcess', () => { } }); + test('one converge per process holds across two copies of the module', async () => { + const copy: typeof import('../converge-in-process.ts') = await import( + `../converge-in-process.ts?copy=${String(Date.now())}` + ); + expect(copy.convergeInProcess).not.toBe(convergeInProcess); + const controller = new AbortController(); + let started!: () => void; + const running = new Promise((resolve) => { + started = resolve; + }); + const { config } = observing( + {}, + Effect.sync(() => started()).pipe(Effect.andThen(Effect.never)), + ); + + const first = convergeInProcess(request(config, {}, { signal: controller.signal })); + await running; + const second = await copy.convergeInProcess(request(fixture(reportsThing).config)); + controller.abort(); + await first; + + expect(second).toEqual({ kind: 'refused', reason: 'already-running' }); + }); + + test('restores the environment even when the working directory cannot be restored', async () => { + const before = process.cwd(); + const doomed = tmpDir(); + process.chdir(doomed); + const cwd = tmpDir(); + const { config } = fixture( + Object.assign( + () => + Effect.sync(() => { + fs.rmSync(doomed, { recursive: true, force: true }); + return { outputs: {}, entities: [] }; + }), + { kind: 'resource' as const }, + ), + ); + try { + const outcome = await convergeInProcess( + request(config, {}, { cwd, env: { COMPOSER_TEST_TOKEN: 'tok' } }), + ); + + expect(outcome.kind).toBe('failed'); + expect(process.env['COMPOSER_TEST_TOKEN']).toBeUndefined(); + } finally { + process.chdir(before); + } + expect(await convergeInProcess(request(fixture(reportsThing).config))).toEqual({ + kind: 'applied', + }); + }); + + test('a converge whose Alchemy services cannot be built fails, and leaves the next one free to run', async () => { + const layer = spyOn(Alchemist, 'layer').mockImplementation(() => { + throw new Error('alchemy without Alchemist.layer'); + }); + let outcome: Awaited>; + try { + outcome = await convergeInProcess(request(fixture(reportsThing).config)); + } finally { + layer.mockRestore(); + } + + expect(outcome).toEqual({ + kind: 'failed', + reasons: [{ tag: 'Error', message: 'alchemy without Alchemist.layer' }], + }); + expect(await convergeInProcess(request(fixture(reportsThing).config))).toEqual({ + kind: 'applied', + }); + }); + test('sets the requested environment and working directory only while it runs', async () => { const seen: { token?: string | undefined; cwd?: string } = {}; const { config } = fixture( @@ -352,7 +427,7 @@ describe('deploy with the real in-process converge', () => { }, { runAssembler: async () => ({ dir, entry: 'server.js' }), - engine: { lower, converge: convergeInProcess }, + converge: { lower, converge: convergeInProcess }, credentials: { workspaceId: 'ws_1', client: {}, serviceToken: 'tok' }, }, ); diff --git a/packages/0-framework/3-tooling/cli/src/app-converge.ts b/packages/0-framework/3-tooling/cli/src/app-converge.ts new file mode 100644 index 000000000..49924a46d --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/app-converge.ts @@ -0,0 +1,105 @@ +/** + * Which copy of Composer's lowering and in-process converge runs `deploy`: + * the one the app installs, found from the directory of the app's + * `prisma.config.ts`, the file its extensions are imported from. The stack, + * Alchemy and the providers then share one copy of `alchemy` and `effect` + * even when the `prisma` host pins another Composer (ADR-0050, ADR-0064). + * The host calls that copy across a version boundary, and Composer's + * packages release in lockstep, so the two versions must be equal. + */ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { lower } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; +import { CliStructuredError } from '@internal/foundation/errors'; +import manifest from '../package.json' with { type: 'json' }; +import type { Converge } from './converge-in-process.ts'; + +const ENTRY = '@prisma/composer/internal/converge'; + +/** The Composer version this host runs; every Composer package carries the same one. */ +export const HOST_COMPOSER_VERSION: string = manifest.version; + +/** The app install's `lower` and `convergeInProcess`. */ +export interface AppConverge { + readonly lower: typeof lower; + readonly converge: Converge; +} + +function unresolvable(appDir: string, detail: string, cause?: unknown): CliStructuredError { + return new CliStructuredError( + 'DEPS.CONVERGE_UNRESOLVABLE', + `Could not load the converge from the app's @prisma/composer in "${appDir}": ${detail}`, + { + why: "`prisma deploy` runs the lowering and Alchemy from the app's own install of @prisma/composer, so the app, its extensions and Alchemy share one copy of alchemy.", + fix: 'Install @prisma/composer in the app, at the same version as the Composer the `prisma` CLI runs.', + ...(cause === undefined ? {} : { cause }), + }, + ); +} + +function installedVersion(require: NodeJS.Require): string | undefined { + try { + const parsed: unknown = JSON.parse( + fs.readFileSync(require.resolve('@prisma/composer/package.json'), 'utf8'), + ); + return typeof parsed === 'object' && + parsed !== null && + 'version' in parsed && + typeof parsed.version === 'string' + ? parsed.version + : undefined; + } catch { + return undefined; + } +} + +/** + * The app's converge, from the install `appDir` resolves. Raises + * DEPS.CONVERGE_UNRESOLVABLE when it is missing or predates the in-process + * converge, and DEPS.CONVERGE_VERSION_MISMATCH when its version is not + * `hostVersion`. + */ +export async function loadAppConverge( + appDir: string, + hostVersion: string = HOST_COMPOSER_VERSION, +): Promise { + const require = createRequire(path.join(appDir, 'package.json')); + let file: string; + try { + file = require.resolve(ENTRY); + } catch (error) { + throw unresolvable(appDir, `${ENTRY} does not resolve.`, error); + } + const appVersion = installedVersion(require); + if (appVersion !== hostVersion) { + throw new CliStructuredError( + 'DEPS.CONVERGE_VERSION_MISMATCH', + `The app's @prisma/composer is ${appVersion ?? 'of an unknown version'}, and the \`prisma\` CLI runs Composer ${hostVersion}.`, + { + why: "Deploy calls the app's @prisma/composer from the CLI's Composer, and Composer's packages work together only at one version.", + fix: `Install @prisma/composer ${hostVersion} in the app, or a \`prisma\` CLI that runs Composer ${appVersion ?? "the app's version"}, so the two versions match.`, + }, + ); + } + let loaded: Record; + try { + loaded = await import(pathToFileURL(file).href); + } catch (error) { + throw unresolvable( + appDir, + `${file} failed to load: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + const { lower: appLower, convergeInProcess } = loaded; + if (typeof appLower !== 'function' || typeof convergeInProcess !== 'function') { + throw unresolvable(appDir, `${file} exports no lower and convergeInProcess.`); + } + return blindCast< + AppConverge, + "the typeof checks above prove both are functions, and the version check proves the entry is this package's own src/exports/converge.ts at this version" + >({ lower: appLower, converge: convergeInProcess }); +} diff --git a/packages/0-framework/3-tooling/cli/src/app-engine.ts b/packages/0-framework/3-tooling/cli/src/app-engine.ts deleted file mode 100644 index b59230c53..000000000 --- a/packages/0-framework/3-tooling/cli/src/app-engine.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Which copy of Composer's lowering and Alchemy runs `deploy`: the one the - * app installs, found from the app directory the way the generated stack - * file's own `@prisma/composer` imports are. The extension descriptors, - * providers and state store come from that install too, through - * `prisma.config.ts`, so the stack, the engine and the providers share one - * copy of `alchemy` and `effect` even when the `prisma` host pins another - * Composer version (ADR-0050, ADR-0064). - */ -import { createRequire } from 'node:module'; -import * as path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import type { lower } from '@internal/core/deploy'; -import { blindCast } from '@internal/foundation/casts'; -import { CliStructuredError } from '@internal/foundation/errors'; -import type { Converge } from './converge-in-process.ts'; - -const ENTRY = '@prisma/composer/internal/converge'; - -/** Lowers the app and converges the stack: one install's `lower` and `convergeInProcess`. */ -export interface DeployEngine { - readonly lower: typeof lower; - readonly converge: Converge; -} - -function unresolvable(appDir: string, detail: string, cause?: unknown): CliStructuredError { - return new CliStructuredError( - 'DEPS.ENGINE_UNRESOLVABLE', - `Could not load the deploy engine from the app's @prisma/composer in "${appDir}": ${detail}`, - { - why: "`prisma deploy` runs the lowering and Alchemy from the app's own install of @prisma/composer, so the app, its extensions and the engine share one copy of alchemy.", - fix: 'Install @prisma/composer in the app, at the same version as the Composer the `prisma` CLI runs, and deploy from the app directory.', - ...(cause === undefined ? {} : { cause }), - }, - ); -} - -/** The app's deploy engine. Raises DEPS.ENGINE_UNRESOLVABLE when the app's @prisma/composer is missing or predates the in-process converge. */ -export async function loadAppEngine(appDir: string): Promise { - let file: string; - try { - file = createRequire(path.join(appDir, 'package.json')).resolve(ENTRY); - } catch (error) { - throw unresolvable(appDir, `${ENTRY} does not resolve.`, error); - } - let loaded: Record; - try { - loaded = await import(pathToFileURL(file).href); - } catch (error) { - throw unresolvable( - appDir, - `${file} failed to load: ${error instanceof Error ? error.message : String(error)}`, - error, - ); - } - const { lower: appLower, convergeInProcess } = loaded; - if (typeof appLower !== 'function' || typeof convergeInProcess !== 'function') { - throw unresolvable(appDir, `${file} exports no lower and convergeInProcess.`); - } - return blindCast< - DeployEngine, - "the typeof checks above prove both are functions; the entry is this package's own src/exports/converge.ts, built into the app's @prisma/composer" - >({ lower: appLower, converge: convergeInProcess }); -} diff --git a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts index 1665bbc20..94219e47c 100644 --- a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts +++ b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts @@ -9,6 +9,7 @@ */ import { isMainThread } from 'node:worker_threads'; import type { lower } from '@internal/core/deploy'; +import { blindCast } from '@internal/foundation/casts'; import * as Alchemist from 'alchemy/Alchemist'; import * as Interaction from 'alchemy/Interaction'; import { recordOf } from 'alchemy/Util/LogRecord'; @@ -149,11 +150,18 @@ export function failureReasons(cause: Cause.Cause): readonly ConvergeFa : [{ tag: 'Unknown', message: Cause.pretty(cause) }]; } -let running = false; +/** One flag per process, shared by every copy of this module, so two apps' installs in one host cannot converge at once. */ +const RUNNING = Symbol.for('prisma.composer.converge.running'); + +const flag = blindCast< + { [RUNNING]?: boolean }, + 'globalThis takes any symbol-keyed property; this one is ours alone' +>(globalThis); /** * Sets the process state Alchemy reads for the length of `run`, and restores - * it on every exit path: the variables in `env` and the working directory. + * it on every exit path: the variables in `env`, then the working directory. + * A directory that cannot be restored never leaves a variable behind. */ async function withProcessState( env: Readonly>, @@ -162,42 +170,28 @@ async function withProcessState( ): Promise { const previousEnv = Object.keys(env).map((name) => [name, process.env[name]] as const); const previousCwd = process.cwd(); + let changedDirectory = false; try { for (const [name, value] of Object.entries(env)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } - if (previousCwd !== cwd) process.chdir(cwd); + if (previousCwd !== cwd) { + process.chdir(cwd); + changedDirectory = true; + } return await run(); } finally { - if (process.cwd() !== previousCwd) process.chdir(previousCwd); for (const [name, value] of previousEnv) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + if (changedDirectory) process.chdir(previousCwd); } } -/** - * Plans and applies `stack` in this process, as `alchemy deploy --yes` would. - * The plan's scope, and the state-store lease it holds, closes before this - * returns, including when `signal` interrupts it. One converge runs at a time - * per process: a second call while one runs is refused. - */ -export const convergeInProcess: Converge = async (request) => { - if (running) return { kind: 'refused', reason: 'already-running' }; - if (!isMainThread && process.cwd() !== request.cwd) { - return { kind: 'refused', reason: 'worker-thread' }; - } - running = true; - const emit = (event: ConvergeEvent): void => { - try { - request.onEvent?.(event); - } catch { - // Rendering is the caller's; its failures are not the deploy's. - } - }; - const program = Effect.gen(function* () { +function program(request: ConvergeRequest, emit: (event: ConvergeEvent) => void) { + return Effect.gen(function* () { const plan = yield* Alchemist.plan({ stack: request.stack, operation: request.operation, @@ -217,11 +211,33 @@ export const convergeInProcess: Converge = async (request) => { Effect.provide(Interaction.layerNonInteractive({ stdout: process.stderr })), Effect.provide(Alchemist.layer()), ); +} +/** + * Plans and applies `stack` in this process, as `alchemy deploy --yes` would. + * The plan's scope, and the state-store lease it holds, closes before this + * returns, including when `signal` interrupts it. One converge runs at a time + * per process, across every install of this module: a second call while one + * runs is refused. + */ +export const convergeInProcess: Converge = async (request) => { + if (flag[RUNNING] === true) return { kind: 'refused', reason: 'already-running' }; + if (!isMainThread && process.cwd() !== request.cwd) { + return { kind: 'refused', reason: 'worker-thread' }; + } + const emit = (event: ConvergeEvent): void => { + try { + request.onEvent?.(event); + } catch { + // Rendering is the caller's; its failures are not the deploy's. + } + }; try { + flag[RUNNING] = true; + const effect = program(request, emit); return await withProcessState(request.env, request.cwd, async () => { const exit = await Effect.runPromiseExit( - program, + effect, request.signal === undefined ? undefined : { signal: request.signal }, ); if (Exit.isSuccess(exit)) return { kind: 'applied' } as const; @@ -239,6 +255,6 @@ export const convergeInProcess: Converge = async (request) => { ], }; } finally { - running = false; + flag[RUNNING] = false; } }; diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index 4e58a8512..60d555b77 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -328,6 +328,62 @@ describe('settlement', () => { }); }); +describe('Ctrl-C', () => { + /** A deploy that runs until the run is aborted, then reports DEPLOY.INTERRUPTED as the real operation does. */ + function interruptedCli(controller: AbortController) { + const double = createOperationsDouble({}); + const family = createComposerFamily({ + operations: { + ...double.operations, + deploy: async (input) => { + controller.abort(); + await new Promise((resolve) => { + if (input.signal?.aborted === true) resolve(); + input.signal?.addEventListener('abort', () => resolve()); + }); + return notOk( + new CliStructuredError('DEPLOY.INTERRUPTED', 'The deploy was interrupted.', { + fix: 'Deploy again to finish.', + }), + ); + }, + }, + }); + return createTestCli({ + commands: { deploy: commandAt(family, 'deploy') }, + config: { composer: validComposerSection() }, + environmentCredential: activeCredential(), + }); + } + + test('an interrupted deploy exits 130, and the human output still says it was interrupted', async () => { + const controller = new AbortController(); + const result = await interruptedCli(controller).run(['deploy', '--entry', 'src/service.ts'], { + ...AS_TTY, + abort: controller.signal, + }); + + expect(result.exitCode).toBe(130); + expect(plain(result.stderr)).toContain('The deploy was interrupted. Deploy again to finish.'); + }); + + test('in JSON, the interruption is a message frame carrying DEPLOY.INTERRUPTED', async () => { + const controller = new AbortController(); + const result = await interruptedCli(controller).run( + ['deploy', '--entry', 'src/service.ts', '--json'], + { ...AS_TTY, abort: controller.signal }, + ); + + expect(result.exitCode).toBe(130); + expect(result.json).toContainEqual( + expect.objectContaining({ + kind: 'message', + data: expect.objectContaining({ code: 'DEPLOY.INTERRUPTED' }), + }), + ); + }); +}); + describe('what a successful run presents', () => { test('deploy reports the app and a row per deployed node', async () => { const { cli } = composerCli({ diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts index fafe9faf3..6d2b60848 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts @@ -178,7 +178,22 @@ export const createDeployCommand = (operations: ComposerOperations) => }, ), ); - if (!result.ok) return notOk(toEngineError(result.failure)); + if (!result.ok) { + // An interrupted run settles as the signal it is: the engine exits + // 130/143 only for its own abort, not for a returned error. The + // structured error still reaches the output, as a message. + if (result.failure.code === 'DEPLOY.INTERRUPTED' && ctx.signal.aborted) { + const { code, message, why, fix } = result.failure; + ctx.report({ + kind: 'message', + severity: 'warn', + text: `${message} ${fix ?? ''}`.trim(), + data: { code, message, why, fix }, + }); + throw ctx.signal.reason; + } + return notOk(toEngineError(result.failure)); + } const { summary, durationMs } = result.value; const target = `to ${args.flags.environment ?? DEFAULT_DEPLOY_ENVIRONMENT} in ${formatDuration(durationMs)}`; diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 15b52f081..5ac3e449f 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -28,7 +28,7 @@ import type { } from '@internal/core/local-target'; import { CliStructuredError } from '@internal/foundation/errors'; import * as Layer from 'effect/Layer'; -import type { DeployEngine } from '../../app-engine.ts'; +import { type AppConverge, HOST_COMPOSER_VERSION } from '../../app-converge.ts'; import type { ComposerConfigSource } from '../../composer-config.ts'; import { dotenv } from '../../config-adapters.ts'; import type { Converge, ConvergeOutcome, ConvergeRequest } from '../../converge-in-process.ts'; @@ -202,8 +202,8 @@ const fakeAssembler = async (node: ServiceNode) => ({ const APPLIED: ConvergeOutcome = { kind: 'applied' }; -/** Core's own `lower` with a stand-in for Alchemy's plan and apply: deploy tests never run the real engine here. */ -function engineWith(converge: Converge): DeployEngine { +/** Core's own `lower` with a stand-in for Alchemy's plan and apply: deploy tests never run the real Alchemy here. */ +function appConverge(converge: Converge): AppConverge { return { lower, converge }; } @@ -217,7 +217,7 @@ afterAll(() => { else process.env['PRISMA_SERVICE_TOKEN'] = previousServiceToken; }); -/** Stands in for Alchemy's plan and apply: deploy tests never run the real engine here. */ +/** Stands in for Alchemy's plan and apply: deploy tests never run the real Alchemy here. */ const applied: Converge = async () => APPLIED; function recordingConverge( @@ -246,7 +246,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(recordingConverge(requests)), + converge: appConverge(recordingConverge(requests)), alchemy: async () => { spawned = true; return { exitCode: 0, signal: null }; @@ -293,7 +293,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async () => { + converge: appConverge(async () => { ran = true; return APPLIED; }), @@ -322,7 +322,7 @@ describe('deploy()', () => { ...(input.entry === undefined ? {} : { entry: input.entry }), cwd: app.dir, }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -354,7 +354,7 @@ describe('deploy()', () => { { config: composerConfig(fakeConfig()), cwd: app.dir }, { runAssembler: fakeAssembler, - engine: engineWith(async () => { + converge: appConverge(async () => { ran = true; return APPLIED; }), @@ -384,7 +384,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(recordingConverge(requests)), + converge: appConverge(recordingConverge(requests)), }, ), ); @@ -411,7 +411,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -442,7 +442,7 @@ describe('deploy()', () => { assembled = true; return fakeAssembler(node); }, - engine: engineWith(async () => { + converge: appConverge(async () => { convergeRan = true; return APPLIED; }), @@ -469,7 +469,7 @@ describe('deploy()', () => { { config, entry: app.entryPath, cwd: app.dir }, { runAssembler: fakeAssembler, - engine: engineWith(async () => { + converge: appConverge(async () => { convergeRan = true; return APPLIED; }), @@ -588,7 +588,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async () => { + converge: appConverge(async () => { convergeRan = true; return APPLIED; }), @@ -616,7 +616,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async () => ({ + converge: appConverge(async () => ({ kind: 'failed', reasons: [ { @@ -666,7 +666,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async () => ({ + converge: appConverge(async () => ({ kind: 'failed', reasons: [ { @@ -705,7 +705,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(recordingConverge(requests, { kind: 'interrupted' })), + converge: appConverge(recordingConverge(requests, { kind: 'interrupted' })), }, ), ); @@ -796,7 +796,7 @@ function stepLine(event: DeployEvent): string { return event.kind === 'step-started' ? `started ${name}` : `finished ${name} ${event.outcome}`; } -describe('deploy() credentials, engine and redaction', () => { +describe('deploy() credentials, app converge and redaction', () => { const deployWith = ( app: { dir: string; entryPath: string }, deps: Parameters[1], @@ -824,7 +824,7 @@ describe('deploy() credentials, engine and redaction', () => { const result = await deployWith( app, { - engine: engineWith(recordingConverge(requests)), + converge: appConverge(recordingConverge(requests)), credentials: { workspaceId: 'ws_1', client: {} }, }, fakeConfig({}, { calls: containerCalls }), @@ -841,7 +841,7 @@ describe('deploy() credentials, engine and redaction', () => { const saved = process.env['PRISMA_SERVICE_TOKEN']; delete process.env['PRISMA_SERVICE_TOKEN']; try { - const result = await deployWith(app, { engine: engineWith(applied) }); + const result = await deployWith(app, { converge: appConverge(applied) }); if (result.ok) throw new Error('expected a failure'); expect(result.failure.code).toBe('DEPLOY.SERVICE_TOKEN_REQUIRED'); } finally { @@ -854,7 +854,7 @@ describe('deploy() credentials, engine and redaction', () => { const requests: ConvergeRequest[] = []; await deployWith(app, { - engine: engineWith(recordingConverge(requests)), + converge: appConverge(recordingConverge(requests)), credentials: { workspaceId: undefined, client: {}, serviceToken: 'tok-1234567890' }, }); @@ -864,17 +864,68 @@ describe('deploy() credentials, engine and redaction', () => { }); }); - test('an app with no installed @prisma/composer fails with DEPS.ENGINE_UNRESOLVABLE before any container is ensured', async () => { + test('an app with no installed @prisma/composer fails with DEPS.CONVERGE_UNRESOLVABLE before any container is ensured', async () => { const app = makeAppDir(); const containerCalls: ContainerCall[] = []; const result = await deployWith(app, {}, fakeConfig({}, { calls: containerCalls })); if (result.ok) throw new Error('expected a failure'); - expect(result.failure.code).toBe('DEPS.ENGINE_UNRESOLVABLE'); + expect(result.failure.code).toBe('DEPS.CONVERGE_UNRESOLVABLE'); expect(containerCalls).toEqual([]); }); + test("the app's install is found beside its prisma.config.ts, not in cwd", async () => { + const app = makeAppDir(); + const configDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-config-'))); + tmpDirs.push(configDir); + fs.writeFileSync(path.join(configDir, 'package.json'), JSON.stringify({ name: 'app' })); + fs.writeFileSync(path.join(configDir, 'prisma.config.ts'), 'export default {};\n'); + const pkg = path.join(configDir, 'node_modules', '@prisma', 'composer'); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ + name: '@prisma/composer', + version: HOST_COMPOSER_VERSION, + type: 'module', + exports: { './internal/converge': './converge.mjs', './package.json': './package.json' }, + }), + ); + fs.writeFileSync( + path.join(pkg, 'converge.mjs'), + "export const lower = () => ({}); export const convergeInProcess = async (request) => ({ kind: 'applied', cwd: request.cwd });", + ); + + const result = await silently(() => + deployWithDeps( + { + config: { value: fakeConfig(), file: path.join(configDir, 'prisma.config.ts') }, + entry: app.entryPath, + environment: 'ci-7', + cwd: app.dir, + }, + { runAssembler: fakeAssembler }, + ), + ); + + if (!result.ok) throw result.failure; + }); + + test('a converge that rejects instead of returning an outcome is DEPLOY.ENGINE_FAILED', async () => { + const app = makeAppDir(); + + const result = await deployWith(app, { + converge: appConverge(async () => { + throw new TypeError('outcome.reasons is not iterable'); + }), + }); + + if (result.ok) throw new Error('expected a failure'); + expect(result.failure.code).toBe('DEPLOY.ENGINE_FAILED'); + expect(result.failure.message).toContain('outcome.reasons is not iterable'); + }); + test('the token never reaches a message event, the engine failure, or a stack-building error', async () => { const app = makeAppDir(); const token = 'tok-SENTINEL-abcdef123456'; @@ -885,7 +936,7 @@ describe('deploy() credentials, engine and redaction', () => { app, { credentials, - engine: engineWith(async (request) => { + converge: appConverge(async (request) => { request.onEvent?.({ kind: 'log', severity: 'warn', text: `retrying with ${token}` }); request.onEvent?.({ kind: 'resource', @@ -906,7 +957,7 @@ describe('deploy() credentials, engine and redaction', () => { app, { credentials, - engine: { + converge: { lower: () => { throw new Error(`state store refused ${token}`); }, @@ -927,7 +978,7 @@ describe('deploy() credentials, engine and redaction', () => { test('a refused converge is DEPLOY.CONVERGE_RUNNING or DEPLOY.WORKER_THREAD', async () => { const app = makeAppDir(); const refused = (reason: 'already-running' | 'worker-thread') => - deployWith(app, { engine: engineWith(async () => ({ kind: 'refused', reason })) }); + deployWith(app, { converge: appConverge(async () => ({ kind: 'refused', reason })) }); const running = await refused('already-running'); const worker = await refused('worker-thread'); @@ -965,7 +1016,7 @@ describe('deploy() step events', () => { report?.({ bytes: 4096 }); return fakeAssembler(node); }, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -1023,7 +1074,7 @@ describe('deploy() step events', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async (request) => { + converge: appConverge(async (request) => { request.onEvent?.(resource('pending', 'pending')); request.onEvent?.(resource('creating', 'running')); request.onEvent?.({ kind: 'log', severity: 'info', text: 'uploading 3 files' }); @@ -1064,7 +1115,7 @@ describe('deploy() step events', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async (request) => { + converge: appConverge(async (request) => { request.onEvent?.({ kind: 'resource', fqn: 'site', @@ -1123,7 +1174,7 @@ describe('deploy() step events', () => { cwd: app.dir, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ).catch((error: unknown) => error), ); @@ -1156,7 +1207,7 @@ describe('deploy() step events', () => { onEvent: (event) => events.push(event), }, { - engine: engineWith(applied), + converge: appConverge(applied), runAssembler: async () => { throw new Error('no built entry'); }, @@ -1193,7 +1244,7 @@ describe('deploy() step events', () => { reportPath: path.join(app.dir, 'package.json', 'report.json'), onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ); } finally { warnSpy.mockRestore(); @@ -1217,7 +1268,7 @@ describe('deploy() step events', () => { throw new Error('renderer bug'); }, }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -1424,7 +1475,7 @@ describe('destroy()', () => { ran.push('alchemy'); return { exitCode: 0, signal: null }; }, - engine: engineWith(async () => { + converge: appConverge(async () => { ran.push('alchemy'); return APPLIED; }), @@ -2961,7 +3012,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(recordingConverge(requests)), + converge: appConverge(recordingConverge(requests)), }, ), ); @@ -2990,7 +3041,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), credentials: { workspaceId: 'ws-1', client: {}, serviceToken: 'tok' }, }, ), @@ -3029,7 +3080,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -3055,7 +3106,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(async () => ({ kind: 'interrupted' })), + converge: appConverge(async () => ({ kind: 'interrupted' })), }, ), ); @@ -3084,7 +3135,7 @@ describe('deploy-run reporting', () => { { entry: app.entryPath, environment: 'ci-6', cwd: app.dir, config: composerConfig(config) }, { runAssembler: fakeAssembler, - engine: engineWith(async () => ({ + converge: appConverge(async () => ({ kind: 'failed', reasons: [{ tag: 'PrismaApiError', message: '409 Conflict' }], })), @@ -3114,7 +3165,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -3161,7 +3212,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -3189,7 +3240,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -3538,7 +3589,7 @@ describe('the operations read the pools from the config adapters', () => { }, { runAssembler: fakeAssembler, - engine: engineWith(applied), + converge: appConverge(applied), }, ), ); @@ -3568,7 +3619,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -3634,7 +3685,7 @@ describe('the operations read the pools from the config adapters', () => { reportPath, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -3661,7 +3712,7 @@ describe('the operations read the pools from the config adapters', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, engine: engineWith(applied) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index e28f0b194..c30a43647 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -18,7 +18,7 @@ import type { import { containerEnv, preflightEnvVarName } from '@internal/core/config'; import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; -import { type DeployEngine, loadAppEngine } from '../app-engine.ts'; +import { type AppConverge, loadAppConverge } from '../app-converge.ts'; import type { ComposerConfigSource } from '../composer-config.ts'; import type { ConvergeEvent, @@ -394,7 +394,7 @@ async function runStackPipelineInner( let converge: ConvergeSetup | undefined; if (action === 'deploy') { - const setup = await prepareConverge(deps, cwd); + const setup = await prepareConverge(deps, path.dirname(path.resolve(cwd, composerConfig.file))); if (!setup.ok) return setup; converge = setup.value; } @@ -775,7 +775,7 @@ async function runStackPipelineInner( } interface ConvergeSetup { - readonly engine: DeployEngine; + readonly appConverge: AppConverge; /** The credential variables Alchemy reads, set for the length of the apply; `undefined` removes one. */ readonly credentialEnv: Readonly>; } @@ -798,28 +798,28 @@ function serviceTokenRequired(): CliStructuredError { /** * What deploy's converge needs before anything is created: the service token, - * and the app's own install of the engine. A caller that supplies credentials + * and the app's own install of the converge. A caller that supplies credentials * supplies the token with them, and the token and workspace always come from * that one credential; a caller that supplies none leaves both to the * environment. */ async function prepareConverge( deps: OperationDeps, - cwd: string, + appDir: string, ): Promise> { const credentials = deps.credentials; const token = credentials === undefined ? process.env['PRISMA_SERVICE_TOKEN'] : credentials.serviceToken; if (token === undefined || token.length === 0) return notOk(serviceTokenRequired()); - let engine: DeployEngine; + let appConverge: AppConverge; try { - engine = deps.engine ?? (await loadAppEngine(cwd)); + appConverge = deps.converge ?? (await loadAppConverge(appDir)); } catch (error) { if (CliStructuredError.is(error)) return notOk(error); throw error; } return ok({ - engine, + appConverge, credentialEnv: credentials === undefined ? {} @@ -916,6 +916,10 @@ function convergeFailure( ); } } + return new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + 'The converge ended with an outcome this Composer does not know.', + ); } /** @@ -951,7 +955,7 @@ async function convergeDeploy( steps.start({ name: 'apply' }); let stack: LoweredStack; try { - stack = setup.engine.lower(root, config, { + stack = setup.appConverge.lower(root, config, { name: pipeline.name, environment, configPool: pipeline.pools.config, @@ -971,15 +975,27 @@ async function convergeDeploy( ), ); } - const outcome = await setup.engine.converge({ - operation: 'deploy', - stack, - stage: resolved.stage, - cwd, - env, - signal: opts.signal, - onEvent: (event) => reportConvergeEvent(steps, event, redact), - }); + let outcome: ConvergeOutcome; + try { + outcome = await setup.appConverge.converge({ + operation: 'deploy', + stack, + stage: resolved.stage, + cwd, + env, + signal: opts.signal, + onEvent: (event) => reportConvergeEvent(steps, event, redact), + }); + } catch (error) { + return notOk( + new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + redact( + `Alchemy could not deploy the app.\n${error instanceof Error ? error.message : String(error)}`, + ), + ), + ); + } if (outcome.kind !== 'applied') return notOk(convergeFailure(outcome, redact)); steps.finish({ name: 'apply' }); return ok(summary); diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index f1e86baef..2b9ffbb9b 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -12,7 +12,7 @@ import type { } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { CliStructuredError } from '@internal/foundation/errors'; -import type { DeployEngine } from '../app-engine.ts'; +import type { AppConverge } from '../app-converge.ts'; import type { startWatch } from '../dev/watch.ts'; import type { RunAlchemy } from '../run-alchemy.ts'; @@ -45,8 +45,8 @@ export interface OperationDeps { * natively and the engine owns signal policy; hosts get the default `spawnAlchemy`. */ readonly alchemy?: RunAlchemy | undefined; - /** Lowers and converges `deploy`'s stack; defaults to the app's own install (`loadAppEngine`). Tests substitute it. */ - readonly engine?: DeployEngine | undefined; + /** Lowers and converges `deploy`'s stack; defaults to the app's own install (`loadAppConverge`). Tests substitute it. */ + readonly converge?: AppConverge | undefined; /** Starts a file watch for `dev`; defaults to chokidar-backed `startWatch`. Tests substitute it to drive changes directly. */ readonly watch?: typeof startWatch | undefined; /** From 38e2d2e5670dfd0c7e083e99a61b6e176bd33703 Mon Sep 17 00:00:00 2001 From: willbot Date: Sat, 31 Oct 2026 11:12:28 +0100 Subject: [PATCH 04/14] Check a converge contract, not the package version; keep the outcome on a failed chdir - @prisma/composer/internal/converge-contract exports CONVERGE_CONTRACT; loadAppConverge reads it before loading the converge and refuses a different number with DEPS.CONVERGE_CONTRACT_MISMATCH, naming both contracts and both versions and saying which side to upgrade. Package versions may differ. - A failed chdir back to a removed directory no longer replaces the converge outcome. - ADR-0064, ADR-0044, the ADR index, deploy-cli.md, the glossary and the deploying guide follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- architecture.config.json | 6 ++ docs/design/03-domain-model/glossary.md | 2 +- docs/design/10-domains/deploy-cli.md | 2 +- ...l-envelopes-with-dotted-namespace-codes.md | 6 +- ...ADR-0064-deploy-runs-alchemy-in-process.md | 6 +- docs/design/90-decisions/README.md | 4 +- docs/guides/deploying.md | 2 +- .../0-framework/3-tooling/cli/package.json | 1 + .../cli/src/__tests__/app-converge.test.ts | 73 ++++++++++++---- .../src/__tests__/converge-in-process.test.ts | 4 +- .../3-tooling/cli/src/app-converge.ts | 84 ++++++++++++------- .../3-tooling/cli/src/converge-contract.ts | 9 ++ .../3-tooling/cli/src/converge-in-process.ts | 11 ++- .../cli/src/exports/converge-contract.ts | 2 + .../operations/__tests__/operations.test.ts | 11 ++- .../3-tooling/cli/tsdown.config.ts | 1 + packages/9-public/composer/package.json | 1 + .../src/exports/internal-converge-contract.ts | 1 + packages/9-public/composer/tsdown.config.ts | 1 + tsconfig.depcruise.json | 6 ++ 20 files changed, 173 insertions(+), 60 deletions(-) create mode 100644 packages/0-framework/3-tooling/cli/src/converge-contract.ts create mode 100644 packages/0-framework/3-tooling/cli/src/exports/converge-contract.ts create mode 100644 packages/9-public/composer/src/exports/internal-converge-contract.ts diff --git a/architecture.config.json b/architecture.config.json index 6870cb71c..790338b69 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -768,6 +768,12 @@ "layer": "public", "plane": "control" }, + { + "glob": "packages/9-public/composer/src/exports/internal-converge-contract.ts", + "domain": "public", + "layer": "public", + "plane": "control" + }, { "glob": "packages/9-public/composer-cli/src/exports/family.ts", "domain": "public", diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 6a6cecef6..7370245c6 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -421,7 +421,7 @@ to a plan. ### Converge -Making an environment match the lowered stack: Alchemy plans what to create, update, replace and delete, then applies it. `prisma deploy` converges in its own process through Alchemy's programmatic API; `prisma dev` and `destroy` converge in an Alchemy child process. The *app converge* is the app install's `lower` and in-process converge, which `deploy` loads from `@prisma/composer/internal/converge` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). Codes: `DEV.CONVERGE_FAILED`, `DEPLOY.CONVERGE_RUNNING`, `DEPS.CONVERGE_UNRESOLVABLE`, `DEPS.CONVERGE_VERSION_MISMATCH`. +Making an environment match the lowered stack: Alchemy plans what to create, update, replace and delete, then applies it. `prisma deploy` converges in its own process through Alchemy's programmatic API; `prisma dev` and `destroy` converge in an Alchemy child process. The *app converge* is the app install's `lower` and in-process converge, which `deploy` loads from `@prisma/composer/internal/converge` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). Codes: `DEV.CONVERGE_FAILED`, `DEPLOY.CONVERGE_RUNNING`, `DEPS.CONVERGE_UNRESOLVABLE`, `DEPS.CONVERGE_CONTRACT_MISMATCH`. ### Control plane / Execution plane diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 3d1e870d5..52abc1c48 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -96,7 +96,7 @@ that relative imports inside the entry graph may use `./x.js` or extensionless bun APIs can only deploy under bun, since loading the graph imports that module — the app's choice, not a CLI limit. -**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`) ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). It loads core's `lower()` and the converge from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, so they use the app's own install of `alchemy` and `effect`, the same one its extensions and providers come from. The `prisma` host's Composer calls that install, so the two must be the same version: a different version fails with `DEPS.CONVERGE_VERSION_MISMATCH`, naming both. An app whose `@prisma/composer` cannot be resolved, or predates that entry, fails with `DEPS.CONVERGE_UNRESOLVABLE`. Both fail before anything is created. Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. +**Where Alchemy runs.** `prisma deploy` runs Alchemy in the `prisma` process, through Alchemy's programmatic API (`alchemy/Alchemist`) ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). It loads core's `lower()` and the converge from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, so they use the app's own install of `alchemy` and `effect`, the same one its extensions and providers come from. The `prisma` host's Composer calls that install, so the two must speak the same converge contract, a number the install exports from `@prisma/composer/internal/converge-contract`; their package versions may differ. A different contract fails with `DEPS.CONVERGE_CONTRACT_MISMATCH`, naming both contracts and both versions and saying which side to upgrade. An app whose `@prisma/composer` cannot be resolved, or predates that entry, fails with `DEPS.CONVERGE_UNRESOLVABLE`. Both fail before anything is created. Alchemy and `effect` load only behind the deploy operation's lazy executor import, so `prisma --version` never loads them. **The Alchemy child's runtime (`destroy` and `prisma dev`).** These still start an Alchemy child. Composer runs the `bin` of the `alchemy` package that the app's `@prisma/composer` depends on, found from the app diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index 4844e1b57..0b564b863 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -1,6 +1,6 @@ # ADR-0044: Errors are structural envelopes with dotted namespace codes -Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_VERSION_MISMATCH` are added. +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added. An interrupted `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message and ends with the engine's `CLI.ABORTED`, exiting 130 (143 for SIGTERM). Amended 2026-10-23: the `CONFIG` registry gains ENTRY_MISSING, for a command or operation that has no entry module from the config or an override. @@ -115,12 +115,12 @@ Closed subcode registry: raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. - `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. - `LOG` — PLATFORM_UNSUPPORTED, ATTACH_FAILED, ADDRESS_UNKNOWN -- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE, CONVERGE_UNRESOLVABLE, CONVERGE_VERSION_MISMATCH *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. +- `DEPS` — EXECUTOR_UNLOADABLE, EXTENSION_INCOMPATIBLE, CONVERGE_UNRESOLVABLE, CONVERGE_CONTRACT_MISMATCH *(EFFECT_VERSION_CONFLICT retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. EXTENSION_INCOMPATIBLE: an installed extension is older than the Composer running the deploy or destroy, so the container it resolved, rebuilt through its own `deserialize`, does not name the environment that was asked for. CONVERGE_UNRESOLVABLE: `deploy` cannot load `@prisma/composer/internal/converge` from the app's own install, or that install predates it ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). - CONVERGE_VERSION_MISMATCH: the app's `@prisma/composer` is not the Composer version the `prisma` CLI runs; the message names both. + CONVERGE_CONTRACT_MISMATCH: the app's `@prisma/composer` speaks another converge contract than the `prisma` CLI's Composer; the message names both contract numbers and both package versions, and the fix says which side to upgrade. Different package versions with the same contract are accepted. Adding a subcode is an edit to this list; adding a namespace is an amendment to this ADR. diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index 110d9f6e9..06c7fa144 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -18,8 +18,8 @@ yield* Alchemist.apply(plan); This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. -- **The app's install runs the converge.** The executor loads `lower` and `convergeInProcess` (together, the app converge) from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from that install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the converge from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entry fails with `DEPS.CONVERGE_UNRESOLVABLE` before anything is created. -- **Host and app run one Composer version.** The host's operation code calls the app's `lower` and converge and reads back their results, so the two must agree on every shape between them. Composer's packages release in lockstep, so the host compares the app's `@prisma/composer` version with its own and refuses any difference with `DEPS.CONVERGE_VERSION_MISMATCH`, naming both versions. An exact match is simpler than a separate contract number, and lockstep releases make it cheap to meet. +- **The app's install runs the converge.** The executor loads `lower` and `convergeInProcess` (together, the app converge) from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from that install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the converge from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entries fails with `DEPS.CONVERGE_UNRESOLVABLE` before anything is created. +- **Host and app speak one converge contract.** The host's operation code calls the app's `lower` and converge and reads back their results, so both sides must agree on the shapes between them: `LowerOptions`, `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and `DeploymentResult`. They need not run the same package version. The `prisma` host pins its Composer and ships on its own schedule (ADR-0050), and create-prisma pins the app's, so the two versions differ between releases, when an app keeps an older Composer, and on dev builds; an exact version match would refuse every deploy in those cases. So the app's install exports a `CONVERGE_CONTRACT` integer from `@prisma/composer/internal/converge-contract`, an entry that imports nothing. The host reads it before loading anything else and refuses a different number with `DEPS.CONVERGE_CONTRACT_MISMATCH`, which names both contract numbers and both package versions and says which side to upgrade. The number is bumped only when one of those shapes changes in a way the other side cannot read. - **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as required values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Only the generated stack files for `destroy` and `prisma dev` read the environment transports, through one core helper, `transportedLowerInputs(config)`. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. - **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. @@ -38,7 +38,7 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy ## Consequences - `deploy` no longer resolves Alchemy's bin or needs Node on PATH under Bun. `DEPLOY.ALCHEMY_BIN_MISSING`, `DEPLOY.NODE_MISSING` and `DEPLOY.STACK_WRITE_FAILED` remain for `destroy` and `prisma dev`. -- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_VERSION_MISMATCH`. +- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH`. - The converge relies on two Alchemy modules outside `alchemy/Alchemist`: `alchemy/Util/LogRecord` (to read log records) and `alchemy/Interaction` (to send interaction output to stderr). - `prisma deploy` declares `needs: { credentials: true }` and no longer `maySpawn`. - Setting `process.env` and the working directory for the length of the apply is process-wide state, so one process runs one converge at a time. The upstream asks below remove the need. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 87a0b2829..f7b312683 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -65,7 +65,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [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 the Postgres emulator hosts one `@prisma/dev` server per database instance. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`OrmMigration` 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). *(Amended by ADR-0050: the command is `prisma dev`; logs are the `log` operation, not a command. Amended 2026-10-23: the entry is the `composer` section's `entry` or `--entry`, not a positional. Amended by ADR-0052: emulator instances are keyed by emulator-generated ids, not the app name, Composer maps each app's nodes to them per directory, and `prisma dev` stops the app's database servers when it ends.)* *(Amended by ADR-0056: a service runs the dev server its build adapter declares in place of its artifact, as a command deployment on the Compute emulator.)* *(Amended by ADR-0062: local dev runs as a named environment, `dev` by default.)* - [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. *(Extended by ADR-0061 to modules.)* *(Amended 2026-10-30: the forwardable secret need is removed; a module takes a secret as an input field.)* *(Amended 2026-10-30, sixth slice: `field()` and `secretString()` ship from `@prisma/composer/arktype` and `@prisma/composer/zod`; `secretString()` accepts a secret pointer.)* *(Amended 2026-10-30, seventh slice: the deploy checks a service's schema with pointers in place; a field bound to `serviceOrigin()` or a plain `generatedParam()` is wrapped with `field()`; `service.input()` has no pointers.)* - [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. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* *(Amended by ADR-0053: `devUrl` and `devStop` join the operations.)* *(Amended by ADR-0059: `deploy` and `destroy` take `environment`, which `destroy` requires.)* *(Amended 2026-10-28: `devUrl` and `devStop` act on the app and environment in their input; `dev` and `log` take `environment`.)* *(Amended by ADR-0064: `deploy` returns the summary as a value; it uses no result file.)* -- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* *(Amended by ADR-0064: the child exit-status passthrough no longer applies to `deploy`; `DEPLOY.INTERRUPTED` and `DEPLOY.SERVICE_TOKEN_REQUIRED` are added.)* +- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* *(Amended by ADR-0064: the child exit-status passthrough no longer applies to `deploy`, whose failures exit 2; `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added; an interrupted `prisma deploy` ends as `CLI.ABORTED` with exit 130.)* - [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 ORM 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. @@ -85,4 +85,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its configuration as one Standard Schema, `input`, exactly as a compute service does; its body receives opaque per-key references it can only pass into children's inputs or derive from with `resolve()`, so the graph cannot branch on configuration. Module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, filled by config adapters. *(Amended 2026-10-29: an input field is a `Value`; the module's schema validates its binding during Load's second pass; a truthiness check on a field is not caught, but always goes the same way.)* *(Amended 2026-10-29, third slice: `paramSource()` and `secretSource()` return values and take an options argument.)* *(Amended 2026-10-30: a root's input is the config pool.)* *(Amended 2026-10-30, fifth slice: the slots are removed; `ModuleNode`.)* *(Amended 2026-10-30, seventh slice: a module's schema checks each runtime value as its pointer; `paramSource()` loses `sentinel`.)* *(Amended 2026-10-30, eighth slice: only a plain field can be a `resolve()` dependency.)* - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. *(Amended by ADR-0064: `deploy` runs Alchemy in its first process and writes no configuration file; the second process applies to `prisma dev` and `destroy`.)* - [ADR-0063](ADR-0063-a-node-depends-on-an-interface-or-exposes-one.md) — The two ends of a connection are named by what the node does with them: a node **depends on** an interface (`deps`) or **exposes** one (`expose`); an interface is a `Contract` in code; Input and Output are retired as port words, because the dataflow and ports-and-adapters traditions give the same two ends opposite names; the module body's `inputs` becomes `deps`; the topology's `in`/`out` stay, defined as the dependency end and the exposed end. -- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`, which must be the host's Composer version. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`, which must speak the host's converge contract. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 1f5f634b6..ae8563e09 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -742,7 +742,7 @@ What to know before embedding it: a step fails, it and the steps around it finish as `failed`; `record` still runs. An `onEvent` that throws does not fail the deploy. A successful result's `durationMs` is the whole deploy's total. -- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env` and changes to `cwd` when your process is elsewhere, then restores both, so a process runs one deploy at a time (`DEPLOY.CONVERGE_RUNNING`), and from a worker thread `cwd` must be the process's working directory (`DEPLOY.WORKER_THREAD`). Deploy uses the lowering and the converge from the app's own install of `@prisma/composer`, found beside its `prisma.config.ts`, which must be the same version as the Composer running the deploy (`DEPS.CONVERGE_VERSION_MISMATCH` otherwise). Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. +- **`deploy` runs the engine in your process.** It returns `summary` as a value and reports the engine's progress through `onEvent`; it prints nothing. While it plans and applies it sets the service token and workspace id in `process.env` and changes to `cwd` when your process is elsewhere, then restores both, so a process runs one deploy at a time (`DEPLOY.CONVERGE_RUNNING`), and from a worker thread `cwd` must be the process's working directory (`DEPLOY.WORKER_THREAD`). Deploy uses the lowering and the converge from the app's own install of `@prisma/composer`, found beside its `prisma.config.ts`, which must speak the same converge contract as the Composer running the deploy (`DEPS.CONVERGE_CONTRACT_MISMATCH` otherwise); the package versions may differ. Pass `signal` to interrupt it. `destroy` still runs the engine as a child process whose output streams to your process's stdio. - **`dev` resolves to `{ ok: true, value: session }` or a failure** — never an exit code. The session is `{ endpoints, stop(), closed }`, with progress (`ready`, `converge-failed`, `config-changed`, `watch-error`, …) delivered through diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 61fb0b979..7444e27e1 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -8,6 +8,7 @@ "./config": "./dist/config.mjs", "./control": "./dist/control.mjs", "./converge": "./dist/converge.mjs", + "./converge-contract": "./dist/converge-contract.mjs", "./family": "./dist/family.mjs", "./pools": "./dist/pools.mjs", "./report": "./dist/report.mjs", diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts index 8b18366b3..44726197e 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import { CliStructuredError } from '@internal/foundation/errors'; import { HOST_COMPOSER_VERSION, loadAppConverge } from '../app-converge.ts'; +import { CONVERGE_CONTRACT } from '../converge-contract.ts'; const dirs: string[] = []; afterEach(() => { @@ -14,7 +15,13 @@ afterEach(() => { /** An app whose node_modules holds a @prisma/composer with the given exports and entry source. */ function appWith( composer: - | { exports: Record; files: Record; version?: string } + | { + exports: Record; + files: Record; + version?: string; + /** The converge contract the install speaks; null for an install with no contract entry. */ + contract?: number | null; + } | undefined, ) { const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-app-converge-'))); @@ -29,9 +36,21 @@ function appWith( name: '@prisma/composer', version: composer.version ?? HOST_COMPOSER_VERSION, type: 'module', - exports: { ...composer.exports, './package.json': './package.json' }, + exports: { + ...composer.exports, + ...(composer.contract === null + ? {} + : { './internal/converge-contract': './dist/contract.mjs' }), + './package.json': './package.json', + }, }), ); + if (composer.contract !== null) { + fs.writeFileSync( + path.join(pkg, 'dist', 'contract.mjs'), + `export const CONVERGE_CONTRACT = ${String(composer.contract ?? CONVERGE_CONTRACT)};`, + ); + } for (const [file, source] of Object.entries(composer.files)) { fs.writeFileSync(path.join(pkg, file), source); } @@ -94,21 +113,45 @@ describe('loadAppConverge', () => { expect(failure.message).toContain('exports no lower and convergeInProcess'); }); - test("an app's @prisma/composer at another version is DEPS.CONVERGE_VERSION_MISMATCH, naming both versions", async () => { + const entry = { + exports: { './internal/converge': './dist/internal-converge.mjs' }, + files: { + 'dist/internal-converge.mjs': + "export const lower = () => {}; export const convergeInProcess = async () => ({ kind: 'applied' });", + }, + }; + + test("an app's @prisma/composer at another version that speaks the same contract loads", async () => { + const engine = await loadAppConverge(appWith({ ...entry, version: '0.0.1' })); + + expect(typeof engine.converge).toBe('function'); + }); + + test('an older contract is DEPS.CONVERGE_CONTRACT_MISMATCH, naming both contracts and versions, and says to upgrade the app', async () => { const failure = await failureOf( - appWith({ - version: '0.0.1', - exports: { './internal/converge': './dist/internal-converge.mjs' }, - files: { - 'dist/internal-converge.mjs': - 'export const lower = () => {}; export const convergeInProcess = async () => ({});', - }, - }), + appWith({ ...entry, version: '0.0.1', contract: CONVERGE_CONTRACT - 1 }), ); - expect(failure.code).toBe('DEPS.CONVERGE_VERSION_MISMATCH'); - expect(failure.message).toContain('0.0.1'); - expect(failure.message).toContain(HOST_COMPOSER_VERSION); - expect(failure.fix).toContain(`Install @prisma/composer ${HOST_COMPOSER_VERSION}`); + expect(failure.code).toBe('DEPS.CONVERGE_CONTRACT_MISMATCH'); + expect(failure.message).toBe( + `The app's @prisma/composer 0.0.1 speaks converge contract ${String(CONVERGE_CONTRACT - 1)}, and the \`prisma\` CLI's Composer ${HOST_COMPOSER_VERSION} speaks contract ${String(CONVERGE_CONTRACT)}.`, + ); + expect(failure.fix).toStartWith("Upgrade the app's @prisma/composer"); + }); + + test('a newer contract says to upgrade the prisma CLI', async () => { + const failure = await failureOf( + appWith({ ...entry, version: '9.0.0', contract: CONVERGE_CONTRACT + 1 }), + ); + + expect(failure.code).toBe('DEPS.CONVERGE_CONTRACT_MISMATCH'); + expect(failure.fix).toStartWith('Upgrade the `prisma` CLI'); + expect(failure.fix).toContain('9.0.0'); + }); + + test('an install with the converge but no contract entry is DEPS.CONVERGE_UNRESOLVABLE', async () => { + const failure = await failureOf(appWith({ ...entry, contract: null })); + + expect(failure.code).toBe('DEPS.CONVERGE_UNRESOLVABLE'); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts index dad81f0ed..8c0e0a8d5 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -254,7 +254,7 @@ describe('convergeInProcess', () => { expect(second).toEqual({ kind: 'refused', reason: 'already-running' }); }); - test('restores the environment even when the working directory cannot be restored', async () => { + test('restores the environment and keeps the outcome when the working directory cannot be restored', async () => { const before = process.cwd(); const doomed = tmpDir(); process.chdir(doomed); @@ -274,7 +274,7 @@ describe('convergeInProcess', () => { request(config, {}, { cwd, env: { COMPOSER_TEST_TOKEN: 'tok' } }), ); - expect(outcome.kind).toBe('failed'); + expect(outcome).toEqual({ kind: 'applied' }); expect(process.env['COMPOSER_TEST_TOKEN']).toBeUndefined(); } finally { process.chdir(before); diff --git a/packages/0-framework/3-tooling/cli/src/app-converge.ts b/packages/0-framework/3-tooling/cli/src/app-converge.ts index 49924a46d..ae1a46ece 100644 --- a/packages/0-framework/3-tooling/cli/src/app-converge.ts +++ b/packages/0-framework/3-tooling/cli/src/app-converge.ts @@ -4,8 +4,9 @@ * `prisma.config.ts`, the file its extensions are imported from. The stack, * Alchemy and the providers then share one copy of `alchemy` and `effect` * even when the `prisma` host pins another Composer (ADR-0050, ADR-0064). - * The host calls that copy across a version boundary, and Composer's - * packages release in lockstep, so the two versions must be equal. + * The host calls that copy across a version boundary, so the two must speak + * the same converge contract (`CONVERGE_CONTRACT`); their package versions + * may differ. */ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; @@ -15,9 +16,11 @@ import type { lower } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; import { CliStructuredError } from '@internal/foundation/errors'; import manifest from '../package.json' with { type: 'json' }; +import { CONVERGE_CONTRACT } from './converge-contract.ts'; import type { Converge } from './converge-in-process.ts'; const ENTRY = '@prisma/composer/internal/converge'; +const CONTRACT_ENTRY = '@prisma/composer/internal/converge-contract'; /** The Composer version this host runs; every Composer package carries the same one. */ export const HOST_COMPOSER_VERSION: string = manifest.version; @@ -56,50 +59,73 @@ function installedVersion(require: NodeJS.Require): string | undefined { } } +async function importFile(appDir: string, file: string): Promise> { + try { + return await import(pathToFileURL(file).href); + } catch (error) { + throw unresolvable( + appDir, + `${file} failed to load: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } +} + +function contractMismatch( + host: { readonly contract: number; readonly version: string }, + app: { readonly contract: number; readonly version: string | undefined }, +): CliStructuredError { + const appVersion = app.version ?? 'an unknown version'; + const update = + app.contract < host.contract + ? `Upgrade the app's @prisma/composer to a release that speaks converge contract ${host.contract}, such as ${host.version}.` + : `Upgrade the \`prisma\` CLI to one that runs a Composer speaking converge contract ${app.contract}, such as ${appVersion}.`; + return new CliStructuredError( + 'DEPS.CONVERGE_CONTRACT_MISMATCH', + `The app's @prisma/composer ${appVersion} speaks converge contract ${app.contract}, and the \`prisma\` CLI's Composer ${host.version} speaks contract ${host.contract}.`, + { + why: "Deploy calls the app's @prisma/composer from the CLI's Composer, and the two read each other's data only when they speak the same converge contract.", + fix: update, + }, + ); +} + /** - * The app's converge, from the install `appDir` resolves. Raises - * DEPS.CONVERGE_UNRESOLVABLE when it is missing or predates the in-process - * converge, and DEPS.CONVERGE_VERSION_MISMATCH when its version is not - * `hostVersion`. + * The app's converge, from the install `appDir` resolves. Reads the app's + * converge contract before loading anything else. Raises + * DEPS.CONVERGE_UNRESOLVABLE when the install is missing or predates the + * in-process converge, and DEPS.CONVERGE_CONTRACT_MISMATCH when it speaks + * another contract than `host`. */ export async function loadAppConverge( appDir: string, - hostVersion: string = HOST_COMPOSER_VERSION, + host: { readonly contract: number; readonly version: string } = { + contract: CONVERGE_CONTRACT, + version: HOST_COMPOSER_VERSION, + }, ): Promise { const require = createRequire(path.join(appDir, 'package.json')); + let contractFile: string; let file: string; try { + contractFile = require.resolve(CONTRACT_ENTRY); file = require.resolve(ENTRY); } catch (error) { - throw unresolvable(appDir, `${ENTRY} does not resolve.`, error); + throw unresolvable(appDir, `${CONTRACT_ENTRY} or ${ENTRY} does not resolve.`, error); } - const appVersion = installedVersion(require); - if (appVersion !== hostVersion) { - throw new CliStructuredError( - 'DEPS.CONVERGE_VERSION_MISMATCH', - `The app's @prisma/composer is ${appVersion ?? 'of an unknown version'}, and the \`prisma\` CLI runs Composer ${hostVersion}.`, - { - why: "Deploy calls the app's @prisma/composer from the CLI's Composer, and Composer's packages work together only at one version.", - fix: `Install @prisma/composer ${hostVersion} in the app, or a \`prisma\` CLI that runs Composer ${appVersion ?? "the app's version"}, so the two versions match.`, - }, - ); + const { CONVERGE_CONTRACT: appContract } = await importFile(appDir, contractFile); + if (typeof appContract !== 'number') { + throw unresolvable(appDir, `${contractFile} exports no CONVERGE_CONTRACT.`); } - let loaded: Record; - try { - loaded = await import(pathToFileURL(file).href); - } catch (error) { - throw unresolvable( - appDir, - `${file} failed to load: ${error instanceof Error ? error.message : String(error)}`, - error, - ); + if (appContract !== host.contract) { + throw contractMismatch(host, { contract: appContract, version: installedVersion(require) }); } - const { lower: appLower, convergeInProcess } = loaded; + const { lower: appLower, convergeInProcess } = await importFile(appDir, file); if (typeof appLower !== 'function' || typeof convergeInProcess !== 'function') { throw unresolvable(appDir, `${file} exports no lower and convergeInProcess.`); } return blindCast< AppConverge, - "the typeof checks above prove both are functions, and the version check proves the entry is this package's own src/exports/converge.ts at this version" + "the typeof checks above prove both are functions, and the contract check proves the entry speaks this host's converge contract" >({ lower: appLower, converge: convergeInProcess }); } diff --git a/packages/0-framework/3-tooling/cli/src/converge-contract.ts b/packages/0-framework/3-tooling/cli/src/converge-contract.ts new file mode 100644 index 000000000..215b1c513 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/converge-contract.ts @@ -0,0 +1,9 @@ +/** + * The version of the shapes the `prisma` host and the app's + * `@prisma/composer` pass each other through `internal/converge`: + * `LowerOptions`, `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and + * `DeploymentResult`. Bump it only when one of them changes in a way the + * other side cannot read. Imports nothing, so a host can read it before + * loading the converge. + */ +export const CONVERGE_CONTRACT = 1; diff --git a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts index 94219e47c..858ce1c89 100644 --- a/packages/0-framework/3-tooling/cli/src/converge-in-process.ts +++ b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts @@ -161,7 +161,8 @@ const flag = blindCast< /** * Sets the process state Alchemy reads for the length of `run`, and restores * it on every exit path: the variables in `env`, then the working directory. - * A directory that cannot be restored never leaves a variable behind. + * A directory that cannot be restored never leaves a variable behind and + * never changes what `run` returned. */ async function withProcessState( env: Readonly>, @@ -186,7 +187,13 @@ async function withProcessState( if (value === undefined) delete process.env[name]; else process.env[name] = value; } - if (changedDirectory) process.chdir(previousCwd); + if (changedDirectory) { + try { + process.chdir(previousCwd); + } catch { + // The old directory is gone, so there is nowhere to return to; the outcome stands. + } + } } } diff --git a/packages/0-framework/3-tooling/cli/src/exports/converge-contract.ts b/packages/0-framework/3-tooling/cli/src/exports/converge-contract.ts new file mode 100644 index 000000000..d45342c54 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/exports/converge-contract.ts @@ -0,0 +1,2 @@ +/** The host-to-app converge contract number. Not public API: published as `@prisma/composer/internal/converge-contract`. */ +export { CONVERGE_CONTRACT } from '../converge-contract.ts'; diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 5ac3e449f..c26b04f29 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -31,6 +31,7 @@ import * as Layer from 'effect/Layer'; import { type AppConverge, HOST_COMPOSER_VERSION } from '../../app-converge.ts'; import type { ComposerConfigSource } from '../../composer-config.ts'; import { dotenv } from '../../config-adapters.ts'; +import { CONVERGE_CONTRACT } from '../../converge-contract.ts'; import type { Converge, ConvergeOutcome, ConvergeRequest } from '../../converge-in-process.ts'; import { DEPLOYMENT_RESULT_FILE_ENV, engineFailureFilePath } from '../../deployment-summary.ts'; import type { AppIdentity } from '../../pipeline.ts'; @@ -889,9 +890,17 @@ describe('deploy() credentials, app converge and redaction', () => { name: '@prisma/composer', version: HOST_COMPOSER_VERSION, type: 'module', - exports: { './internal/converge': './converge.mjs', './package.json': './package.json' }, + exports: { + './internal/converge': './converge.mjs', + './internal/converge-contract': './contract.mjs', + './package.json': './package.json', + }, }), ); + fs.writeFileSync( + path.join(pkg, 'contract.mjs'), + `export const CONVERGE_CONTRACT = ${String(CONVERGE_CONTRACT)};`, + ); fs.writeFileSync( path.join(pkg, 'converge.mjs'), "export const lower = () => ({}); export const convergeInProcess = async (request) => ({ kind: 'applied', cwd: request.cwd });", diff --git a/packages/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index 3bb07ced3..4006c4e67 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ pools: 'src/exports/pools.ts', control: 'src/exports/control.ts', converge: 'src/exports/converge.ts', + 'converge-contract': 'src/exports/converge-contract.ts', family: 'src/exports/family.ts', testing: 'src/exports/testing.ts', }, diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index ac62ef0a5..af6005828 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -8,6 +8,7 @@ "./config": "./dist/config.mjs", "./internal/pools": "./dist/internal-pools.mjs", "./internal/converge": "./dist/internal-converge.mjs", + "./internal/converge-contract": "./dist/internal-converge-contract.mjs", "./control": "./dist/control.mjs", "./deploy": "./dist/deploy.mjs", "./local-target": "./dist/local-target.mjs", diff --git a/packages/9-public/composer/src/exports/internal-converge-contract.ts b/packages/9-public/composer/src/exports/internal-converge-contract.ts new file mode 100644 index 000000000..7632e597c --- /dev/null +++ b/packages/9-public/composer/src/exports/internal-converge-contract.ts @@ -0,0 +1 @@ +export * from '@internal/cli/converge-contract'; diff --git a/packages/9-public/composer/tsdown.config.ts b/packages/9-public/composer/tsdown.config.ts index de308b967..831f12799 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ config: 'src/exports/config.ts', 'internal-pools': 'src/exports/internal-pools.ts', 'internal-converge': 'src/exports/internal-converge.ts', + 'internal-converge-contract': 'src/exports/internal-converge-contract.ts', control: 'src/exports/control.ts', deploy: 'src/exports/deploy.ts', 'local-target': 'src/exports/local-target.ts', diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json index 6f15c569c..d7d907eb5 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -61,6 +61,9 @@ "@internal/cli/config": ["./packages/0-framework/3-tooling/cli/src/exports/config.ts"], "@internal/cli/pools": ["./packages/0-framework/3-tooling/cli/src/exports/pools.ts"], "@internal/cli/converge": ["./packages/0-framework/3-tooling/cli/src/exports/converge.ts"], + "@internal/cli/converge-contract": [ + "./packages/0-framework/3-tooling/cli/src/exports/converge-contract.ts" + ], "@internal/cli/control": ["./packages/0-framework/3-tooling/cli/src/exports/control.ts"], "@internal/cli/family": ["./packages/0-framework/3-tooling/cli/src/exports/family.ts"], "@internal/cli/testing": ["./packages/0-framework/3-tooling/cli/src/exports/testing.ts"], @@ -153,6 +156,9 @@ "@prisma/composer/internal/converge": [ "./packages/9-public/composer/src/exports/internal-converge.ts" ], + "@prisma/composer/internal/converge-contract": [ + "./packages/9-public/composer/src/exports/internal-converge-contract.ts" + ], "@prisma/composer/deploy": ["./packages/9-public/composer/src/exports/deploy.ts"], "@prisma/composer/local-target": ["./packages/9-public/composer/src/exports/local-target.ts"], "@prisma/composer/testing": ["./packages/9-public/composer/src/exports/testing.ts"], From 5fb814e72557333c0368027611ba5eda6edd362d Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:21:44 +0100 Subject: [PATCH 05/14] build(deps): pin the alchemy and distilled previews that make the Prisma token optional alchemy-run/alchemy#3101 and alchemy-run/distilled#1076 let PrismaEnvironment and Distilled Credentials omit the service token, so an HttpClient can authenticate requests itself. Pin alchemy, @alchemy.run/prisma and @distilled.cloud/prisma to the pr:3101:ab55f95 previews, and move effect and its companions to 4.1.2, the floor of that Distilled build's effect peer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- pnpm-lock.yaml | 468 ++++++++++-------- pnpm-workspace.yaml | 14 +- .../upgrade-alchemy-effect/SKILL.md | 4 +- 3 files changed, 260 insertions(+), 226 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d34bc29fd..624671080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,26 +7,26 @@ settings: catalogs: default: '@alchemy.run/prisma': - specifier: 2.0.0-beta.85 + specifier: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95 version: 2.0.0-beta.85 '@distilled.cloud/prisma': - specifier: 1.0.0-rc.18 + specifier: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz version: 1.0.0-rc.18 '@effect/platform-bun': - specifier: 4.1.0 - version: 4.1.0 + specifier: 4.1.2 + version: 4.1.2 '@effect/platform-node': - specifier: 4.1.0 - version: 4.1.0 + specifier: 4.1.2 + version: 4.1.2 '@effect/platform-node-shared': - specifier: 4.1.0 - version: 4.1.0 + specifier: 4.1.2 + version: 4.1.2 alchemy: - specifier: 2.0.0-beta.85 + specifier: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95 version: 2.0.0-beta.85 effect: - specifier: 4.1.0 - version: 4.1.0 + specifier: 4.1.2 + version: 4.1.2 prisma: specifier: 8.0.0-rc.26 version: 8.0.0-rc.26 @@ -123,10 +123,10 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -142,7 +142,7 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0) + version: 4.1.2(effect@4.1.2) '@prisma/composer': specifier: workspace:0.36.0 version: link:../../packages/9-public/composer @@ -151,13 +151,13 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@prisma/composer-cli': specifier: workspace:* @@ -256,7 +256,7 @@ importers: version: 8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 pg: specifier: 8.22.0 version: 8.22.0 @@ -290,10 +290,10 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@prisma/composer-cli': specifier: workspace:* @@ -312,7 +312,7 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0) + version: 4.1.2(effect@4.1.2) '@prisma/composer': specifier: workspace:0.36.0 version: link:../../packages/9-public/composer @@ -336,13 +336,13 @@ importers: version: link:modules/storefront alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -485,7 +485,7 @@ importers: dependencies: '@effect/platform-bun': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0) + version: 4.1.2(effect@4.1.2) '@prisma/composer': specifier: workspace:0.36.0 version: link:../../packages/9-public/composer @@ -500,13 +500,13 @@ importers: version: link:modules/storefront alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@prisma/composer-cli': specifier: workspace:* @@ -648,10 +648,10 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@internal/tsdown-config': specifier: workspace:0.36.0 @@ -850,7 +850,7 @@ importers: devDependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/tsdown-config': specifier: workspace:0.36.0 version: link:../../0-foundation/tsdown-config @@ -865,10 +865,10 @@ importers: version: 26.1.1 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 tsdown: specifier: ^0.22.7 version: 0.22.12(typescript@6.0.3) @@ -929,7 +929,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/bundle-paths': specifier: workspace:0.36.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -950,10 +950,10 @@ importers: version: 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 tar: specifier: ^7.5.21 version: 7.5.21 @@ -990,13 +990,13 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@distilled.cloud/prisma': specifier: 'catalog:' - version: 1.0.0-rc.18(effect@4.1.0) + version: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@effect/platform-node': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0)(redis@6.2.1) + version: 4.1.2(effect@4.1.2)(redis@6.2.1) '@internal/bundle-paths': specifier: workspace:0.36.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -1011,10 +1011,10 @@ importers: version: 1.80.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 devDependencies: '@internal/tsdown-config': specifier: workspace:0.36.0 @@ -1060,7 +1060,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/core': specifier: workspace:0.36.0 version: link:../../../0-framework/1-core/core @@ -1099,13 +1099,13 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -1118,7 +1118,7 @@ importers: version: 3.1087.0 '@distilled.cloud/prisma': specifier: 'catalog:' - version: 1.0.0-rc.18(effect@4.1.0) + version: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@internal/tsdown-config': specifier: workspace:0.36.0 version: link:../../../0-framework/0-foundation/tsdown-config @@ -1378,7 +1378,7 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -1387,7 +1387,7 @@ importers: version: 17.4.2 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 esbuild: specifier: ^0.28.1 version: 0.28.2 @@ -1448,13 +1448,13 @@ importers: version: 1.80.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) cross-spawn: specifier: ^7.0.6 version: 7.0.6 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 esbuild: specifier: ^0.28.1 version: 0.28.2 @@ -1494,19 +1494,19 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@distilled.cloud/prisma': specifier: 'catalog:' - version: 1.0.0-rc.18(effect@4.1.0) + version: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@effect/platform-bun': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0) + version: 4.1.2(effect@4.1.2) '@effect/platform-node': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0)(redis@6.2.1) + version: 4.1.2(effect@4.1.2)(redis@6.2.1) '@effect/platform-node-shared': specifier: 'catalog:' - version: 4.1.0(effect@4.1.0) + version: 4.1.2(effect@4.1.2) '@prisma/composer': specifier: workspace:0.36.0 version: link:../composer @@ -1524,13 +1524,13 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 jose: specifier: ^6.1.3 version: 6.2.4 @@ -1633,7 +1633,7 @@ importers: version: 26.1.1 alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -1657,7 +1657,7 @@ importers: version: link:../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) + version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) devDependencies: '@prisma/composer-cli': specifier: workspace:* @@ -1686,17 +1686,19 @@ importers: packages: - '@alchemy.run/node-utils@2.0.0-beta.85': - resolution: {integrity: sha512-2gxqMNMoJdx13w7wlDJw+gaXQ8e1VxQFsKldOvweqpd7JEgB5TGn3oO8r1sb/GB9+UzqXdh8tKp2MOVgFOrmbg==} + '@alchemy.run/node-utils@https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz': + resolution: {integrity: sha512-7tnqQqAvc7XZmKmDkG5PUqAiY6lauSzO+n//JybZ+g4oeooORf9I+Kx6wjnl46dLX6D6xy3XfUmMmRZR8KGREA==, tarball: https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz} + version: 2.0.0-beta.85 - '@alchemy.run/prisma@2.0.0-beta.85': - resolution: {integrity: sha512-YSLh0IUk24iamM7MgHgQzMAoQ7l0j6V+k7bk7ATfGbbd85Hhoi8h2UanQmjfeJ0dEkO/KmdhqfBT0aJgraogTw==} + '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95': + resolution: {integrity: sha512-39gVXh6e5Jsgkeqs7NneR9S44/XfHahNwv9K8/uPjRjYFtwKBgb4ijVI8haXhF2pQaySqxlk0n0XJ7lC5GLyFQ==, tarball: https://pkg.alchemy.run/@alchemy.run/prisma/-/04b1f57d7e5a865ec59a96ad6ec57c7a97bcd8cb7f6085763737f8ec835dc024.tgz} + version: 2.0.0-beta.85 peerDependencies: '@effect/platform-bun': ^4.1.0 '@effect/platform-node': ^4.1.0 '@prisma/orm-postgres': ^8.0.0-rc.11 '@vercel/nft': ^1.10.2 - alchemy: 2.0.0-beta.85 + alchemy: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz effect: ^4.1.0 prisma: ^8.0.0-rc.15 ws: ^8.21.0 @@ -1714,8 +1716,8 @@ packages: ws: optional: true - '@alchemy.run/sigil@0.1.0-alpha.1': - resolution: {integrity: sha512-RtHELG2w+GluVewRsuKm66idtPzC+4STptMr/61X1Vy5uBkDzcCzjzV8G6jUa4iBv6Il0hHV7UL6wvv//9nD/Q==} + '@alchemy.run/sigil@0.1.0-alpha.3': + resolution: {integrity: sha512-NBhQRjbeFp4jPYDGqlXSoUlT45QyNc/6DGQWmkXUnS9nHKjHa5eIRZChdnCDYHYvmgFJWNaRfPFGwxrCWmlLzw==} engines: {node: '>=22'} peerDependencies: '@types/react': '>=19.2.0' @@ -1992,25 +1994,35 @@ packages: '@cloudflare/workers-types@5.20261013.1': resolution: {integrity: sha512-0SfkM/MA+oSIAzFFmjr0wZLH54oeCtugaOn8GpJDBnerQiML+rOPq2W6j6bBGE51o+N4DOd0tvMruLmkAQzlpw==} - '@distilled.cloud/aws4signer@1.0.0-rc.18': - resolution: {integrity: sha512-eYJi2rsblwjxpa0kj2qUQYqY9cEwD6ow0njiFL6I5ZnlOqrE8I3aLiWvScoc3s9bQUQD5nFdmSnORoPJ5GsHKg==} + '@distilled.cloud/aws4signer@https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz': + resolution: {integrity: sha512-B5EwfP98W1K7ubC/UQF7No0bpu/9RSh/d8RZNvszNT9v+OvCGt/hmwd87mD2jVoYnfqEO6Sw6XEGHeisRz08hA==, tarball: https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz} + version: 1.0.0-rc.18 peerDependencies: - effect: ^4.0.5 + effect: ^4.1.2 - '@distilled.cloud/aws@1.0.0-rc.18': - resolution: {integrity: sha512-VZDlOGEXvXvTdY/0sTEoTWnPCGde+fjvq1ZIW3wgiZK/X8ChbbonGArg+Sc9OO4E9tr1WBymASrZ906uLnLWFw==} + '@distilled.cloud/aws@https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz': + resolution: {integrity: sha512-z78IsvD1o0D3+UcKfiKTzqTQlfBa2B6M2ucdnr8sCuM8LdvepQrzAh4E0ibtdF54PFwOaUisbnAmoXn4Xr9EJw==, tarball: https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz} + version: 1.0.0-rc.18 peerDependencies: - effect: ^4.0.5 + effect: ^4.1.2 - '@distilled.cloud/core@1.0.0-rc.18': - resolution: {integrity: sha512-Iui60+sNl1CwUu7NKGLuFf3VC4H9xYzfm0Fc8ZQvd87qwf1sDMB3EAWZrq/QFgrwzSY16z2mJ0PNHaBUnGet8A==} + '@distilled.cloud/core@https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz': + resolution: {integrity: sha512-YqFoDrVR5ZgZrsJRHBVUIH1eFBl5hlaKulsuvftBQcdzhCbA1/tIulBUx5B+pQDTTML4woCoiAA7mYHGHomt0A==, tarball: https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz} + version: 1.0.0-rc.18 peerDependencies: - effect: ^4.0.5 + effect: ^4.1.2 - '@distilled.cloud/prisma@1.0.0-rc.18': - resolution: {integrity: sha512-hWiMZL5706ONqbBllELI9lsbrIz9ZdgthAxAt5ScFZYOjTKqkWUqNRr2fnqnd1xjYvu+F7VPs0VChvOD2NfB2w==} + '@distilled.cloud/prisma@https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz': + resolution: {integrity: sha512-G0dAsiV4gc2Q07UeIVg9mH3XRUrJrXcGEzppy7gus/eHi9zqC5dVJXYBQRoeObdCJIykG+v5/+Yj6nAruEfFUA==, tarball: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz} + version: 1.0.0-rc.18 peerDependencies: - effect: ^4.0.5 + effect: ^4.1.2 + + '@distilled.cloud/s3@https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz': + resolution: {integrity: sha512-0jyIu+cKFiwhOUyRj5CzYHJ8FezjZxjKEhOrPHEQlNxy5YXDo7xtZ1eOEe9uqwwbnO5MbLrGc0XSDSJPcAI0lQ==, tarball: https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz} + version: 1.0.0-rc.18 + peerDependencies: + effect: ^4.1.2 '@durable-streams/client@0.2.3': resolution: {integrity: sha512-609hWTqe8/OXzIFnv+oDdlT57QsCAc3F2c/nAQBcYhSLmmbXk5rHx7rnQSmk9MeGGQ8dsg9UCZf47dTJG3q3ig==} @@ -2027,23 +2039,23 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - '@effect/platform-bun@4.1.0': - resolution: {integrity: sha512-ZgIV+tV2utcbiopbWSoJuGn3FZuyqhLWIYITFCR8vlwnKicO1q0WgP4BBjDUYUTMRPEU/N1+BcZourAgpiDI/w==} + '@effect/platform-bun@4.1.2': + resolution: {integrity: sha512-waEAa84CITqjctgw9VdhlL9PyXRHICG3KAWTFRDPUE2XL/pfMPhO7V9oFdfx22dbAhhUzV+MHs0d9hnmy/DQ7w==} peerDependencies: - effect: ^4.1.0 + effect: ^4.1.2 - '@effect/platform-node-shared@4.1.0': - resolution: {integrity: sha512-uYQfFRoxWLo8fIhiSeBcmz/R1ooOfE3HF3cWHFgxz82tZMk2Wn02BGABcdLmaAch9VpDLbtWnuhSxNyBsGMUFg==} + '@effect/platform-node-shared@4.1.2': + resolution: {integrity: sha512-wSwl9A/G1hcVvF8nLI+YfB3EyOIDvWS6+lsTZBkmgDnPBL7m8YWZF+1HD+mdEsb52SUGfukip46Q3gRwSdk3QA==} engines: {node: '>=22.19.0'} peerDependencies: - effect: ^4.1.0 + effect: ^4.1.2 - '@effect/platform-node@4.1.0': - resolution: {integrity: sha512-v+X7moGVF9k5oMVvz0slA+NgtEod+W+oygeI285MDJgSlUCW3JcqElj+cD3rlijFZtpKhC7RLkU1GuCxjGHTpA==} + '@effect/platform-node@4.1.2': + resolution: {integrity: sha512-ye0UjxORdd5Qu3paZ1LljgGOALWHt7muP9VTpjUSCNfcesiB/Hm7wPJIvZeu4gnwpTe5BtEnO1VHngWqjJp8mA==} engines: {node: '>=22.19.0'} peerDependencies: - effect: ^4.1.0 - redis: '>=5.0.0 <7.0.0' + effect: ^4.1.2 + redis: '>=5.1.0 <7.0.0' '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} @@ -2474,8 +2486,8 @@ packages: '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} - '@oxc-project/types@0.153.0': - resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} '@prisma/cli-engine@0.9.0': resolution: {integrity: sha512-M53Xx/KiPQ02iT8FXT2c9X+3fVgMVfWK/emf/tq+jRNFi7b0Ffwaeo1yz8XS4UKOza5DaKRwMs+K8U44z9corg==} @@ -2896,8 +2908,8 @@ packages: peerDependencies: '@redis/client': ^6.2.1 - '@rolldown/binding-android-arm-eabi@1.2.13': - resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] @@ -2914,8 +2926,8 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.2.13': - resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -2932,8 +2944,8 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.2.13': - resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -2950,8 +2962,8 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.13': - resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -2968,8 +2980,8 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.2.13': - resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -2986,8 +2998,8 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': - resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -3004,8 +3016,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.13': - resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3022,8 +3034,8 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.2.13': - resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] @@ -3040,8 +3052,8 @@ packages: cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.2.13': - resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -3058,8 +3070,8 @@ packages: cpu: [s390x] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.2.13': - resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] @@ -3076,8 +3088,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.2.13': - resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3094,8 +3106,8 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.2.13': - resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] @@ -3112,8 +3124,8 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.2.13': - resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3140,8 +3152,8 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.2.13': - resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3158,8 +3170,8 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.13': - resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3640,11 +3652,12 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alchemy@2.0.0-beta.85: - resolution: {integrity: sha512-dRSyfVDfU3wevBT0Iam6PUjQTn6PY6Lyd2BOeg9ToE208ifrx0sdmNUag8lLeiHisxlhaJJRyOtqKcWZ7lY0Pw==} + alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95: + resolution: {integrity: sha512-rv9uh8bZQa0Pgoni2wjSZv/iU2yoTzX2qmPG8KL2RLJrhF5YWDdfWTi2ZRKeoJJe6cgfoPksiCyvdG5J/CJLng==, tarball: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz} + version: 2.0.0-beta.85 hasBin: true peerDependencies: - '@alchemy.run/frontend-frameworks': 2.0.0-beta.85 + '@alchemy.run/frontend-frameworks': https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/-/46b0dbaae9d92ac368f1723a6ec79141e7a7ac5776644a098cb136fc57175986.tgz '@effect/platform-bun': ^4.1.0 '@effect/platform-node': ^4.1.0 effect: ^4.1.0 @@ -4100,8 +4113,8 @@ packages: effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} - effect@4.1.0: - resolution: {integrity: sha512-lGx2MJJyrXA8RZb2UpQ/0Nkb5Utaimby7oD92+18noBxLPE85IFOvptdzau8dvcxAOwfwHo9iqapbKeFzqQM/Q==} + effect@4.1.2: + resolution: {integrity: sha512-d3Oah1WyKxFLFh19yWheS5IXIVaITzxZ71JkE1rkUozq/6EcJSU5nPk5bF2QrSygZ3SQoiNQB8TsxFriyMeMaQ==} elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} @@ -5051,8 +5064,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.2.13: - resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5609,6 +5622,18 @@ packages: utf-8-validate: optional: true + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@1.0.1: resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==} engines: {node: '>=20'} @@ -5655,52 +5680,52 @@ packages: snapshots: - '@alchemy.run/node-utils@2.0.0-beta.85': + '@alchemy.run/node-utils@https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz': dependencies: chokidar: 5.0.0 - rolldown: 1.2.13 + rolldown: 1.2.16 - '@alchemy.run/prisma@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0)': + '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)': dependencies: - '@distilled.cloud/aws': 1.0.0-rc.18(effect@4.1.0) - '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.0) - '@distilled.cloud/prisma': 1.0.0-rc.18(effect@4.1.0) + '@distilled.cloud/aws': https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz(effect@4.1.2) + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@prisma/dev': 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) - effect: 4.1.0 - rolldown: 1.2.13 + alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + effect: 4.1.2 + rolldown: 1.2.16 optionalDependencies: - '@effect/platform-bun': 4.1.0(effect@4.1.0) - '@effect/platform-node': 4.1.0(effect@4.1.0)(redis@6.2.1) + '@effect/platform-bun': 4.1.2(effect@4.1.2) + '@effect/platform-node': 4.1.2(effect@4.1.2)(redis@6.2.1) '@prisma/orm-postgres': 8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vercel/nft': 1.10.2 prisma: 8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - ws: 8.22.0 + ws: 8.22.1 transitivePeerDependencies: - typescript - vite - '@alchemy.run/prisma@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0))(effect@4.1.0)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0)': + '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)': dependencies: - '@distilled.cloud/aws': 1.0.0-rc.18(effect@4.1.0) - '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.0) - '@distilled.cloud/prisma': 1.0.0-rc.18(effect@4.1.0) + '@distilled.cloud/aws': https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz(effect@4.1.2) + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@prisma/dev': 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - alchemy: 2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0) - effect: 4.1.0 - rolldown: 1.2.13 + alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + effect: 4.1.2 + rolldown: 1.2.16 optionalDependencies: - '@effect/platform-bun': 4.1.0(effect@4.1.0) - '@effect/platform-node': 4.1.0(effect@4.1.0)(redis@6.2.1) + '@effect/platform-bun': 4.1.2(effect@4.1.2) + '@effect/platform-node': 4.1.2(effect@4.1.2)(redis@6.2.1) '@prisma/orm-postgres': 8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vercel/nft': 1.10.2 prisma: 8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - ws: 8.22.0 + ws: 8.22.1 transitivePeerDependencies: - typescript - vite - '@alchemy.run/sigil@0.1.0-alpha.1(@types/react@19.2.17)': + '@alchemy.run/sigil@0.1.0-alpha.3(@types/react@19.2.17)': optionalDependencies: '@types/react': 19.2.17 @@ -6064,25 +6089,32 @@ snapshots: '@cloudflare/workers-types@5.20261013.1': optional: true - '@distilled.cloud/aws4signer@1.0.0-rc.18(effect@4.1.0)': + '@distilled.cloud/aws4signer@https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz(effect@4.1.2)': + dependencies: + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + effect: 4.1.2 + + '@distilled.cloud/aws@https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.0) - effect: 4.1.0 + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + '@distilled.cloud/s3': https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/aws@1.0.0-rc.18(effect@4.1.0)': + '@distilled.cloud/core@https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2)': dependencies: - '@distilled.cloud/aws4signer': 1.0.0-rc.18(effect@4.1.0) - '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.0) - effect: 4.1.0 + effect: 4.1.2 - '@distilled.cloud/core@1.0.0-rc.18(effect@4.1.0)': + '@distilled.cloud/prisma@https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2)': dependencies: - effect: 4.1.0 + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + '@distilled.cloud/s3': https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz(effect@4.1.2) + effect: 4.1.2 - '@distilled.cloud/prisma@1.0.0-rc.18(effect@4.1.0)': + '@distilled.cloud/s3@https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz(effect@4.1.2)': dependencies: - '@distilled.cloud/core': 1.0.0-rc.18(effect@4.1.0) - effect: 4.1.0 + '@distilled.cloud/aws4signer': https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz(effect@4.1.2) + '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) + effect: 4.1.2 '@durable-streams/client@0.2.3': dependencies: @@ -6114,27 +6146,27 @@ snapshots: - msw - vite - '@effect/platform-bun@4.1.0(effect@4.1.0)': + '@effect/platform-bun@4.1.2(effect@4.1.2)': dependencies: - '@effect/platform-node-shared': 4.1.0(effect@4.1.0) - effect: 4.1.0 + '@effect/platform-node-shared': 4.1.2(effect@4.1.2) + effect: 4.1.2 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.1.0(effect@4.1.0)': + '@effect/platform-node-shared@4.1.2(effect@4.1.2)': dependencies: '@types/ws': 8.18.2 - effect: 4.1.0 - ws: 8.22.0 + effect: 4.1.2 + ws: 8.22.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1)': + '@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1)': dependencies: - '@effect/platform-node-shared': 4.1.0(effect@4.1.0) - effect: 4.1.0 + '@effect/platform-node-shared': 4.1.2(effect@4.1.2) + effect: 4.1.2 redis: 6.2.1 undici: 8.12.3 transitivePeerDependencies: @@ -6444,7 +6476,7 @@ snapshots: '@oxc-project/types@0.140.0': {} - '@oxc-project/types@0.153.0': {} + '@oxc-project/types@0.156.0': {} '@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5)': dependencies: @@ -7104,7 +7136,7 @@ snapshots: dependencies: '@redis/client': 6.2.1 - '@rolldown/binding-android-arm-eabi@1.2.13': + '@rolldown/binding-android-arm-eabi@1.2.16': optional: true '@rolldown/binding-android-arm64@1.1.5': @@ -7113,7 +7145,7 @@ snapshots: '@rolldown/binding-android-arm64@1.2.0': optional: true - '@rolldown/binding-android-arm64@1.2.13': + '@rolldown/binding-android-arm64@1.2.16': optional: true '@rolldown/binding-darwin-arm64@1.1.5': @@ -7122,7 +7154,7 @@ snapshots: '@rolldown/binding-darwin-arm64@1.2.0': optional: true - '@rolldown/binding-darwin-arm64@1.2.13': + '@rolldown/binding-darwin-arm64@1.2.16': optional: true '@rolldown/binding-darwin-x64@1.1.5': @@ -7131,7 +7163,7 @@ snapshots: '@rolldown/binding-darwin-x64@1.2.0': optional: true - '@rolldown/binding-darwin-x64@1.2.13': + '@rolldown/binding-darwin-x64@1.2.16': optional: true '@rolldown/binding-freebsd-x64@1.1.5': @@ -7140,7 +7172,7 @@ snapshots: '@rolldown/binding-freebsd-x64@1.2.0': optional: true - '@rolldown/binding-freebsd-x64@1.2.13': + '@rolldown/binding-freebsd-x64@1.2.16': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.1.5': @@ -7149,7 +7181,7 @@ snapshots: '@rolldown/binding-linux-arm-gnueabihf@1.2.0': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': optional: true '@rolldown/binding-linux-arm64-gnu@1.1.5': @@ -7158,7 +7190,7 @@ snapshots: '@rolldown/binding-linux-arm64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.13': + '@rolldown/binding-linux-arm64-gnu@1.2.16': optional: true '@rolldown/binding-linux-arm64-musl@1.1.5': @@ -7167,7 +7199,7 @@ snapshots: '@rolldown/binding-linux-arm64-musl@1.2.0': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.13': + '@rolldown/binding-linux-arm64-musl@1.2.16': optional: true '@rolldown/binding-linux-ppc64-gnu@1.1.5': @@ -7176,7 +7208,7 @@ snapshots: '@rolldown/binding-linux-ppc64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.13': + '@rolldown/binding-linux-ppc64-gnu@1.2.16': optional: true '@rolldown/binding-linux-s390x-gnu@1.1.5': @@ -7185,7 +7217,7 @@ snapshots: '@rolldown/binding-linux-s390x-gnu@1.2.0': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.13': + '@rolldown/binding-linux-s390x-gnu@1.2.16': optional: true '@rolldown/binding-linux-x64-gnu@1.1.5': @@ -7194,7 +7226,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.13': + '@rolldown/binding-linux-x64-gnu@1.2.16': optional: true '@rolldown/binding-linux-x64-musl@1.1.5': @@ -7203,7 +7235,7 @@ snapshots: '@rolldown/binding-linux-x64-musl@1.2.0': optional: true - '@rolldown/binding-linux-x64-musl@1.2.13': + '@rolldown/binding-linux-x64-musl@1.2.16': optional: true '@rolldown/binding-openharmony-arm64@1.1.5': @@ -7212,7 +7244,7 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.2.0': optional: true - '@rolldown/binding-openharmony-arm64@1.2.13': + '@rolldown/binding-openharmony-arm64@1.2.16': optional: true '@rolldown/binding-wasm32-wasi@1.1.5': @@ -7235,7 +7267,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.2.0': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.13': + '@rolldown/binding-win32-arm64-msvc@1.2.16': optional: true '@rolldown/binding-win32-x64-msvc@1.1.5': @@ -7244,7 +7276,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.0': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.13': + '@rolldown/binding-win32-x64-msvc@1.2.16': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -7775,44 +7807,44 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0): + alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): dependencies: - '@alchemy.run/node-utils': 2.0.0-beta.85 - '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.2.17) + '@alchemy.run/node-utils': https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz + '@alchemy.run/sigil': 0.1.0-alpha.3(@types/react@19.2.17) capnweb: 0.12.0 - effect: 4.1.0 + effect: 4.1.2 fflate: 0.8.3 pathe: 2.0.3 picomatch: 4.0.7 - rolldown: 1.2.13 + rolldown: 1.2.16 tinyglobby: 0.2.17 yaml: 2.9.1 optionalDependencies: - '@effect/platform-bun': 4.1.0(effect@4.1.0) - '@effect/platform-node': 4.1.0(effect@4.1.0)(redis@6.2.1) + '@effect/platform-bun': 4.1.2(effect@4.1.2) + '@effect/platform-node': 4.1.2(effect@4.1.2)(redis@6.2.1) vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - ws: 8.22.0 + ws: 8.22.1 transitivePeerDependencies: - '@types/react' - react-devtools-core - alchemy@2.0.0-beta.85(@effect/platform-bun@4.1.0(effect@4.1.0))(@effect/platform-node@4.1.0(effect@4.1.0)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.0)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.0): + alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): dependencies: - '@alchemy.run/node-utils': 2.0.0-beta.85 - '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.2.17) + '@alchemy.run/node-utils': https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz + '@alchemy.run/sigil': 0.1.0-alpha.3(@types/react@19.2.17) capnweb: 0.12.0 - effect: 4.1.0 + effect: 4.1.2 fflate: 0.8.3 pathe: 2.0.3 picomatch: 4.0.7 - rolldown: 1.2.13 + rolldown: 1.2.16 tinyglobby: 0.2.17 yaml: 2.9.1 optionalDependencies: - '@effect/platform-bun': 4.1.0(effect@4.1.0) - '@effect/platform-node': 4.1.0(effect@4.1.0)(redis@6.2.1) + '@effect/platform-bun': 4.1.2(effect@4.1.2) + '@effect/platform-node': 4.1.2(effect@4.1.2)(redis@6.2.1) vite: 8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - ws: 8.22.0 + ws: 8.22.1 transitivePeerDependencies: - '@types/react' - react-devtools-core @@ -8192,7 +8224,7 @@ snapshots: fast-check: 3.23.2 optional: true - effect@4.1.0: {} + effect@4.1.2: {} elkjs@0.11.1: optional: true @@ -9254,26 +9286,26 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.0 '@rolldown/binding-win32-x64-msvc': 1.2.0 - rolldown@1.2.13: + rolldown@1.2.16: dependencies: - '@oxc-project/types': 0.153.0 + '@oxc-project/types': 0.156.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.13 - '@rolldown/binding-android-arm64': 1.2.13 - '@rolldown/binding-darwin-arm64': 1.2.13 - '@rolldown/binding-darwin-x64': 1.2.13 - '@rolldown/binding-freebsd-x64': 1.2.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 - '@rolldown/binding-linux-arm64-gnu': 1.2.13 - '@rolldown/binding-linux-arm64-musl': 1.2.13 - '@rolldown/binding-linux-ppc64-gnu': 1.2.13 - '@rolldown/binding-linux-s390x-gnu': 1.2.13 - '@rolldown/binding-linux-x64-gnu': 1.2.13 - '@rolldown/binding-linux-x64-musl': 1.2.13 - '@rolldown/binding-openharmony-arm64': 1.2.13 - '@rolldown/binding-win32-arm64-msvc': 1.2.13 - '@rolldown/binding-win32-x64-msvc': 1.2.13 + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 rou3@0.7.12: {} @@ -9797,6 +9829,8 @@ snapshots: ws@8.22.0: {} + ws@8.22.1: {} + wsl-utils@1.0.1: dependencies: is-wsl: 3.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4c3197aab..b6e219185 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,10 +13,10 @@ catalog: # alchemy and every @alchemy.run/* package move together, at one commit or # one release; @distilled.cloud/prisma is the exact version # @alchemy.run/prisma depends on. See the upgrade-alchemy-effect skill. - alchemy: 2.0.0-beta.85 - '@alchemy.run/prisma': 2.0.0-beta.85 - '@distilled.cloud/prisma': 1.0.0-rc.18 - effect: 4.1.0 - '@effect/platform-bun': 4.1.0 - '@effect/platform-node': 4.1.0 - '@effect/platform-node-shared': 4.1.0 + alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95 + '@alchemy.run/prisma': https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95 + '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz + effect: 4.1.2 + '@effect/platform-bun': 4.1.2 + '@effect/platform-node': 4.1.2 + '@effect/platform-node-shared': 4.1.2 diff --git a/skills-contrib/upgrade-alchemy-effect/SKILL.md b/skills-contrib/upgrade-alchemy-effect/SKILL.md index ab1dcc22b..67976fd24 100644 --- a/skills-contrib/upgrade-alchemy-effect/SKILL.md +++ b/skills-contrib/upgrade-alchemy-effect/SKILL.md @@ -122,9 +122,9 @@ install never gets there. ## Pinning an unreleased alchemy build -The repo is on a registry release: `alchemy` and `@alchemy.run/prisma` at 2.0.0-beta.85, `@distilled.cloud/prisma` at 1.0.0-rc.18. It was on a preview of commit `972f294` until beta.85 shipped that commit. Use a preview only when you need a fix that no release contains yet. +The repo is on a preview of alchemy-run/alchemy#3101 at `ab55f95` (`https://pkg.alchemy.run//pr:3101:ab55f95`): `alchemy` and `@alchemy.run/prisma` at that URL, `@distilled.cloud/prisma` at the tarball URL `@alchemy.run/prisma` depends on, and `effect` with its companions at 4.1.2, the floor of that Distilled build's `effect` peer. The preview lets `PrismaEnvironment` and Distilled's `Credentials` omit the token (alchemy-run/alchemy#3101, alchemy-run/distilled#1076), which `prisma deploy` needs to send every request through the engine's client. Move back to a registry release once one contains both changes. Before this, the repo was on 2.0.0-beta.85 and `@distilled.cloud/prisma` 1.0.0-rc.18. Use a preview only when you need a fix that no release contains yet. -alchemy publishes a preview of every commit on `main`. Pin it by commit, never by branch, in the `catalog:` block of `pnpm-workspace.yaml`: +alchemy publishes a preview of every commit on `main`, and of each pull request's head as `pr::`. Pin it by commit, never by branch, in the `catalog:` block of `pnpm-workspace.yaml`: ```yaml alchemy: https://pkg.alchemy.run/alchemy/ From c259ac62a6fe0ee8adbd1b7114d238d395ed8bc5 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:21:44 +0100 Subject: [PATCH 06/14] feat(core): lower() hands the caller's credentials to providers and state LowerOptions.credentials carries what the caller already authenticated. Core passes it to each extension's providers() and to the state descriptor's create(), so an extension can use the engine's client inside the converge instead of reading a token from process.env. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-core/core/src/control/app-config.ts | 15 +++++++++++---- .../1-core/core/src/control/deploy.ts | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index 9e9334860..f3fe8c199 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -55,8 +55,12 @@ export interface ExtensionDescriptor { readonly provisions?: ReadonlyMap; /** Once-per-lowering hook — the application's shared infrastructure (e.g. prisma-cloud's Project). */ readonly application?: ApplicationDescriptor; - /** The extension's Alchemy providers — merged across all configured extensions (config order). */ - readonly providers?: () => Layer.Layer; + /** + * The extension's Alchemy providers — merged across all configured extensions (config order). + * `credentials` is what the caller already authenticated (`LowerOptions.credentials`); absent means the extension falls back to its own credential protocol. + * METHOD SYNTAX REQUIRED, like `preflight`: an extension types `credentials` against its own client, which assigns here only through method bivariance. + */ + providers?(credentials?: ContainerCredentials): Layer.Layer; /** * Deploy-time prerequisite check — the CLI runs it once, after the app's * Project/Branch are resolved and BEFORE any stack file is written or Alchemy @@ -122,8 +126,11 @@ export interface ExtensionDescriptor { export interface StateDescriptor { /** The owning extension's id — matched against `ExtensionDescriptor.id`. */ readonly extension: string; - /** Build the state layer. `container` is the owning extension's resolved instance; `undefined` when it declared no container descriptor. */ - create(container: ContainerInstance | undefined): AlchemyStateLayer; + /** Build the state layer. `container` is the owning extension's resolved instance; `undefined` when it declared no container descriptor. `credentials` is as `ExtensionDescriptor.providers` receives it. */ + create( + container: ContainerInstance | undefined, + credentials?: ContainerCredentials, + ): AlchemyStateLayer; } /** The resolved deploy context handed to an extension's `preflight` hook. `C` erases to `unknown` at the framework boundary — see ContainerCredentials. */ diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index da31cb05e..3742b19a7 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -19,6 +19,7 @@ import { } from '../node.ts'; import { type PreflightPayload, readPreflightPayload } from '../preflight-transport.ts'; import type { + ContainerCredentials, ContainerInstance, ExtensionDescriptor, NodeDescriptor, @@ -240,6 +241,8 @@ export interface LowerOptions { readonly containers: ReadonlyMap; /** Each extension's preflight payload, by extension id. A generated stack file reads them with `transportedLowerInputs`. */ readonly preflight: ReadonlyMap; + /** What the caller already authenticated, handed to every extension's `providers()` and to the state descriptor's `create()`. Absent means each extension falls back to its own credential protocol. */ + readonly credentials?: ContainerCredentials | undefined; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -619,7 +622,9 @@ export function resolveStateLayer( config: PrismaAppConfig, containers: ReadonlyMap, ): AlchemyStateLayer { - return opts.state ?? config.state.create(containers.get(config.state.extension)); + return ( + opts.state ?? config.state.create(containers.get(config.state.extension), opts.credentials) + ); } /** @@ -627,9 +632,12 @@ export function resolveStateLayer( * extension without `providers` is skipped; no used-extensions-only * filtering (ADR-0017's pinned providers rule). */ -export function mergedProviders(config: PrismaAppConfig): Layer.Layer { +export function mergedProviders( + config: PrismaAppConfig, + credentials?: ContainerCredentials, +): Layer.Layer { const layers = config.extensions.flatMap((extension) => - extension.providers !== undefined ? [extension.providers()] : [], + extension.providers !== undefined ? [extension.providers(credentials)] : [], ); const [first, ...rest] = layers; return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); @@ -905,7 +913,7 @@ export function lower(root: ModuleNode, config: PrismaAppConfig, opts: LowerOpti // A LowerError at deploy is fatal; orDie moves it off the error channel to // match what Alchemy.Stack accepts. const stackEffect = Effect.orDie(lowering(root, config, opts)) as Effect.Effect; - const providers = opts.providers ?? mergedProviders(config); + const providers = opts.providers ?? mergedProviders(config, opts.credentials); return Alchemy.Stack( opts.name, From aa67480e7825fc8e480d95b1268b0fa28a58e876 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:21:49 +0100 Subject: [PATCH 07/14] feat(prisma-cloud): send every Management API request through the supplied client The Prisma provider bundle and the hosted state store now take the caller's Management API client (ctx.api under prisma deploy). Composer's own resources use it directly. Alchemy's providers, Distilled's operations and the state store get an Effect HttpClient that sends Management API requests through the client's per-method calls, with the body, query and headers unchanged and the response read as a stream; other hosts, such as presigned upload URLs, go to node's transport. PrismaEnvironment and Credentials carry only the base URL, so nothing but the client sets Authorization. Without a supplied client, one is built from PRISMA_SERVICE_TOKEN and used the same way. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/credentials-interop.test.ts | 4 +- .../__tests__/management-http-client.test.ts | 246 ++++++++++++++++++ .../0-lowering/lowering/src/client.ts | 15 +- .../lowering/src/management-http-client.ts | 102 ++++++++ .../0-lowering/lowering/src/providers.ts | 89 +++---- .../0-lowering/lowering/src/state/layer.ts | 41 ++- .../target/src/control/extension.ts | 25 +- 7 files changed, 444 insertions(+), 78 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/credentials-interop.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/credentials-interop.test.ts index 9a4f5cfc5..46709db08 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/credentials-interop.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/credentials-interop.test.ts @@ -24,6 +24,8 @@ test('Composer and generated Prisma API credentials coexist in one context', asy ); expect(Redacted.value(result.composer.token)).toBe('test-service-token'); - expect(Redacted.value(result.generated.apiToken)).toBe('test-service-token'); + expect(result.generated.apiToken && Redacted.value(result.generated.apiToken)).toBe( + 'test-service-token', + ); expect(result.generated.apiBaseUrl).toBe('https://api.prisma.test'); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts new file mode 100644 index 000000000..76eb72a9f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts @@ -0,0 +1,246 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as Prisma from '@alchemy.run/prisma'; +import type { Credentials, Retry } from '@distilled.cloud/prisma'; +import { getWorkspaces } from '@distilled.cloud/prisma/management'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import * as Effect from 'effect/Effect'; +import * as Fiber from 'effect/Fiber'; +import * as HttpClient from 'effect/http/HttpClient'; +import * as HttpClientRequest from 'effect/http/HttpClientRequest'; +import type * as Layer from 'effect/Layer'; +import type { ManagementApiClient } from '../client.ts'; +import { managementHttpClientLayer } from '../management-http-client.ts'; +import { providers } from '../providers.ts'; + +const ORIGIN = 'https://api.prisma.test'; + +/** + * The SDK client the engine hands a command as `ctx.api`, over a fetch that + * records each request. The engine wraps the client in a Proxy that turns + * every property into an async call, so this does too: the adapter may only + * call the client's methods. + */ +function fakeEngineClient(respond: (request: Request) => Promise | Response) { + const requests: Request[] = []; + const sdk = createManagementApiClient({ + baseUrl: ORIGIN, + fetch: async (request: Request) => { + requests.push(request); + return respond(request); + }, + }); + const client = new Proxy( + {}, + { + get: (_target, property) => + typeof property === 'symbol' || property === 'then' + ? undefined + : async (...args: unknown[]) => Reflect.apply(Reflect.get(sdk, property), sdk, args), + }, + ) as ManagementApiClient; + return { client, requests }; +} + +const run = ( + client: ManagementApiClient, + effect: Effect.Effect, +) => + Effect.runPromise( + effect.pipe(Effect.scoped, Effect.provide(managementHttpClientLayer(client, ORIGIN))), + ); + +describe('managementHttpClientLayer', () => { + test('a Management API request goes through the client with its body, query and headers intact and no Authorization', async () => { + const { client, requests } = fakeEngineClient(() => + Response.json({ data: { id: 'proj_1' } }, { status: 201, headers: { 'x-request-id': 'r1' } }), + ); + const body = '{"name":"shop", "region":"eu-central-1"}'; + + const response = await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.post(`${ORIGIN}/v1/projects`).pipe( + HttpClientRequest.setUrlParams({ limit: '5', cursor: 'a b' }), + HttpClientRequest.bodyText(body, 'application/json'), + HttpClientRequest.setHeader('x-custom', 'kept'), + ), + ); + return { + status: response.status, + header: response.headers['x-request-id'], + text: yield* response.text, + }; + }), + ); + + expect(response).toEqual({ status: 201, header: 'r1', text: '{"data":{"id":"proj_1"}}' }); + expect(requests).toHaveLength(1); + const [request] = requests; + if (request === undefined) throw new Error('no request'); + expect(request.method).toBe('POST'); + expect(request.url).toBe(`${ORIGIN}/v1/projects?limit=5&cursor=a+b`); + expect(await request.text()).toBe(body); + expect(request.headers.get('content-type')).toBe('application/json'); + expect(request.headers.get('x-custom')).toBe('kept'); + expect(request.headers.get('x-prisma-client-name')).toBe('composer'); + expect(request.headers.has('authorization')).toBe(false); + }); + + test('an error response keeps its status and body', async () => { + const { client } = fakeEngineClient( + () => new Response('{"error":{"code":"quota"}}', { status: 422 }), + ); + + const result = await run( + client, + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).get(`${ORIGIN}/v1/projects/p1`); + return { status: response.status, json: yield* response.json }; + }), + ); + + expect(result).toEqual({ status: 422, json: { error: { code: 'quota' } } }); + }); + + test('a failure in the client is a TransportError carrying the cause', async () => { + const failure = new Error('CLI.CREDENTIALS_REQUIRED'); + const { client } = fakeEngineClient(() => Promise.reject(failure)); + + const error = await run( + client, + Effect.flip( + Effect.flatMap(HttpClient.HttpClient, (http) => http.get(`${ORIGIN}/v1/projects`)), + ), + ); + + expect(error.reason._tag).toBe('TransportError'); + expect(error.reason.cause).toBe(failure); + }); + + test('interrupting the request aborts the client call', async () => { + let signal: AbortSignal | undefined; + const { client } = fakeEngineClient( + (request) => + new Promise((_resolve, reject) => { + signal = request.signal; + request.signal.addEventListener('abort', () => reject(request.signal.reason)); + }), + ); + + await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const fiber = yield* Effect.forkChild(http.get(`${ORIGIN}/v1/projects`)); + yield* Effect.promise(async () => { + while (signal === undefined) await new Promise((resolve) => setTimeout(resolve, 1)); + }); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(signal?.aborted).toBe(true); + }); + + describe('another host', () => { + const uploads: { method: string; headers: http.IncomingHttpHeaders; body: string }[] = []; + const server = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += String(chunk); + }); + request.on('end', () => { + uploads.push({ method: request.method ?? '', headers: request.headers, body }); + response.writeHead(200).end(); + }); + }); + let uploadUrl = ''; + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + uploadUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/upload?sig=abc`; + }); + afterAll(() => { + server.close(); + }); + + test('goes to the node transport with an explicit Content-Length and no credential, not through the client', async () => { + const { client, requests } = fakeEngineClient(() => new Response(null, { status: 500 })); + + const status = await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.put(uploadUrl).pipe( + HttpClientRequest.bodyUint8Array( + new TextEncoder().encode('artifact'), + 'application/gzip', + ), + ), + ); + return response.status; + }), + ); + + expect(status).toBe(200); + expect(requests).toEqual([]); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.method).toBe('PUT'); + expect(uploads[0]?.body).toBe('artifact'); + expect(uploads[0]?.headers['content-length']).toBe('8'); + expect(uploads[0]?.headers['authorization']).toBeUndefined(); + }); + }); +}); + +/** The type lists the stack services the providers need when a stack runs them; these tests only call the clients the bundle exposes. */ +const bundle = (client: ManagementApiClient) => + providers({ client }) as unknown as Layer.Layer< + Prisma.PrismaClient | Credentials | HttpClient.HttpClient | Retry.Retry + >; + +describe('providers({ client })', () => { + test("an upstream provider's request goes through the supplied client, with no token anywhere", async () => { + const saved = process.env['PRISMA_SERVICE_TOKEN']; + delete process.env['PRISMA_SERVICE_TOKEN']; + const { client, requests } = fakeEngineClient(() => + Response.json({ data: { id: 'proj_1', name: 'shop' } }), + ); + try { + const project = await Effect.runPromise( + Effect.gen(function* () { + const prisma = yield* Prisma.PrismaClient; + return yield* prisma.getProject('proj_1'); + }).pipe(Effect.scoped, Effect.provide(bundle(client))), + ); + + expect(project).toMatchObject({ id: 'proj_1' }); + // Alchemy addresses the default origin; the client sends the path to its own server. + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + `GET ${ORIGIN}/v1/projects/proj_1`, + ]); + expect(requests[0]?.headers.has('authorization')).toBe(false); + } finally { + if (saved !== undefined) process.env['PRISMA_SERVICE_TOKEN'] = saved; + } + }); +}); + +describe('providers({ client }) and the generated operations', () => { + test('a Distilled operation goes through the supplied client with no Authorization', async () => { + const { client, requests } = fakeEngineClient(() => + Response.json({ data: [], pagination: { hasMore: false, nextCursor: null } }), + ); + + await Effect.runPromise(getWorkspaces({}).pipe(Effect.scoped, Effect.provide(bundle(client)))); + + expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( + ['GET /v1/workspaces'], + ); + expect(requests[0]?.headers.has('authorization')).toBe(false); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index 2cbee49fc..6c7740c24 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -4,7 +4,12 @@ import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import { deploySourceHeaders, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { + deploySourceHeaders, + fromEnv, + managementApiBaseUrl, + PrismaCredentials, +} from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -38,3 +43,11 @@ export const layer = (options?: { }); }), ); + +/** The caller's already-authenticated client, or one built from `PRISMA_SERVICE_TOKEN` when the caller supplies none. */ +export const suppliedOrFromEnv = ( + supplied: ManagementApiClient | undefined, +): Layer.Layer => + supplied === undefined + ? layer().pipe(Layer.provide(fromEnv())) + : Layer.succeed(ManagementClient, supplied); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts new file mode 100644 index 000000000..da4783b8f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts @@ -0,0 +1,102 @@ +import * as NodeHttpClient from '@effect/platform-node/NodeHttpClient'; +import { blindCast } from '@internal/foundation/casts'; +import * as Effect from 'effect/Effect'; +import * as FetchHttpClient from 'effect/http/FetchHttpClient'; +import * as HttpClient from 'effect/http/HttpClient'; +import type * as HttpClientError from 'effect/http/HttpClientError'; +import * as HttpClientRequest from 'effect/http/HttpClientRequest'; +import * as Layer from 'effect/Layer'; +import type { ManagementApiClient } from './client.ts'; +import { deploySourceHeaders } from './credentials.ts'; + +const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +type Method = (typeof METHODS)[number]; + +const isMethod = (method: string): method is Method => METHODS.some((m) => m === method); + +interface RawInit extends Omit { + readonly body: BodyInit | undefined; + readonly headers: Record; + readonly bodySerializer: (body: BodyInit) => BodyInit; + readonly parseAs: 'stream'; +} + +type RawCall = ( + path: string, + init: RawInit, +) => Promise<{ readonly response: Response; readonly error?: unknown }>; + +const rawCalls = (client: ManagementApiClient) => + blindCast< + Readonly>, + "openapi-fetch's per-method calls take any path string at runtime; its typed signature only narrows the path to the documented ones, and this client forwards paths Alchemy built" + >(client); + +/** openapi-fetch reads an error body to give it back parsed, so the response it returns has no body left. */ +const withErrorBody = (response: Response, error: unknown): Response => { + if (!response.bodyUsed) return response; + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(typeof error === 'string' ? error : JSON.stringify(error), { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +/** A `fetch` that sends each request through the SDK client, which owns authentication. */ +const fetchThrough = (client: ManagementApiClient): typeof globalThis.fetch => { + const calls = rawCalls(client); + const send = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(input instanceof Request ? input.url : input); + const method = init?.method ?? 'GET'; + if (!isMethod(method)) { + return Promise.reject(new Error(`The Management API client cannot send ${method}.`)); + } + const headers: Record = Object.fromEntries( + new Headers(init?.headers).entries(), + ); + // Without a content type of its own, openapi-fetch would label the body JSON. + if (init?.body != null && headers['content-type'] === undefined) headers['content-type'] = null; + const { response, error } = await calls[method](`${url.pathname}${url.search}`, { + ...init, + body: init?.body ?? undefined, + headers, + bodySerializer: (body) => body, + parseAs: 'stream', + }); + return withErrorBody(response, error); + }; + return Object.assign(send, { preconnect: globalThis.fetch.preconnect }); +}; + +/** + * The deploy's `HttpClient`. Requests to the Management API origin go through + * `client`, which sets and refreshes the credential; every other host, such as + * a presigned artifact upload URL, goes to node's transport unauthenticated, + * because those uploads need node's explicit Content-Length. Every request + * carries the deploy-source headers. + */ +export const managementHttpClientLayer = ( + client: ManagementApiClient, + origin: string, +): Layer.Layer => + Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const management = yield* HttpClient.HttpClient.pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetchThrough(client)), + ); + const other = yield* NodeHttpClient.makeNodeHttp; + const routed = HttpClient.makeWith( + Effect.flatMap((request) => + URL.parse(request.url)?.origin === origin + ? management.execute(request) + : other.execute(request), + ), + Effect.succeed, + ); + return HttpClient.mapRequest(routed, HttpClientRequest.setHeaders(deploySourceHeaders())); + }), + ).pipe(Layer.provide(NodeHttpClient.layerAgent)); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts index 2473ff5bd..d453c9f45 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -1,72 +1,54 @@ import * as Prisma from '@alchemy.run/prisma'; import { Credentials } from '@distilled.cloud/prisma'; -import * as NodeHttpClient from '@effect/platform-node/NodeHttpClient'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; -import * as HttpClient from 'effect/http/HttpClient'; -import * as HttpClientRequest from 'effect/http/HttpClientRequest'; import * as Layer from 'effect/Layer'; +import type { ManagementApiClient } from './client.ts'; import * as client from './client.ts'; -import { - deploySourceHeaders, - fromEnv, - managementApiBaseUrl, - PrismaCredentials, -} from './credentials.ts'; +import { managementApiBaseUrl } from './credentials.ts'; +import { managementHttpClientLayer } from './management-http-client.ts'; /** The collection of Prisma resource providers. */ export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} /** - * Upstream's `PrismaEnvironment`, built from Composer's own env credentials — - * no profile store, so no TTY prompt and no non-interactive hard-fail: - * `PRISMA_SERVICE_TOKEN` (redacted, via `PrismaCredentials`) plus the base - * URL from `managementApiBaseUrl()` — the SAME resolver `client.ts` uses, - * so `PRISMA_API_URL` moves the postgres family and the compute/bucket/state - * clients together, never one without the other. + * Upstream's `PrismaEnvironment` and the generated operations' `Credentials`, + * with the base URL from `managementApiBaseUrl()` (the resolver `client.ts` + * uses, so `PRISMA_API_URL` moves every client together) and no token: the + * bundle's `HttpClient` sends Management API requests through + * `ManagementClient`, which authenticates them. */ const prismaEnvironment = () => Layer.effect( Prisma.PrismaEnvironment, - Effect.gen(function* () { - const { token } = yield* PrismaCredentials; - const baseUrl = yield* managementApiBaseUrl(); - return { - type: 'serviceToken' as const, - serviceToken: token, - source: { type: 'env' as const, details: 'PRISMA_SERVICE_TOKEN' }, - baseUrl, - }; - }), + Effect.map(managementApiBaseUrl(), (baseUrl) => ({ + type: 'serviceToken' as const, + source: { type: 'env' as const, details: "the deploy's Management API client" }, + baseUrl, + })), ); -/** The generated Management API operations' credentials: the same token and origin. */ const operationCredentials = () => Layer.effect( Credentials, - Effect.gen(function* () { - const { token } = yield* PrismaCredentials; - const apiBaseUrl = yield* managementApiBaseUrl(); - return Effect.succeed({ apiToken: token, apiBaseUrl }); - }), + Effect.map(managementApiBaseUrl(), (apiBaseUrl) => Effect.succeed({ apiBaseUrl })), ); -/** A node:http transport that adds deploy-source headers to every request. */ -const prismaManagementHttpLayer = Layer.effect( - HttpClient.HttpClient, +/** The bundle's `HttpClient`, over `ManagementClient` (see `managementHttpClientLayer`). */ +const prismaManagementHttpLayer = Layer.unwrap( Effect.gen(function* () { - const base = yield* HttpClient.HttpClient; - return HttpClient.mapRequest(base, HttpClientRequest.setHeaders(deploySourceHeaders())); + const management = yield* client.ManagementClient; + const origin = yield* managementApiBaseUrl(); + return managementHttpClientLayer(management, origin); }), -).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)); +); /** * Upstream alchemy's live providers for the postgres family (Project, * Database, Connection), the compute family (App, Deployment, * EnvironmentVariable, CustomDomain), and the bucket family (Bucket, BucketAccessKey), - * over upstream's management clients, authenticated by - * {@link prismaEnvironment}. The generated Management API operations read - * their token and origin from the same environment. + * over upstream's management clients, configured by + * {@link prismaEnvironment}. * * Composed from the per-resource provider layers rather than upstream's own * `providers()` bundle: that bundle pulls in the profile store @@ -91,19 +73,21 @@ const upstreamPrismaProviders = () => ); /** - * The Prisma provider bundle: every resource provider, the Management API - * client, and env-based credentials. Plug into a stack with - * `{ providers: Prisma.providers() }`. + * The Prisma provider bundle: every resource provider and the Management API + * client. Plug into a stack with `{ providers: Prisma.providers() }`. * - * The deploy-source transport is the bundle's ambient `HttpClient`: upstream's - * generated Management API operations resolve it when they run, and its - * `Deployment` artifact upload needs node's explicit Content-Length (fetch - * streams chunked). Upstream's providers run their operations against this - * bundle's output, so their credentials are merged into it. Invariant: no - * Composer provider may resolve the ambient `HttpClient` — each carries its - * own client — or it would silently get this override. + * `options.client` is the caller's authenticated Management API client; without + * one, the bundle builds its own from `PRISMA_SERVICE_TOKEN`. Either way, every + * Management API request, upstream's and Composer's, goes through that client. + * + * The bundle's ambient `HttpClient` is {@link prismaManagementHttpLayer}: + * upstream's operations resolve it when they run. Upstream's providers run + * their operations against this bundle's output, so their credentials are + * merged into it. Invariant: no Composer provider may resolve the ambient + * `HttpClient` — each carries its own client — or it would silently get this + * override. */ -export const providers = () => +export const providers = (options?: { readonly client?: ManagementApiClient | undefined }) => Layer.effect( Providers, Provider.collection([ @@ -120,7 +104,6 @@ export const providers = () => ).pipe( Layer.provideMerge(upstreamPrismaProviders()), Layer.provideMerge(prismaManagementHttpLayer), - Layer.provideMerge(client.layer()), - Layer.provideMerge(fromEnv()), + Layer.provideMerge(client.suppliedOrFromEnv(options?.client)), Layer.orDie, ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index 011f8a188..b85b10dc5 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -8,7 +8,6 @@ import { type StateService, } from 'alchemy/State'; import * as Effect from 'effect/Effect'; -import * as FetchHttpClient from 'effect/http/FetchHttpClient'; import * as HttpClientRequest from 'effect/http/HttpClientRequest'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; @@ -18,6 +17,7 @@ import { withResourceReporting } from '../builds/state-store.ts'; import * as client from '../client.ts'; import { resolveDefaultBranchId } from '../container.ts'; import * as credentials from '../credentials.ts'; +import { managementHttpClientLayer } from '../management-http-client.ts'; import { failOnEmptyScopeWithLiveResources, scopeOccupied } from './empty-scope.ts'; import { hostedStateBootstrapError } from './errors.ts'; import { @@ -37,7 +37,7 @@ import { migrateLegacyResourceState } from './legacy-resources.ts'; * (stack, Alchemy stage) deploy lease, fork its heartbeat, run the migration guard, * and build the stock store. Finalizers (reverse order): interrupt the * heartbeat, release the lease. The Management API plumbing - * (`ManagementClient`, `PrismaCredentials`) and the store's `HttpClient` are + * (`ManagementClient`, supplied or built from `PRISMA_SERVICE_TOKEN`) and the store's `HttpClient` are * provided internally, so the returned layer's only requirements are the * ones alchemy itself already provides to every state store * (`StackServices`). @@ -50,17 +50,22 @@ import { migrateLegacyResourceState } from './legacy-resources.ts'; * and alchemy's own convention (e.g. a missing state store is `Effect.die` * in `Stack.make`). */ -export const prismaStateLayer = (ids: { - readonly projectId: string; - readonly branchId?: string; - /** The project's default Branch id, when the deploy targets `production` — skips re-resolving it. */ - readonly defaultBranchId?: string; -}): Layer.Layer => +export const prismaStateLayer = ( + ids: { + readonly projectId: string; + readonly branchId?: string; + /** The project's default Branch id, when the deploy targets `production` — skips re-resolving it. */ + readonly defaultBranchId?: string; + }, + supplied?: client.ManagementApiClient, +): Layer.Layer => // The origin comes from the same resolver upstream's providers use // (credentials.managementApiBaseUrl), so PRISMA_API_URL moves the state // client and the resource providers together, never one without the other. Layer.unwrap( - credentials.managementApiBaseUrl().pipe(Effect.map((origin) => stateLayerAgainst(origin, ids))), + credentials + .managementApiBaseUrl() + .pipe(Effect.map((origin) => stateLayerAgainst(origin, ids, supplied))), ).pipe(Layer.orDie); /** @@ -98,9 +103,13 @@ export const stateLayerAgainst = ( readonly branchId?: string; readonly defaultBranchId?: string; }, + supplied?: client.ManagementApiClient, ): Layer.Layer => { const { projectId, branchId, defaultBranchId } = ids; - const dependencies = client.layer({ apiOrigin }).pipe(Layer.provideMerge(credentials.fromEnv())); + const dependencies = + supplied === undefined + ? client.layer({ apiOrigin }).pipe(Layer.provide(credentials.fromEnv())) + : Layer.succeed(client.ManagementClient, supplied); return Layer.effect( State, @@ -111,7 +120,6 @@ export const stateLayerAgainst = ( hostedStateBootstrapError(container, step, cause); const mgmt = yield* client.ManagementClient; - const { token } = yield* credentials.PrismaCredentials; // A non-production environment carries its branchId; production carries defaultBranchId — // re-resolved via the Management API only when neither is present. @@ -144,13 +152,18 @@ export const stateLayerAgainst = ( ); } + // The store's requests go through `mgmt`, which authenticates them, so + // the bearer header Alchemy's store always sets is removed. const service = yield* makeHttpStateStore({ url: `${apiOrigin}/v1/projects/${projectId}/branches/${stateBranchId}/alchemy-state`, - authToken: Redacted.value(token), + authToken: '', transformClient: (req) => - HttpClientRequest.setHeader(req, LEASE_HEADER, Redacted.value(lease.leaseId)), + req.pipe( + HttpClientRequest.removeHeader('authorization'), + HttpClientRequest.setHeader(LEASE_HEADER, Redacted.value(lease.leaseId)), + ), id: 'prisma-postgres', - }).pipe(Effect.provide(FetchHttpClient.layer)); + }).pipe(Effect.provide(managementHttpClientLayer(mgmt, apiOrigin))); const migrated = migrateRowsOnRead(service); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index 3c4425483..503620933 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -3,7 +3,11 @@ * @internal/lowering is imported; loaded only by `prisma.config.ts`. */ -import type { ExtensionDescriptor, StateDescriptor } from '@internal/core/config'; +import type { + ContainerCredentials, + ExtensionDescriptor, + StateDescriptor, +} from '@internal/core/config'; import type { ProvisionerDescriptor } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; @@ -169,13 +173,16 @@ const selfOriginValue: ServiceProviderParam['valueForService'] = (provisioned, a /** The user-facing state descriptor: `state: prismaState()` in the `composer` section of `prisma.config.ts` (ADR-0017). */ export const prismaState = (): StateDescriptor => ({ extension: PRISMA_CLOUD_EXTENSION_ID, - create: (container) => { + create: (container, credentials?: ContainerCredentials) => { const { projectId, branchId, defaultBranchId } = prismaCloudContainerOf(container); - return prismaStateLayer({ - projectId, - ...(branchId !== undefined ? { branchId } : {}), - ...(defaultBranchId !== undefined ? { defaultBranchId } : {}), - }); + return prismaStateLayer( + { + projectId, + ...(branchId !== undefined ? { branchId } : {}), + ...(defaultBranchId !== undefined ? { defaultBranchId } : {}), + }, + credentials?.client, + ); }, }); @@ -329,10 +336,10 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor container: containerDescriptor({ region: () => o().region }), - providers: () => + providers: (credentials?: ContainerCredentials) => asProvidersLayer( Layer.mergeAll( - Prisma.providers(), + Prisma.providers({ client: credentials?.client }), PgWarmProvider(), OrmMigrationProvider(), S3CredentialsProvider(), From 57dd838b2b3d0fce88533f8a2bb26959a8394e6f Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:21:53 +0100 Subject: [PATCH 08/14] feat(cli): prisma deploy works with a prisma login session Deploy passes ctx.api and the active credential's workspace to lower() as values, so the providers authenticate through the engine's refreshing client. DEPLOY.SERVICE_TOKEN_REQUIRED, the command's serviceToken plumbing and the PRISMA_SERVICE_TOKEN and PRISMA_WORKSPACE_ID entries of the converge's environment overlay are removed. CONVERGE_CONTRACT goes to 2: an app on contract 1 would ignore the credentials and look for a token in the environment. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/converge-in-process.test.ts | 2 +- .../3-tooling/cli/src/converge-contract.ts | 2 +- .../cli/src/family/__tests__/deploy.test.ts | 164 ++++++++++++++++-- .../cli/src/family/commands/deploy.ts | 12 +- .../operations/__tests__/operations.test.ts | 153 ++++++++-------- .../src/operations/execute-deploy-destroy.ts | 48 +---- .../3-tooling/cli/src/operations/shared.ts | 13 +- 7 files changed, 229 insertions(+), 165 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts index 8c0e0a8d5..a42b7302c 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -428,7 +428,7 @@ describe('deploy with the real in-process converge', () => { { runAssembler: async () => ({ dir, entry: 'server.js' }), converge: { lower, converge: convergeInProcess }, - credentials: { workspaceId: 'ws_1', client: {}, serviceToken: 'tok' }, + credentials: { workspaceId: 'ws_1', client: {} }, }, ); diff --git a/packages/0-framework/3-tooling/cli/src/converge-contract.ts b/packages/0-framework/3-tooling/cli/src/converge-contract.ts index 215b1c513..6dce29636 100644 --- a/packages/0-framework/3-tooling/cli/src/converge-contract.ts +++ b/packages/0-framework/3-tooling/cli/src/converge-contract.ts @@ -6,4 +6,4 @@ * other side cannot read. Imports nothing, so a host can read it before * loading the converge. */ -export const CONVERGE_CONTRACT = 1; +export const CONVERGE_CONTRACT = 2; diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index 60d555b77..cc7f7c195 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -7,10 +7,19 @@ import { describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import type { + ContainerCredentials, + ContainerDescriptor, + LocateContainerInput, + PrismaAppConfig, +} from '@internal/core/config'; +import { lower } from '@internal/core/deploy'; import { CliStructuredError } from '@internal/foundation/errors'; import { notOk, ok } from '@internal/foundation/result'; import type { TestCli } from '@prisma/cli-engine/testing'; import { createTestCli, mintTestJwt } from '@prisma/cli-engine/testing'; +import * as Layer from 'effect/Layer'; +import { deployWithDeps } from '../../operations/deploy.ts'; import { createOperationsDouble, type OperationsDouble } from '../../testing/operations-double.ts'; import { createComposerFamily } from '../family.ts'; import { commandAt } from './command-at.ts'; @@ -210,42 +219,36 @@ describe('credentials', () => { expect(double.calls.deploy).toEqual([]); }); - test('a `prisma login` session reaches the operation with no service token, so the operation refuses it', async () => { + test('a `prisma login` session reaches the operation as the engine client and its workspace, with no token', async () => { const { cli, double } = composerCli({ signedIn: 'session' }); await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); const credentials = double.calls.deps.deploy[0]?.credentials; expect(credentials?.workspaceId).toBe('ws_1'); - expect(credentials?.serviceToken).toBeUndefined(); + expect(credentials?.client).toBeDefined(); + expect(Object.keys(credentials ?? {}).sort()).toEqual(['client', 'workspaceId']); }); - /** - * Alchemy runs in this process and reads its credential from - * `process.env`, so the handler hands the operation the environment token - * and that token's workspace, for the operation to set there for the apply. - */ - test('an environment token reaches the operation with its own workspace, and no child is spawned', async () => { + test('an environment token reaches the operation the same way, and no child is spawned', async () => { const { cli, double } = composerCli(); const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); expect(result.exitCode).toBe(0); expect(result.spawns).toEqual([]); const credentials = double.calls.deps.deploy[0]?.credentials; - expect(credentials?.serviceToken).toEqual(expect.any(String)); expect(credentials?.workspaceId).toBe('ws_1'); + expect(Object.keys(credentials ?? {}).sort()).toEqual(['client', 'workspaceId']); expect(double.calls.deploy[0]?.signal).toBeInstanceOf(AbortSignal); }); - test('with both a session and an environment token, the token and the workspace come from one credential', async () => { + test("with both a session and an environment token, the workspace is the active credential's", async () => { const { cli, double } = composerCli({ signedIn: 'both' }); await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); - const credentials = double.calls.deps.deploy[0]?.credentials; - expect( - credentials?.serviceToken === undefined - ? credentials?.workspaceId - : `${credentials.workspaceId} with a token`, - ).toBeOneOf(['ws_session', 'ws_env with a token']); + expect(double.calls.deps.deploy[0]?.credentials?.workspaceId).toBeOneOf([ + 'ws_session', + 'ws_env', + ]); }); test("a provider's console.log during the operation goes to stderr, and console is restored after", async () => { @@ -282,6 +285,135 @@ describe('credentials', () => { }); }); +describe('a stored session', () => { + const coreIndex = path.resolve( + import.meta.dir, + '../../../../../1-core/core/src/exports/index.ts', + ); + const unused = () => { + throw new Error('the converge is faked, so no descriptor body runs'); + }; + + function appDir(): { readonly dir: string; readonly entry: string } { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'composer-session-'))); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'session-app' })); + fs.writeFileSync(path.join(dir, 'prisma.config.ts'), 'export default {};\n'); + const entry = path.join(dir, 'service.ts'); + fs.writeFileSync( + entry, + [ + `import { module, service } from ${JSON.stringify(coreIndex)};`, + "export default module('session-app', {}, ({ provision }) => {", + " provision(service({ name: 'app', extension: 'fixture-extension', type: 'fixture/compute', inputs: {}, params: {},", + " build: { extension: 'fixture-build', type: 'node', module: import.meta.url, entry: 'dist/server.js' } }), { id: 'app' });", + ' return {};', + '});', + ].join('\n'), + ); + return { dir, entry }; + } + + test('deploys with no PRISMA_SERVICE_TOKEN: the engine client reaches the providers and state, and no credential enters process.env', async () => { + const app = appDir(); + const seen: { credentials: ContainerCredentials[]; envDuringApply: unknown[] } = { + credentials: [], + envDuringApply: [], + }; + const container: ContainerDescriptor = { + ensure: async (input, credentials) => { + if (credentials !== undefined) seen.credentials.push(credentials); + return { input, serialize: () => JSON.stringify(input) }; + }, + locate: async () => undefined, + remove: async () => {}, + deserialize: (serialized) => { + const input: LocateContainerInput = JSON.parse(serialized); + return { input, serialize: () => serialized }; + }, + }; + const section: PrismaAppConfig = { + extensions: [ + { + id: 'fixture-extension', + nodes: { + 'fixture/compute': { + kind: 'service', + provision: unused, + serialize: unused, + package: unused, + deploy: unused, + }, + }, + container, + providers: (credentials) => { + if (credentials !== undefined) seen.credentials.push(credentials); + return Layer.empty; + }, + }, + { id: 'fixture-build', nodes: { node: { kind: 'build', assemble: unused } } }, + ], + state: { + extension: 'fixture-extension', + create: (_container, credentials) => { + if (credentials !== undefined) seen.credentials.push(credentials); + return Layer.empty as never; + }, + }, + }; + const double = createOperationsDouble({}); + const family = createComposerFamily({ + operations: { + ...double.operations, + deploy: (input, deps) => + deployWithDeps(input, { + ...deps, + runAssembler: async () => ({ dir: app.dir, entry: 'server.js' }), + converge: { + lower, + converge: async () => { + seen.envDuringApply.push( + Object.keys(process.env).filter((name) => + ['PRISMA_SERVICE_TOKEN', 'PRISMA_WORKSPACE_ID'].includes(name), + ), + ); + return { kind: 'applied' }; + }, + }, + }), + }, + }); + const cli = createTestCli({ + commandFamilies: [family], + commands: { deploy: commandAt(family, 'deploy') }, + config: { composer: section }, + credential: activeCredential(), + }); + const saved = { + token: process.env['PRISMA_SERVICE_TOKEN'], + ws: process.env['PRISMA_WORKSPACE_ID'], + }; + delete process.env['PRISMA_SERVICE_TOKEN']; + delete process.env['PRISMA_WORKSPACE_ID']; + try { + const result = await cli.run(['deploy', '--entry', app.entry], { ...AS_TTY, cwd: app.dir }); + + expect(plain(result.stderr)).not.toContain('✘'); + expect(result.exitCode).toBe(0); + expect(seen.envDuringApply).toEqual([[]]); + // The container, the providers and the state store each got the same engine client and workspace. + expect(seen.credentials).toHaveLength(3); + const [first] = seen.credentials; + expect(first?.workspaceId).toBe('ws_1'); + expect(first?.client).toBeDefined(); + for (const credentials of seen.credentials) expect(credentials.client).toBe(first?.client); + } finally { + if (saved.token !== undefined) process.env['PRISMA_SERVICE_TOKEN'] = saved.token; + if (saved.ws !== undefined) process.env['PRISMA_WORKSPACE_ID'] = saved.ws; + fs.rmSync(app.dir, { recursive: true, force: true }); + } + }); +}); + describe('settlement', () => { test('a failed converge renders the normal envelope and exits 2', async () => { const { cli } = composerCli({ diff --git a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts index 6d2b60848..3214bff93 100644 --- a/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts +++ b/packages/0-framework/3-tooling/cli/src/family/commands/deploy.ts @@ -7,7 +7,7 @@ * deploy targets the environment production when no `--environment` is given. */ import type { EngineEvent } from '@prisma/cli-engine'; -import { defineCommand, flag, SERVICE_TOKEN_ENV_VAR } from '@prisma/cli-engine'; +import { defineCommand, flag } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import { DEFAULT_DEPLOY_ENVIRONMENT, @@ -149,13 +149,10 @@ export const createDeployCommand = (operations: ComposerOperations) => }, needs: { config: composerSection, credentials: true }, handler: async (args, ctx) => { - // The engine resolved which credential this run acts as. Only a service - // token from the environment can reach Alchemy, and its token and - // workspace travel together; a stored session carries no token, so the - // operation refuses it with DEPLOY.SERVICE_TOKEN_REQUIRED. + // Every Management API request the deploy makes goes through ctx.api, + // which authenticates and refreshes; the workspace is the same + // credential's. const credential = await ctx.activeCredential(); - const serviceToken = - credential?.origin.source === 'environment' ? ctx.env[SERVICE_TOKEN_ENV_VAR] : undefined; const result = await withConsoleOnStderr(() => operations.deploy( { @@ -173,7 +170,6 @@ export const createDeployCommand = (operations: ComposerOperations) => credentials: { workspaceId: credential?.workspaceId, client: ctx.api, - serviceToken, }, }, ), diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index c26b04f29..9df4ee4ff 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -20,7 +20,7 @@ import type { PrismaAppConfig, RunOutcome, } from '@internal/core/config'; -import { lower } from '@internal/core/deploy'; +import { type LowerOptions, lower } from '@internal/core/deploy'; import type { LocalTargetAttachment, LocalTargetCurrentApp, @@ -208,7 +208,7 @@ function appConverge(converge: Converge): AppConverge { return { lower, converge }; } -// Deploy needs a service token; a caller passing no credentials leaves it to the environment. +// A caller passing no credentials leaves the token to the environment. const previousServiceToken = process.env['PRISMA_SERVICE_TOKEN']; beforeAll(() => { process.env['PRISMA_SERVICE_TOKEN'] = 'test-service-token'; @@ -252,11 +252,7 @@ describe('deploy()', () => { spawned = true; return { exitCode: 0, signal: null }; }, - credentials: { - workspaceId: 'ws_handler', - client: {}, - serviceToken: 'token-from-the-handler', - }, + credentials: { workspaceId: 'ws_handler', client: {} }, }, ), ); @@ -266,7 +262,7 @@ describe('deploy()', () => { expect(requests[0]).toMatchObject({ stage: 'ci-7', cwd: app.dir, - env: { PRISMA_SERVICE_TOKEN: 'token-from-the-handler', PRISMA_WORKSPACE_ID: 'ws_handler' }, + env: {}, }); expect(requests[0]?.stack.stackName).toBe('hello-ops'); expect(spawned).toBe(false); @@ -817,52 +813,32 @@ describe('deploy() credentials, app converge and redaction', () => { ), ); - test('a credential that is not a service token is refused before any container is ensured', async () => { + test("the caller's credentials reach the lowering as a value, and no credential enters the converge's environment", async () => { const app = makeAppDir(); const containerCalls: ContainerCall[] = []; const requests: ConvergeRequest[] = []; + const lowered: LowerOptions[] = []; + const credentials = { workspaceId: 'ws_1', client: {} }; const result = await deployWith( app, { - converge: appConverge(recordingConverge(requests)), - credentials: { workspaceId: 'ws_1', client: {} }, + converge: { + lower: (root, config, opts) => { + lowered.push(opts); + return lower(root, config, opts); + }, + converge: recordingConverge(requests), + }, + credentials, }, fakeConfig({}, { calls: containerCalls }), ); - if (result.ok) throw new Error('expected a failure'); - expect(result.failure.code).toBe('DEPLOY.SERVICE_TOKEN_REQUIRED'); - expect(containerCalls).toEqual([]); - expect(requests).toEqual([]); - }); - - test('without credentials or PRISMA_SERVICE_TOKEN in the environment, deploy is refused', async () => { - const app = makeAppDir(); - const saved = process.env['PRISMA_SERVICE_TOKEN']; - delete process.env['PRISMA_SERVICE_TOKEN']; - try { - const result = await deployWith(app, { converge: appConverge(applied) }); - if (result.ok) throw new Error('expected a failure'); - expect(result.failure.code).toBe('DEPLOY.SERVICE_TOKEN_REQUIRED'); - } finally { - process.env['PRISMA_SERVICE_TOKEN'] = saved; - } - }); - - test('a credential that names no workspace removes PRISMA_WORKSPACE_ID for the apply', async () => { - const app = makeAppDir(); - const requests: ConvergeRequest[] = []; - - await deployWith(app, { - converge: appConverge(recordingConverge(requests)), - credentials: { workspaceId: undefined, client: {}, serviceToken: 'tok-1234567890' }, - }); - - expect(requests[0]?.env).toEqual({ - PRISMA_SERVICE_TOKEN: 'tok-1234567890', - PRISMA_WORKSPACE_ID: undefined, - }); + if (!result.ok) throw result.failure; + expect(containerCalls.map((call) => call.op)).toEqual(['ensure']); + expect(lowered[0]?.credentials).toBe(credentials); + expect(requests[0]?.env).toEqual({}); }); test('an app with no installed @prisma/composer fails with DEPS.CONVERGE_UNRESOLVABLE before any container is ensured', async () => { @@ -939,49 +915,58 @@ describe('deploy() credentials, app converge and redaction', () => { const app = makeAppDir(); const token = 'tok-SENTINEL-abcdef123456'; const events: DeployEvent[] = []; - const credentials = { workspaceId: 'ws_1', client: {}, serviceToken: token }; - - const failed = await deployWith( - app, - { - credentials, - converge: appConverge(async (request) => { - request.onEvent?.({ kind: 'log', severity: 'warn', text: `retrying with ${token}` }); - request.onEvent?.({ - kind: 'resource', - fqn: 'app', - resourceType: 'Prisma.Deployment', - status: 'fail', - phase: 'failed', - message: `401 for Bearer ${token}`, - }); - return { kind: 'failed', reasons: [{ tag: 'PrismaApiError', message: `bad ${token}` }] }; - }), - }, - fakeConfig(), - (event) => events.push(event), - ); - const stateBroken = fakeConfig(); - const stackError = await deployWith( - app, - { - credentials, - converge: { - lower: () => { - throw new Error(`state store refused ${token}`); + const credentials = { workspaceId: 'ws_1', client: {} }; + // A service token reaches deploy only through the environment the user set. + const saved = process.env['PRISMA_SERVICE_TOKEN']; + process.env['PRISMA_SERVICE_TOKEN'] = token; + try { + const failed = await deployWith( + app, + { + credentials, + converge: appConverge(async (request) => { + request.onEvent?.({ kind: 'log', severity: 'warn', text: `retrying with ${token}` }); + request.onEvent?.({ + kind: 'resource', + fqn: 'app', + resourceType: 'Prisma.Deployment', + status: 'fail', + phase: 'failed', + message: `401 for Bearer ${token}`, + }); + return { + kind: 'failed', + reasons: [{ tag: 'PrismaApiError', message: `bad ${token}` }], + }; + }), + }, + fakeConfig(), + (event) => events.push(event), + ); + const stateBroken = fakeConfig(); + const stackError = await deployWith( + app, + { + credentials, + converge: { + lower: () => { + throw new Error(`state store refused ${token}`); + }, + converge: applied, }, - converge: applied, }, - }, - stateBroken, - ); + stateBroken, + ); - expect(JSON.stringify(events)).not.toContain('SENTINEL'); - expect(events.filter((event) => event.kind === 'message')).toHaveLength(2); - if (failed.ok || stackError.ok) throw new Error('expected failures'); - expect(JSON.stringify(failed.failure)).not.toContain('SENTINEL'); - expect(stackError.failure.code).toBe('DEPLOY.ENGINE_FAILED'); - expect(stackError.failure.message).toBe('state store refused [redacted]'); + expect(JSON.stringify(events)).not.toContain('SENTINEL'); + expect(events.filter((event) => event.kind === 'message')).toHaveLength(2); + if (failed.ok || stackError.ok) throw new Error('expected failures'); + expect(JSON.stringify(failed.failure)).not.toContain('SENTINEL'); + expect(stackError.failure.code).toBe('DEPLOY.ENGINE_FAILED'); + expect(stackError.failure.message).toBe('state store refused [redacted]'); + } finally { + process.env['PRISMA_SERVICE_TOKEN'] = saved; + } }); test('a refused converge is DEPLOY.CONVERGE_RUNNING or DEPLOY.WORKER_THREAD', async () => { @@ -3051,7 +3036,7 @@ describe('deploy-run reporting', () => { { runAssembler: fakeAssembler, converge: appConverge(applied), - credentials: { workspaceId: 'ws-1', client: {}, serviceToken: 'tok' }, + credentials: { workspaceId: 'ws-1', client: {} }, }, ), ); diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index c30a43647..6de1b9704 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -398,10 +398,7 @@ async function runStackPipelineInner( if (!setup.ok) return setup; converge = setup.value; } - const platformCredentials = - deps.credentials === undefined - ? undefined - : { workspaceId: deps.credentials.workspaceId, client: deps.credentials.client }; + const platformCredentials = deps.credentials; // Destroy-only guardrail — first, ahead of every other step, so it // surfaces even when the rest of the pipeline goes on to fail for an @@ -776,55 +773,19 @@ async function runStackPipelineInner( interface ConvergeSetup { readonly appConverge: AppConverge; - /** The credential variables Alchemy reads, set for the length of the apply; `undefined` removes one. */ - readonly credentialEnv: Readonly>; } -/** - * Alchemy runs in this process and reads its Prisma credential only from the - * environment, and the engine gives a command no way to read a `prisma login` - * session's token. So deploy needs a service token. - */ -function serviceTokenRequired(): CliStructuredError { - return new CliStructuredError( - 'DEPLOY.SERVICE_TOKEN_REQUIRED', - 'Deploy needs PRISMA_SERVICE_TOKEN in the environment.', - { - why: 'Alchemy applies the deploy in this process and reads its Prisma credential from the environment. A `prisma login` session cannot reach it yet.', - fix: 'Create a service token for the workspace in the Prisma Console and set PRISMA_SERVICE_TOKEN to it, then deploy again.', - }, - ); -} - -/** - * What deploy's converge needs before anything is created: the service token, - * and the app's own install of the converge. A caller that supplies credentials - * supplies the token with them, and the token and workspace always come from - * that one credential; a caller that supplies none leaves both to the - * environment. - */ +/** What deploy's converge needs before anything is created: the app's own install of the converge. */ async function prepareConverge( deps: OperationDeps, appDir: string, ): Promise> { - const credentials = deps.credentials; - const token = - credentials === undefined ? process.env['PRISMA_SERVICE_TOKEN'] : credentials.serviceToken; - if (token === undefined || token.length === 0) return notOk(serviceTokenRequired()); - let appConverge: AppConverge; try { - appConverge = deps.converge ?? (await loadAppConverge(appDir)); + return ok({ appConverge: deps.converge ?? (await loadAppConverge(appDir)) }); } catch (error) { if (CliStructuredError.is(error)) return notOk(error); throw error; } - return ok({ - appConverge, - credentialEnv: - credentials === undefined - ? {} - : { PRISMA_SERVICE_TOKEN: token, PRISMA_WORKSPACE_ID: credentials.workspaceId }, - }); } /** One Alchemy progress event as the deploy's step events: each resource is a step inside `apply`. */ @@ -941,7 +902,7 @@ async function convergeDeploy( ): Promise> { const { cwd, environment, steps } = opts; const config = pipeline.configSource.value; - const env = { ...resolved.env, ...setup.credentialEnv }; + const env = resolved.env; const redact = convergeRedactor( { ...process.env, ...env }, poolSecrets(pipeline.pools, config.secrets !== undefined), @@ -962,6 +923,7 @@ async function convergeDeploy( bundles: pipeline.assembled.bundles, containers: resolved.containers, preflight: resolved.preflight, + credentials: opts.deps.credentials, report: (result) => { summary = toDeploymentSummary(result); }, diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index 2b9ffbb9b..4034fdb82 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -63,18 +63,7 @@ export interface OperationDeps { * call", and both commands are credential-free by contract: there is no * authenticated call on those paths for this to reach. */ - readonly credentials?: OperationCredentials | undefined; -} - -/** The caller's credential: what the in-process leg authenticates with, and the service token Alchemy applies `deploy` with. */ -export interface OperationCredentials extends ContainerCredentials { - /** - * The service token the credential is. Absent when it is not one, such as a - * `prisma login` session: `deploy` then refuses with - * DEPLOY.SERVICE_TOKEN_REQUIRED, because Alchemy reads its credential only - * from the environment. - */ - readonly serviceToken?: string | undefined; + readonly credentials?: ContainerCredentials | undefined; } /** From 53cf8b63f64e6bcc956fe4d404c567a1b50fdc0e Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:21:57 +0100 Subject: [PATCH 09/14] docs: deploy authenticates through ctx.api ADR-0064's credentials section now records that every Management API request goes through the engine's client, and why: cli-engine keeps tokens away from commands. The deploy-cli domain doc, ADR-0044's code list, the guides and the core-concepts skill drop DEPLOY.SERVICE_TOKEN_REQUIRED, and the guides again say prisma auth login is enough for prisma deploy. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/deploy-cli.md | 5 ++--- ...uctural-envelopes-with-dotted-namespace-codes.md | 7 +++---- .../ADR-0064-deploy-runs-alchemy-in-process.md | 13 +++++++------ docs/design/90-decisions/README.md | 4 ++-- docs/guides/deploying.md | 2 +- docs/guides/getting-started.md | 6 +++--- skills/prisma-composer-core-concepts/SKILL.md | 7 +++---- 7 files changed, 21 insertions(+), 23 deletions(-) diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 52abc1c48..f90494238 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -189,8 +189,8 @@ user can rerun it by hand (ADR-0007). - **Result.** The report hook runs inside the apply, in the same process, so the deployment summary comes back as a value. - **Failure.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`. Its message carries the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's (`--adopt`) and every credential and pool secret removed. - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI reports that code as a message (a frame in JSON) and exits 130 through the engine's own abort (`CLI.ABORTED`). - - **Process state.** Alchemy reads its configuration only from `process.env` and `.env` in the working directory. For the length of the plan and apply, `deploy` sets the service token, the workspace id and each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere. It restores all of them on every exit path. One process runs one converge at a time, across every install it loads (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). - - **Credentials.** Alchemy needs a service token. The engine gives a command no way to read a `prisma login` session's token, so the command passes a token only when the active credential came from `PRISMA_SERVICE_TOKEN`, with that credential's workspace. The operation checks for it before preflight and before any container is ensured, and fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` without one. + - **Process state.** For the length of the plan and apply, `deploy` sets each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere, because Alchemy reads `.env` and keeps `.alchemy` there. It restores both on every exit path. One process runs one converge at a time, across every install it loads (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). + - **Credentials.** The command passes the engine's `ctx.api` and the active credential's workspace as the operation's `credentials`, and `lower()` receives them as `LowerOptions.credentials`. Every Management API request the deploy makes goes through that client, which sets the bearer token and refreshes it: Composer's own resources call it, and Alchemy's Prisma providers and state store reach it through an Effect `HttpClient` that forwards Management API requests to it and sends other hosts (presigned upload URLs) to node's transport. No token reaches Composer's code and none is written into `process.env`, so a `prisma login` session deploys like `PRISMA_SERVICE_TOKEN` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). A caller of the operation that passes no credentials gets a client built from `PRISMA_SERVICE_TOKEN`. `destroy` (and `prisma dev`) write the pipeline's results as a runnable stack module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one environment variable per extension — content the CLI writes but never reads (ADR-0038). `alchemy destroy` re-imports and re-evaluates the same stack, so its target reconstruction needs the containers. The generated file and Alchemy's state live in the process's working directory — tool state lives where you run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. @@ -299,7 +299,6 @@ them: | Built output missing | the expected path, and "run your build" | | Root's input refused by its schema | the schema's messages, and that a root's input is the config pool for the environment | | A config adapter throws or returns something other than an object (`CONFIG.ADAPTER_FAILED`, `CONFIG.ADAPTER_INVALID`) | which list and position, and the fix | -| No `PRISMA_SERVICE_TOKEN` in the environment for `deploy` (`DEPLOY.SERVICE_TOKEN_REQUIRED`) | to set it to a workspace service token | | Alchemy's plan or apply failed (`DEPLOY.ENGINE_FAILED`) | the errors Alchemy failed with | | Ctrl-C during the plan or apply (`DEPLOY.INTERRUPTED`) | to deploy again; the lease is already released | diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index 0b564b863..bfd44a41f 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -1,6 +1,6 @@ # ADR-0044: Errors are structural envelopes with dotted namespace codes -Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added. An interrupted `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message and ends with the engine's `CLI.ABORTED`, exiting 130 (143 for SIGTERM). +Amended by [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` starts no Alchemy child, so the child exit-status passthrough below applies only to `prisma dev` and `destroy`. A failed `deploy` plan or apply is `DEPLOY.ENGINE_FAILED` and exits 2. `DEPLOY.INTERRUPTED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added. An interrupted `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message and ends with the engine's `CLI.ABORTED`, exiting 130 (143 for SIGTERM). Amended 2026-10-23: the `CONFIG` registry gains ENTRY_MISSING, for a command or operation that has no entry module from the config or an override. @@ -102,14 +102,13 @@ Closed subcode registry: CONTAINER_FAILED, PREFLIGHT_FAILED, ENGINE_FAILED, TEARDOWN_FAILED, CONTAINER_REMOVE_FAILED, BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED, - INTERRUPTED, SERVICE_TOKEN_REQUIRED, CONVERGE_RUNNING, WORKER_THREAD + INTERRUPTED, CONVERGE_RUNNING, WORKER_THREAD *(STAGE_INVALID, STAGE_UNVALIDATABLE and SCOPE_MISSING retired by [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md))*. ENVIRONMENT_REQUIRED: a `destroy` call names no environment. ENGINE_FAILED: Alchemy failed. `deploy` runs Alchemy in process and exits 2; `destroy` and `prisma dev` pass the Alchemy child's exit status through (see § Exit codes). INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. `prisma deploy` exits 130 (143 for SIGTERM): it reports this code as a message event and ends with the engine's abort, `CLI.ABORTED`. - SERVICE_TOKEN_REQUIRED: `deploy`'s credential is not a service token, such as a `prisma login` session, or there is none in the environment ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). Temporary: it is retired when `@prisma/cli-engine` gives a command the active access token (the in-process credentials need, `needs: { credentials: 'process' }`, asked of `@prisma/cli-engine`), so a session can deploy. - CONVERGE_RUNNING: another in-process converge holds this process's environment and working directory. + CONVERGE_RUNNING: another in-process converge holds this process's working directory and the reporter variables it sets in the environment. WORKER_THREAD: `deploy` was called from a worker thread with a `cwd` other than the process's working directory, which a worker cannot change. `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index 06c7fa144..74ea18fa9 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -19,14 +19,14 @@ yield* Alchemist.apply(plan); This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. - **The app's install runs the converge.** The executor loads `lower` and `convergeInProcess` (together, the app converge) from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from that install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the converge from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entries fails with `DEPS.CONVERGE_UNRESOLVABLE` before anything is created. -- **Host and app speak one converge contract.** The host's operation code calls the app's `lower` and converge and reads back their results, so both sides must agree on the shapes between them: `LowerOptions`, `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and `DeploymentResult`. They need not run the same package version. The `prisma` host pins its Composer and ships on its own schedule (ADR-0050), and create-prisma pins the app's, so the two versions differ between releases, when an app keeps an older Composer, and on dev builds; an exact version match would refuse every deploy in those cases. So the app's install exports a `CONVERGE_CONTRACT` integer from `@prisma/composer/internal/converge-contract`, an entry that imports nothing. The host reads it before loading anything else and refuses a different number with `DEPS.CONVERGE_CONTRACT_MISMATCH`, which names both contract numbers and both package versions and says which side to upgrade. The number is bumped only when one of those shapes changes in a way the other side cannot read. +- **Host and app speak one converge contract.** The host's operation code calls the app's `lower` and converge and reads back their results, so both sides must agree on the shapes between them: `LowerOptions` (contract 2 added `credentials`, which an older app would ignore and then look for a token in the environment), `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and `DeploymentResult`. They need not run the same package version. The `prisma` host pins its Composer and ships on its own schedule (ADR-0050), and create-prisma pins the app's, so the two versions differ between releases, when an app keeps an older Composer, and on dev builds; an exact version match would refuse every deploy in those cases. So the app's install exports a `CONVERGE_CONTRACT` integer from `@prisma/composer/internal/converge-contract`, an entry that imports nothing. The host reads it before loading anything else and refuses a different number with `DEPS.CONVERGE_CONTRACT_MISMATCH`, which names both contract numbers and both package versions and says which side to upgrade. The number is bumped only when one of those shapes changes in a way the other side cannot read. - **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as required values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Only the generated stack files for `destroy` and `prisma dev` read the environment transports, through one core helper, `transportedLowerInputs(config)`. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. - **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. - **Failures are structured.** The converge returns the errors Alchemy failed with as typed reasons (tag and message), and the deploy maps them to `DEPLOY.ENGINE_FAILED`, with Composer's advice in place of the Alchemy CLI's and secrets removed. A converge that rejects instead of returning an outcome is `DEPLOY.ENGINE_FAILED` too. An interrupted one is `DEPLOY.INTERRUPTED`. Every message event, and an error raised while the stack is built, is redacted the same way. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. - **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. The engine exits 130 (143 for SIGTERM) only when a handler ends with its abort, not when it returns an error, so `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message event, which JSON output carries as a frame with the code, and then ends with the engine's abort (`CLI.ABORTED`). -- **The credential is an environment variable.** Alchemy reads its configuration only from `process.env` and `.env`. `deploy` sets `PRISMA_SERVICE_TOKEN`, `PRISMA_WORKSPACE_ID` and each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. Only Alchemy's credentials and working directory need process state; the reporter variables ride the same overlay until a reporter can read them as values. `@prisma/cli-engine` 0.9.0 gives a command no way to read a `prisma login` session's token. So the operation checks for a service token before preflight and before any container is ensured, and refuses with `DEPLOY.SERVICE_TOKEN_REQUIRED`. The command passes a token only when the engine's active credential came from the environment, with that same credential's workspace; when the credential names no workspace, `PRISMA_WORKSPACE_ID` is removed for the apply. A token from one credential is never paired with a workspace from another. -- **One converge per process.** Because of that process state, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`. The flag lives on `globalThis` under a `Symbol.for` key, so it holds across every install of the entry a host loads, such as two apps deployed from one script; and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. +- **Every Management API request goes through `ctx.api`.** `@prisma/cli-engine` keeps token material away from commands: a command never holds a token, `ctx.api` (the Management API SDK client over a fetch that sets the bearer token and refreshes it) is the only API client in the process, and the engine's `process.env` is never changed (prisma-cli `docs/architecture/credential-manager.md`). So `prisma deploy` passes `ctx.api` and the active credential's workspace to the operation as `credentials`, and the operation hands them to `lower()` as a value (`LowerOptions.credentials`). Core gives them to each extension's `providers(credentials)` and to the state descriptor's `create(container, credentials)`. The Prisma Cloud extension builds its provider bundle and its state store over that client: Composer's own resources call it directly, and Alchemy's Prisma providers and state store get an Effect `HttpClient` that sends each request to the Management API origin through the client's per-method calls, with the body, query string and headers unchanged and the response read as a stream. Requests to any other host, such as the presigned artifact upload URLs, go to node's transport unauthenticated, because those uploads need node's explicit `Content-Length`. Alchemy's `PrismaEnvironment` and Distilled's `Credentials` carry only the base URL, so neither sets an `Authorization` header (both made the token optional upstream for this, alchemy-run/alchemy#3101 and alchemy-run/distilled#1076). A `prisma login` session therefore deploys like a service token, and deploy writes no credential into `process.env`. A caller of the `deploy` operation that passes no `credentials`, such as a script, gets a client the extension builds from `PRISMA_SERVICE_TOKEN`, used the same way. Deploy still sets each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. +- **One converge per process.** Because of the reporter variables and the working directory, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`. The flag lives on `globalThis` under a `Symbol.for` key, so it holds across every install of the entry a host loads, such as two apps deployed from one script; and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. - **Stdout stays the engine's.** Alchemy's log lines and events go to the caller as values, and Alchemy's non-interactive `Interaction` writes to stderr. The `prisma deploy` command, not the operation, sends `console`'s stdout methods to stderr while the operation runs, so a provider's `console.log` cannot corrupt JSON output; a host calling the operation keeps its own `console`, as ADR-0043 requires. A provider that writes to `process.stdout` directly is out of reach. ## Reasoning @@ -38,11 +38,11 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy ## Consequences - `deploy` no longer resolves Alchemy's bin or needs Node on PATH under Bun. `DEPLOY.ALCHEMY_BIN_MISSING`, `DEPLOY.NODE_MISSING` and `DEPLOY.STACK_WRITE_FAILED` remain for `destroy` and `prisma dev`. -- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH`. +- New codes: `DEPLOY.INTERRUPTED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH`. - The converge relies on two Alchemy modules outside `alchemy/Alchemist`: `alchemy/Util/LogRecord` (to read log records) and `alchemy/Interaction` (to send interaction output to stderr). - `prisma deploy` declares `needs: { credentials: true }` and no longer `maySpawn`. -- Setting `process.env` and the working directory for the length of the apply is process-wide state, so one process runs one converge at a time. The upstream asks below remove the need. -- Interactive deploys signed in with `prisma login` need `PRISMA_SERVICE_TOKEN` until `@prisma/cli-engine` gives a command the active access token, or Alchemy lets a caller pass configuration to `plan` instead of reading only the environment. +- Setting the reporter variables and the working directory for the length of the apply is process-wide state, so one process runs one converge at a time. +- `@prisma/composer-prisma-cloud` depends on Alchemy and Distilled releases that accept a `PrismaEnvironment` and `Credentials` without a token. ## Alternatives considered @@ -50,6 +50,7 @@ The bisection argument weakens with the API. A failed deploy now reports Alchemy - **Keep generating `alchemy.run.ts` and load it with `Alchemist.importStack`.** Rejected: the file existed so a failed run could be rerun with the Alchemy CLI; writing code to import it back is circuitous. - **`SigilRenderer` for output.** Rejected: it would give `prisma deploy` a second output format and print nothing in JSON mode. - **Read the stored token by spawning a process.** Rejected: it would bring back a child for credentials alone. +- **Ask the engine for the active access token (`needs: { credentials: 'process' }`) and set it in `process.env`.** Rejected: it breaks all three of the engine's credential rules. The token would sit in Composer's code and in `process.env`, and a long apply could outlive it, since only `ctx.api` refreshes it. ## Related diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 57a4bb012..634a97399 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -65,7 +65,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [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 the Postgres emulator hosts one `@prisma/dev` server per database instance. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`OrmMigration` 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). *(Amended by ADR-0050: the command is `prisma dev`; logs are the `log` operation, not a command. Amended 2026-10-23: the entry is the `composer` section's `entry` or `--entry`, not a positional. Amended by ADR-0052: emulator instances are keyed by emulator-generated ids, not the app name, Composer maps each app's nodes to them per directory, and `prisma dev` stops the app's database servers when it ends.)* *(Amended by ADR-0056: a service runs the dev server its build adapter declares in place of its artifact, as a command deployment on the Compute emulator.)* *(Amended by ADR-0062: local dev runs as a named environment, `dev` by default.)* - [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. *(Extended by ADR-0061 to modules.)* *(Amended 2026-10-30: the forwardable secret need is removed; a module takes a secret as an input field.)* *(Amended 2026-10-30, sixth slice: `field()` and `secretString()` ship from `@prisma/composer/arktype` and `@prisma/composer/zod`; `secretString()` accepts a secret pointer.)* *(Amended 2026-10-30, seventh slice: the deploy checks a service's schema with pointers in place; a field bound to `serviceOrigin()` or a plain `generatedParam()` is wrapped with `field()`; `service.input()` has no pointers.)* - [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. *(Amended by ADR-0049: each operation takes a required `config: { value, file }`, and the effect-conflict diagnosis is retired.)* *(Amended by ADR-0050: the `prisma` CLI renders two of the four operations, as `prisma deploy` and `prisma dev`; `destroy` and `log` are called from scripts.)* *(Amended by ADR-0053: `devUrl` and `devStop` join the operations.)* *(Amended by ADR-0059: `deploy` and `destroy` take `environment`, which `destroy` requires.)* *(Amended 2026-10-28: `devUrl` and `devStop` act on the app and environment in their input; `dev` and `log` take `environment`.)* *(Amended by ADR-0064: `deploy` returns the summary as a value; it uses no result file.)* -- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* *(Amended by ADR-0064: the child exit-status passthrough no longer applies to `deploy`, whose failures exit 2; `DEPLOY.INTERRUPTED`, `DEPLOY.SERVICE_TOKEN_REQUIRED` (temporary), `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added; an interrupted `prisma deploy` ends as `CLI.ABORTED` with exit 130.)* +- [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. *(Amended by ADR-0049: `DEPS.EFFECT_VERSION_CONFLICT` is retired. Amended 2026-10-23: `CONFIG.ENTRY_MISSING` is added. Amended by ADR-0059: the `DEPLOY` codes say environment instead of stage, and `DEPS.EXTENSION_INCOMPATIBLE` is added.)* *(Amended 2026-10-30: `CONFIG.ADAPTER_FAILED` and `CONFIG.ADAPTER_INVALID` are added.)* *(Amended by ADR-0064: the child exit-status passthrough no longer applies to `deploy`, whose failures exit 2; `DEPLOY.INTERRUPTED`, `DEPLOY.CONVERGE_RUNNING`, `DEPLOY.WORKER_THREAD`, `DEPS.CONVERGE_UNRESOLVABLE` and `DEPS.CONVERGE_CONTRACT_MISMATCH` are added; an interrupted `prisma deploy` ends as `CLI.ABORTED` with exit 130.)* - [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 ORM 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. @@ -85,4 +85,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its configuration as one Standard Schema, `input`, exactly as a compute service does; its body receives opaque per-key references it can only pass into children's inputs or derive from with `resolve()`, so the graph cannot branch on configuration. Module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, filled by config adapters. *(Amended 2026-10-29: an input field is a `Value`; the module's schema validates its binding during Load's second pass; a truthiness check on a field is not caught, but always goes the same way.)* *(Amended 2026-10-29, third slice: `paramSource()` and `secretSource()` return values and take an options argument.)* *(Amended 2026-10-30: a root's input is the config pool.)* *(Amended 2026-10-30, fifth slice: the slots are removed; `ModuleNode`.)* *(Amended 2026-10-30, seventh slice: a module's schema checks each runtime value as its pointer; `paramSource()` loses `sentinel`.)* *(Amended 2026-10-30, eighth slice: only a plain field can be a `resolve()` dependency.)* - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — The graph's structure is the same whatever inputs Composer is given; only values differ per environment. A module body holds values (`Value`), opaque stand-ins for data known later: deploy-time values, whose data the deploy has by the end of Load, and runtime values (`RuntimeValue`, which is `Value>`), whose data only the platform holds. `resolve(dependencies, fn)` computes a value from deploy-time values only, such as `environment.name` or a plain input field; `fn` receives their real values as one tuple after the graph is built, returns plain data, and never sees a pointer. Config adapters in the `composer` section's `config` list produce the configuration, which is the root's input and what `envParam()` reads; secrets adapters in its `secrets` list produce the secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Secrets stay out of deploy files, state and output through pointers and redaction. Every environment has a name and none is special: `prisma deploy` defaults `--environment` to `production`, `prisma dev` to `dev`; on Prisma Cloud `production` is the Project's default Branch. *(Amended by ADR-0064: `deploy` runs Alchemy in its first process and writes no configuration file; the second process applies to `prisma dev` and `destroy`.)* - [ADR-0063](ADR-0063-a-node-depends-on-an-interface-or-exposes-one.md) — The two ends of a connection are named by what the node does with them: a node **depends on** an interface (`deps`) or **exposes** one (`expose`); an interface is a `Contract` in code; Input and Output are retired as port words, because the dataflow and ports-and-adapters traditions give the same two ends opposite names; the module body's `inputs` becomes `deps`; the topology's `in`/`out` stay, defined as the dependency end and the exposed end. -- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. The credential reaches Alchemy through `process.env`; a `prisma login` session needs `PRISMA_SERVICE_TOKEN` (`DEPLOY.SERVICE_TOKEN_REQUIRED`). Lowering and Alchemy load from the app's own install of `@prisma/composer`, which must speak the host's converge contract. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `prisma deploy` and the `deploy` operation run Alchemy's plan and apply in the `prisma` process through `alchemy/Alchemist`: no generated stack file, no Alchemy child. `lower()` takes containers, preflight payloads and the report hook as values, the summary comes back as a value, Alchemy's events become engine step events, failures are `DEPLOY.ENGINE_FAILED` or `DEPLOY.INTERRUPTED`, and Ctrl-C releases the state lease. Every Management API request, Alchemy's and Composer's, goes through the engine's `ctx.api`, which `lower()` receives as `LowerOptions.credentials`, so a `prisma login` session deploys with no `PRISMA_SERVICE_TOKEN` and no credential enters `process.env`. Lowering and Alchemy load from the app's own install of `@prisma/composer`, which must speak the host's converge contract. Supersedes ADR-0007 for `deploy`; amends ADR-0038, ADR-0043, ADR-0044, ADR-0049 and ADR-0062. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index ae8563e09..9344ccf3d 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -23,7 +23,7 @@ code. The `prisma` commands and the operations you call from a script sign in differently: -- **`prisma deploy`** needs `PRISMA_SERVICE_TOKEN` in the environment, a service token you create in the workspace's settings in the [Prisma Console](https://console.prisma.io). It overrides any stored session. Deploy runs Alchemy in the `prisma` process, and Alchemy reads its credential only from the environment, so a `prisma auth login` session alone is not enough yet: without the variable, deploy fails with `DEPLOY.SERVICE_TOKEN_REQUIRED` before it does anything. `prisma dev` needs no sign-in. +- **`prisma deploy`** signs in the way every `prisma` command does. On your machine, run `prisma auth login` once; it opens a browser and stores a session for one workspace. In CI, set `PRISMA_SERVICE_TOKEN` instead; it overrides any stored session. `prisma dev` needs no sign-in. - **The operations of `@prisma/composer/control`**, and so a destroy script, never use a `prisma auth login` session. `deploy` and `destroy` read two environment variables, and each is required once the operation reaches diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 420535c7e..74beb21bb 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -342,14 +342,14 @@ shared contract code into a chunk neither output contains: pnpm run build ``` -Deploying needs a service token. Create one in your workspace's settings in the [Prisma Console](https://console.prisma.io) and set it as `PRISMA_SERVICE_TOKEN`, on your machine and in CI alike: +Deploying needs a signed-in `prisma`. Sign in once; it opens a browser and stores a session for one workspace: ```sh -export PRISMA_SERVICE_TOKEN= +pnpm prisma auth login pnpm prisma deploy ``` -A `prisma auth login` session alone is not enough for `prisma deploy` yet; without the token it fails with `DEPLOY.SERVICE_TOKEN_REQUIRED`. +In CI, set `PRISMA_SERVICE_TOKEN` to a service token from the [Prisma Console](https://console.prisma.io) instead. `pnpm prisma` runs under Node, which suits this app: the modules `prisma` loads declare services and never call Bun APIs themselves. An app whose diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index f8dd42733..a9c9cc4be 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -407,10 +407,9 @@ complete pattern. Deploy compares the declared topology against recorded deploy state and applies only the difference. Re-deploying with nothing changed is a no-op; -removing a node removes its deployed resource. `prisma deploy` needs -`PRISMA_SERVICE_TOKEN` in the environment, on a developer machine too: it runs -the deploy engine in its own process, and a `prisma auth login` session cannot -reach it yet (`DEPLOY.SERVICE_TOKEN_REQUIRED`). The `/control` operations, and so +removing a node removes its deployed resource. `prisma deploy` needs a signed-in +identity: `prisma auth login` stores a session on a developer machine, and +`PRISMA_SERVICE_TOKEN` overrides it in CI. The `/control` operations, and so a destroy script, never use that session: `deploy` and `destroy` read `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID` from environment variables (both in the workspace's Console settings); `dev` and `log` read neither. The From 5a21d7ce0f27d607139e65a47aaf438868eee8c4 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 13:33:41 +0100 Subject: [PATCH 10/14] build(deps): pin alchemy to the tarball @alchemy.run/prisma names as its peer npm ls compares dependency specs, so the pr:3101 URL and the tarball URL the provider names as its alchemy peer count as different packages even though one redirects to the other, and check:npm-effect-resolution failed on an invalid alchemy. Pinning the tarball URL makes the specs match. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- pnpm-lock.yaml | 74 +++++++++---------- pnpm-workspace.yaml | 2 +- .../upgrade-alchemy-effect/SKILL.md | 4 +- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624671080..ccab2f679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ catalogs: specifier: 4.1.2 version: 4.1.2 alchemy: - specifier: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95 + specifier: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz version: 2.0.0-beta.85 effect: specifier: 4.1.2 @@ -123,7 +123,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -151,7 +151,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -290,7 +290,7 @@ importers: version: link:../../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -336,7 +336,7 @@ importers: version: link:modules/storefront alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -500,7 +500,7 @@ importers: version: link:modules/storefront alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -648,7 +648,7 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -850,7 +850,7 @@ importers: devDependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/tsdown-config': specifier: workspace:0.36.0 version: link:../../0-foundation/tsdown-config @@ -865,7 +865,7 @@ importers: version: 26.1.1 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -929,7 +929,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/bundle-paths': specifier: workspace:0.36.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -950,7 +950,7 @@ importers: version: 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -990,7 +990,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@distilled.cloud/prisma': specifier: 'catalog:' version: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) @@ -1011,7 +1011,7 @@ importers: version: 1.80.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: specifier: 'catalog:' version: 4.1.2 @@ -1060,7 +1060,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@internal/core': specifier: workspace:0.36.0 version: link:../../../0-framework/1-core/core @@ -1099,7 +1099,7 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -1378,7 +1378,7 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -1448,7 +1448,7 @@ importers: version: 1.80.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -1494,7 +1494,7 @@ importers: dependencies: '@alchemy.run/prisma': specifier: 'catalog:' - version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) '@distilled.cloud/prisma': specifier: 'catalog:' version: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) @@ -1524,7 +1524,7 @@ importers: version: 1.1.0 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -1633,7 +1633,7 @@ importers: version: 26.1.1 alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) arktype: specifier: ^2.2.3 version: 2.2.7 @@ -1657,7 +1657,7 @@ importers: version: link:../packages/9-public/composer-prisma-cloud alchemy: specifier: 'catalog:' - version: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + version: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) devDependencies: '@prisma/composer-cli': specifier: workspace:* @@ -1687,11 +1687,11 @@ importers: packages: '@alchemy.run/node-utils@https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz': - resolution: {integrity: sha512-7tnqQqAvc7XZmKmDkG5PUqAiY6lauSzO+n//JybZ+g4oeooORf9I+Kx6wjnl46dLX6D6xy3XfUmMmRZR8KGREA==, tarball: https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz} + resolution: {tarball: https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz} version: 2.0.0-beta.85 '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95': - resolution: {integrity: sha512-39gVXh6e5Jsgkeqs7NneR9S44/XfHahNwv9K8/uPjRjYFtwKBgb4ijVI8haXhF2pQaySqxlk0n0XJ7lC5GLyFQ==, tarball: https://pkg.alchemy.run/@alchemy.run/prisma/-/04b1f57d7e5a865ec59a96ad6ec57c7a97bcd8cb7f6085763737f8ec835dc024.tgz} + resolution: {tarball: https://pkg.alchemy.run/@alchemy.run/prisma/-/04b1f57d7e5a865ec59a96ad6ec57c7a97bcd8cb7f6085763737f8ec835dc024.tgz} version: 2.0.0-beta.85 peerDependencies: '@effect/platform-bun': ^4.1.0 @@ -1995,31 +1995,31 @@ packages: resolution: {integrity: sha512-0SfkM/MA+oSIAzFFmjr0wZLH54oeCtugaOn8GpJDBnerQiML+rOPq2W6j6bBGE51o+N4DOd0tvMruLmkAQzlpw==} '@distilled.cloud/aws4signer@https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz': - resolution: {integrity: sha512-B5EwfP98W1K7ubC/UQF7No0bpu/9RSh/d8RZNvszNT9v+OvCGt/hmwd87mD2jVoYnfqEO6Sw6XEGHeisRz08hA==, tarball: https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz} + resolution: {tarball: https://pkg.alchemy.run/@distilled.cloud/aws4signer/-/335d948c83041df357777a81805526c7da93c6907d9d38b7891d4fca7b7174d9.tgz} version: 1.0.0-rc.18 peerDependencies: effect: ^4.1.2 '@distilled.cloud/aws@https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz': - resolution: {integrity: sha512-z78IsvD1o0D3+UcKfiKTzqTQlfBa2B6M2ucdnr8sCuM8LdvepQrzAh4E0ibtdF54PFwOaUisbnAmoXn4Xr9EJw==, tarball: https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz} + resolution: {tarball: https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz} version: 1.0.0-rc.18 peerDependencies: effect: ^4.1.2 '@distilled.cloud/core@https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz': - resolution: {integrity: sha512-YqFoDrVR5ZgZrsJRHBVUIH1eFBl5hlaKulsuvftBQcdzhCbA1/tIulBUx5B+pQDTTML4woCoiAA7mYHGHomt0A==, tarball: https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz} + resolution: {tarball: https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz} version: 1.0.0-rc.18 peerDependencies: effect: ^4.1.2 '@distilled.cloud/prisma@https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz': - resolution: {integrity: sha512-G0dAsiV4gc2Q07UeIVg9mH3XRUrJrXcGEzppy7gus/eHi9zqC5dVJXYBQRoeObdCJIykG+v5/+Yj6nAruEfFUA==, tarball: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz} + resolution: {tarball: https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz} version: 1.0.0-rc.18 peerDependencies: effect: ^4.1.2 '@distilled.cloud/s3@https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz': - resolution: {integrity: sha512-0jyIu+cKFiwhOUyRj5CzYHJ8FezjZxjKEhOrPHEQlNxy5YXDo7xtZ1eOEe9uqwwbnO5MbLrGc0XSDSJPcAI0lQ==, tarball: https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz} + resolution: {tarball: https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz} version: 1.0.0-rc.18 peerDependencies: effect: ^4.1.2 @@ -3652,7 +3652,7 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95: + alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz: resolution: {integrity: sha512-rv9uh8bZQa0Pgoni2wjSZv/iU2yoTzX2qmPG8KL2RLJrhF5YWDdfWTi2ZRKeoJJe6cgfoPksiCyvdG5J/CJLng==, tarball: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz} version: 2.0.0-beta.85 hasBin: true @@ -5685,13 +5685,13 @@ snapshots: chokidar: 5.0.0 rolldown: 1.2.16 - '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)': - dependencies: + ? '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)' + : dependencies: '@distilled.cloud/aws': https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz(effect@4.1.2) '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@prisma/dev': 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + alchemy: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: 4.1.2 rolldown: 1.2.16 optionalDependencies: @@ -5705,13 +5705,13 @@ snapshots: - typescript - vite - '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)': - dependencies: + ? '@alchemy.run/prisma@https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@prisma/orm-postgres@8.0.0-rc.20(@prisma/cli-engine@0.9.0(@prisma/management-api-sdk@1.80.0)(magicast@0.5.5))(magicast@0.5.5)(temporal-polyfill@1.0.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(@vercel/nft@1.10.2)(alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1))(effect@4.1.2)(prisma@8.0.0-rc.26(magicast@0.5.5)(typanion@3.14.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)))(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1)' + : dependencies: '@distilled.cloud/aws': https://pkg.alchemy.run/@distilled.cloud/aws/-/37a325c303a79aba416da30d03a972ef34263967530c1112de9c432bfbd2c29c.tgz(effect@4.1.2) '@distilled.cloud/core': https://pkg.alchemy.run/@distilled.cloud/core/-/f73c71a235264d43ff37cac60da3e699ba6d89d06d8cf6ca9a4322d1cc5f9fd5.tgz(effect@4.1.2) '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz(effect@4.1.2) '@prisma/dev': 0.25.2(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) + alchemy: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1) effect: 4.1.2 rolldown: 1.2.16 optionalDependencies: @@ -7807,7 +7807,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): + alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): dependencies: '@alchemy.run/node-utils': https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz '@alchemy.run/sigil': 0.1.0-alpha.3(@types/react@19.2.17) @@ -7828,7 +7828,7 @@ snapshots: - '@types/react' - react-devtools-core - alchemy@https://pkg.alchemy.run/alchemy/pr:3101:ab55f95(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): + alchemy@https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz(@effect/platform-bun@4.1.2(effect@4.1.2))(@effect/platform-node@4.1.2(effect@4.1.2)(redis@6.2.1))(@types/react@19.2.17)(effect@4.1.2)(vite@8.1.2(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))(ws@8.22.1): dependencies: '@alchemy.run/node-utils': https://pkg.alchemy.run/@alchemy.run/node-utils/-/7ca778b6ff66b8a15b532535f1227c058382469f80ed51008909ddadfef5d42f.tgz '@alchemy.run/sigil': 0.1.0-alpha.3(@types/react@19.2.17) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6e219185..de6afb09d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,7 +13,7 @@ catalog: # alchemy and every @alchemy.run/* package move together, at one commit or # one release; @distilled.cloud/prisma is the exact version # @alchemy.run/prisma depends on. See the upgrade-alchemy-effect skill. - alchemy: https://pkg.alchemy.run/alchemy/pr:3101:ab55f95 + alchemy: https://pkg.alchemy.run/alchemy/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz '@alchemy.run/prisma': https://pkg.alchemy.run/@alchemy.run/prisma/pr:3101:ab55f95 '@distilled.cloud/prisma': https://pkg.alchemy.run/@distilled.cloud/prisma/-/9af912743056174c99bfc63a4702e5c09d6e5d718057b81e8964ffde4f87408b.tgz effect: 4.1.2 diff --git a/skills-contrib/upgrade-alchemy-effect/SKILL.md b/skills-contrib/upgrade-alchemy-effect/SKILL.md index 67976fd24..16d8b1db2 100644 --- a/skills-contrib/upgrade-alchemy-effect/SKILL.md +++ b/skills-contrib/upgrade-alchemy-effect/SKILL.md @@ -122,7 +122,7 @@ install never gets there. ## Pinning an unreleased alchemy build -The repo is on a preview of alchemy-run/alchemy#3101 at `ab55f95` (`https://pkg.alchemy.run//pr:3101:ab55f95`): `alchemy` and `@alchemy.run/prisma` at that URL, `@distilled.cloud/prisma` at the tarball URL `@alchemy.run/prisma` depends on, and `effect` with its companions at 4.1.2, the floor of that Distilled build's `effect` peer. The preview lets `PrismaEnvironment` and Distilled's `Credentials` omit the token (alchemy-run/alchemy#3101, alchemy-run/distilled#1076), which `prisma deploy` needs to send every request through the engine's client. Move back to a registry release once one contains both changes. Before this, the repo was on 2.0.0-beta.85 and `@distilled.cloud/prisma` 1.0.0-rc.18. Use a preview only when you need a fix that no release contains yet. +The repo is on a preview of alchemy-run/alchemy#3101 at `ab55f95` (`https://pkg.alchemy.run//pr:3101:ab55f95`): `@alchemy.run/prisma` at that URL, `alchemy` at the tarball URL `@alchemy.run/prisma` names as its `alchemy` peer, `@distilled.cloud/prisma` at the tarball URL `@alchemy.run/prisma` depends on, and `effect` with its companions at 4.1.2, the floor of that Distilled build's `effect` peer. The preview lets `PrismaEnvironment` and Distilled's `Credentials` omit the token (alchemy-run/alchemy#3101, alchemy-run/distilled#1076), which `prisma deploy` needs to send every request through the engine's client. Move back to a registry release once one contains both changes. Before this, the repo was on 2.0.0-beta.85 and `@distilled.cloud/prisma` 1.0.0-rc.18. Use a preview only when you need a fix that no release contains yet. alchemy publishes a preview of every commit on `main`, and of each pull request's head as `pr::`. Pin it by commit, never by branch, in the `catalog:` block of `pnpm-workspace.yaml`: @@ -136,7 +136,7 @@ alchemy: https://pkg.alchemy.run/alchemy/ - **The commit URL is immutable.** It redirects to a content-addressed tarball (`/alchemy/-/.tgz`). The lockfile records the commit URL and the tarball's integrity. - **Pin `alchemy` and every `@alchemy.run/*` package at the same commit.** They are built together. - **A preview's own dependencies are content-addressed URLs too.** `@alchemy.run/prisma` depends on `@distilled.cloud/prisma` by a `/-/.tgz` URL. Pin our `@distilled.cloud/prisma` to that exact URL (read it from the tarball's `package.json`), or pnpm and npm install a second copy and `Credentials` stops matching. The commit URL for the same package does not dedupe with it. -- **The `alchemy` peer of `@alchemy.run/prisma` names a different tarball hash than the commit URL resolves to.** The two differ only in an embedded URL in `package.json`; the code is identical. Both pnpm and npm accept our copy and install one `alchemy`; `check:npm-effect-resolution` asserts that. +- **The `alchemy` peer of `@alchemy.run/prisma` is a tarball URL.** For a commit preview it may name a different tarball hash than the commit URL resolves to; the two differ only in an embedded URL in `package.json`, and npm accepts our copy. For a pull request preview (`pr::`) the peer names the same tarball the PR URL redirects to, but npm compares the specs and `npm ls` reports our `alchemy` invalid, so `check:npm-effect-resolution` fails. Pin `alchemy` to the peer's exact tarball URL then. - **Not every package has a preview.** `@alchemy.run/test` returns 404 at every commit, so nothing may depend on it (see the breakage classes). - **Read the source at the commit**, not the docs: `git clone https://github.com/alchemy-run/alchemy && git checkout `, or extract the tarball into a gitignored `wip/` folder. - **Move back to a registry version** once a release contains the commit: `npm view alchemy dist-tags`, then compare the release date with the commit date. From 223f09060e8038ddb6c164fc6a69477f0975e170 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 14:23:34 +0100 Subject: [PATCH 11/14] fix(prisma-cloud): address slice 4 review of deploy through ctx.api - The deploy HttpClient (renamed deployHttpClientLayer) removes every request's own Authorization, so only the supplied client authenticates. The state store no longer strips it itself. - With a supplied client, Management API requests are recognised by the origin from PRISMA_MANAGEMENT_API_URL, as the prisma CLI reads it. A PRISMA_API_URL naming another host fails with DEPLOY.MANAGEMENT_API_URL_CONFLICT before any container is resolved. - Rebuilt JSON error bodies keep a JSON string's quotes. - One choice between the supplied client and a token-built one (suppliedOrFromEnv), used by the providers and the state store. - providers({ credentials }) takes an input object, like preflight. - ContainerCredentials is renamed CallerCredentials, in its own module. - PrismaEnvironment no longer claims an environment service token when a client is supplied. - Tests: Authorization removal, env-built client keeps its token, JSON string error body, byte/form/stream bodies with a 401 retry under bun and node, env-only path sends Bearer, host conflict and match, and the state store over a supplied client. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/03-domain-model/glossary.md | 4 + docs/design/10-domains/deploy-cli.md | 2 +- ...l-envelopes-with-dotted-namespace-codes.md | 3 +- ...ADR-0064-deploy-runs-alchemy-in-process.md | 2 +- .../1-core/core/src/caller-credentials.ts | 20 + .../1-core/core/src/container-transport.ts | 31 +- .../1-core/core/src/control/app-config.ts | 28 +- .../1-core/core/src/control/deploy.ts | 8 +- .../cli/src/family/__tests__/deploy.test.ts | 6 +- .../src/operations/execute-deploy-destroy.ts | 4 +- .../3-tooling/cli/src/operations/shared.ts | 4 +- .../src/__tests__/deploy-http-client.test.ts | 520 ++++++++++++++++++ .../fixtures/refreshing-engine-client.ts | 30 + .../__tests__/fixtures/stream-under-node.ts | 25 + .../__tests__/management-http-client.test.ts | 246 --------- .../lowering/src/builds/reporter.ts | 2 +- .../0-lowering/lowering/src/client.ts | 36 +- .../0-lowering/lowering/src/credentials.ts | 85 ++- ...t-http-client.ts => deploy-http-client.ts} | 32 +- .../0-lowering/lowering/src/providers.ts | 108 ++-- .../src/state/__tests__/state-api.test.ts | 31 +- .../0-lowering/lowering/src/state/layer.ts | 27 +- .../target/src/__tests__/container.test.ts | 41 ++ .../target/src/__tests__/invariants.test.ts | 4 +- .../1-extensions/target/src/container.ts | 12 +- .../target/src/control/extension.ts | 7 +- 26 files changed, 891 insertions(+), 427 deletions(-) create mode 100644 packages/0-framework/1-core/core/src/caller-credentials.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-http-client.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/refreshing-engine-client.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/stream-under-node.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts rename packages/1-prisma-cloud/0-lowering/lowering/src/{management-http-client.ts => deploy-http-client.ts} (77%) diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 7370245c6..0d3f9a609 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -470,6 +470,10 @@ otherwise found by `gitName` or created). The deploy CLI resolves and ensures both; Alchemy provisions only the resources inside them and never creates or destroys a container itself. +### Caller credentials + +What the caller of a deploy or destroy has already authenticated: a workspace and a platform API client (`CallerCredentials`). For `prisma deploy` they are the active credential's workspace and the engine's `ctx.api`. The framework hands them unchanged to every extension hook that talks to the platform: the container lifecycle, `preflight`, reporters, `providers` and the state store. When the caller has none, each extension uses its own credential protocol, for Prisma Cloud `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). + ## Authoring surface — the next layer How a developer actually writes the [Core nouns](#core-nouns). The concrete diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index f90494238..838dfd3d9 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -190,7 +190,7 @@ user can rerun it by hand (ADR-0007). - **Failure.** A failed plan or apply is `DEPLOY.ENGINE_FAILED`. Its message carries the errors Alchemy failed with, with Composer's advice in place of the Alchemy CLI's (`--adopt`) and every credential and pool secret removed. - **Ctrl-C.** The engine's abort signal interrupts the plan or apply. The plan's scope closes, which releases the environment's state lease (ADR-0045), and the deploy fails with `DEPLOY.INTERRUPTED`. The CLI reports that code as a message (a frame in JSON) and exits 130 through the engine's own abort (`CLI.ABORTED`). - **Process state.** For the length of the plan and apply, `deploy` sets each reporter's variables in `process.env`, and changes to the deploy's working directory when the process is elsewhere, because Alchemy reads `.env` and keeps `.alchemy` there. It restores both on every exit path. One process runs one converge at a time, across every install it loads (`DEPLOY.CONVERGE_RUNNING`), and a worker thread cannot change directory (`DEPLOY.WORKER_THREAD`). - - **Credentials.** The command passes the engine's `ctx.api` and the active credential's workspace as the operation's `credentials`, and `lower()` receives them as `LowerOptions.credentials`. Every Management API request the deploy makes goes through that client, which sets the bearer token and refreshes it: Composer's own resources call it, and Alchemy's Prisma providers and state store reach it through an Effect `HttpClient` that forwards Management API requests to it and sends other hosts (presigned upload URLs) to node's transport. No token reaches Composer's code and none is written into `process.env`, so a `prisma login` session deploys like `PRISMA_SERVICE_TOKEN` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). A caller of the operation that passes no credentials gets a client built from `PRISMA_SERVICE_TOKEN`. + - **Credentials.** The command passes the engine's `ctx.api` and the active credential's workspace as the operation's `credentials`, and `lower()` receives them as `LowerOptions.credentials`. Every Management API request the deploy makes goes through that client, which sets the bearer token and refreshes it: Composer's own resources call it, and Alchemy's Prisma providers and state store reach it through an Effect `HttpClient` that forwards Management API requests to it without their own `Authorization` header and sends other hosts (presigned upload URLs) to node's transport. The client's host comes from `PRISMA_MANAGEMENT_API_URL`; a `PRISMA_API_URL` that names another host fails the deploy with `DEPLOY.MANAGEMENT_API_URL_CONFLICT` before anything is created. No token reaches Composer's code and none is written into `process.env`, so a `prisma login` session deploys like `PRISMA_SERVICE_TOKEN` ([ADR-0064](../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md)). A caller of the operation that passes no credentials gets a client built from `PRISMA_SERVICE_TOKEN`. `destroy` (and `prisma dev`) write the pipeline's results as a runnable stack module at `.prisma-composer/alchemy.run.ts` and drive the `alchemy` CLI against it (ADR-0007), started as § Runtime describes, carrying every resolved container across to the child as one environment variable per extension — content the CLI writes but never reads (ADR-0038). `alchemy destroy` re-imports and re-evaluates the same stack, so its target reconstruction needs the containers. The generated file and Alchemy's state live in the process's working directory — tool state lives where you run the tool, like any other CLI (ADR-0004). The generated stack file reads the config pool again, from the same adapters; it never reads the secrets pool. diff --git a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md index bfd44a41f..563fab143 100644 --- a/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md +++ b/docs/design/90-decisions/ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md @@ -102,7 +102,7 @@ Closed subcode registry: CONTAINER_FAILED, PREFLIGHT_FAILED, ENGINE_FAILED, TEARDOWN_FAILED, CONTAINER_REMOVE_FAILED, BUILD_REQUIRED, ALCHEMY_BIN_MISSING, NODE_MISSING, STACK_WRITE_FAILED, - INTERRUPTED, CONVERGE_RUNNING, WORKER_THREAD + INTERRUPTED, CONVERGE_RUNNING, WORKER_THREAD, MANAGEMENT_API_URL_CONFLICT *(STAGE_INVALID, STAGE_UNVALIDATABLE and SCOPE_MISSING retired by [ADR-0059](ADR-0059-a-deployment-environment-is-called-an-environment.md))*. ENVIRONMENT_REQUIRED: a `destroy` call names no environment. @@ -110,6 +110,7 @@ Closed subcode registry: INTERRUPTED: the abort signal stopped `deploy`'s plan or apply. `prisma deploy` exits 130 (143 for SIGTERM): it reports this code as a message event and ends with the engine's abort, `CLI.ABORTED`. CONVERGE_RUNNING: another in-process converge holds this process's working directory and the reporter variables it sets in the environment. WORKER_THREAD: `deploy` was called from a worker thread with a `cwd` other than the process's working directory, which a worker cannot change. + MANAGEMENT_API_URL_CONFLICT: the deploy was given a Management API client, and `PRISMA_API_URL` names another host than the one that client sends to (`PRISMA_MANAGEMENT_API_URL`, or `https://api.prisma.io`). Raised by the Prisma Cloud extension when it resolves the environment's container, for `deploy` and `destroy`. `prisma dev` starts the Alchemy child the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. - `DEV` — PLATFORM_UNSUPPORTED, TARGET_UNSUPPORTED, CONTAINER_FAILED, TEARDOWN_FAILED, PREFLIGHT_FAILED, EMULATOR_FAILED, SERVICE_START_FAILED, CONVERGE_FAILED, ATTACH_FAILED, STACK_WRITE_FAILED, EMULATORS_DIR_INVALID, EMULATOR_UNREACHABLE, SERVICE_READY_TIMEOUT_INVALID, NOT_STARTED_HERE, ADDRESS_UNKNOWN, NOT_RUNNING. EMULATOR_UNREACHABLE is raised by the emulator client when it cannot connect, so `log` raises it under `DEV` too; for a connection failure it takes the place of SERVICE_START_FAILED, ATTACH_FAILED and `LOG.ATTACH_FAILED`. `devUrl` also raises it when the emulator a node runs on is not running. NOT_STARTED_HERE (no `prisma dev` has recorded an app in the directory) is raised by `devUrl` and `devStop`; ADDRESS_UNKNOWN (the recorded app has no node at that address; the known addresses are listed) and NOT_RUNNING (the node exists but is not running; its state is named, and the reason and fix depend on it) by `devUrl` alone ([ADR-0053](ADR-0053-dev-url-dev-stop-and-emulator-list.md)). Both operations raise EMULATOR_FAILED when the local target fails without a code. `emulator list` fails only with EMULATORS_DIR_INVALID; an emulator it cannot list is reported in that emulator's entry with EMULATOR_UNREACHABLE or EMULATOR_FAILED. diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index 74ea18fa9..ea5f17b42 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -25,7 +25,7 @@ This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-st - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. - **Failures are structured.** The converge returns the errors Alchemy failed with as typed reasons (tag and message), and the deploy maps them to `DEPLOY.ENGINE_FAILED`, with Composer's advice in place of the Alchemy CLI's and secrets removed. A converge that rejects instead of returning an outcome is `DEPLOY.ENGINE_FAILED` too. An interrupted one is `DEPLOY.INTERRUPTED`. Every message event, and an error raised while the stack is built, is redacted the same way. This amends [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md): the child exit-status passthrough no longer applies to `deploy`; its failures exit 2, and Ctrl-C exits 130. - **Ctrl-C interrupts the plan or apply.** The engine's abort signal interrupts the running effect. The plan's scope closes, which releases the environment's state lease ([ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md)), so the next deploy does not wait for the lease to expire. The engine exits 130 (143 for SIGTERM) only when a handler ends with its abort, not when it returns an error, so `prisma deploy` reports `DEPLOY.INTERRUPTED` as a message event, which JSON output carries as a frame with the code, and then ends with the engine's abort (`CLI.ABORTED`). -- **Every Management API request goes through `ctx.api`.** `@prisma/cli-engine` keeps token material away from commands: a command never holds a token, `ctx.api` (the Management API SDK client over a fetch that sets the bearer token and refreshes it) is the only API client in the process, and the engine's `process.env` is never changed (prisma-cli `docs/architecture/credential-manager.md`). So `prisma deploy` passes `ctx.api` and the active credential's workspace to the operation as `credentials`, and the operation hands them to `lower()` as a value (`LowerOptions.credentials`). Core gives them to each extension's `providers(credentials)` and to the state descriptor's `create(container, credentials)`. The Prisma Cloud extension builds its provider bundle and its state store over that client: Composer's own resources call it directly, and Alchemy's Prisma providers and state store get an Effect `HttpClient` that sends each request to the Management API origin through the client's per-method calls, with the body, query string and headers unchanged and the response read as a stream. Requests to any other host, such as the presigned artifact upload URLs, go to node's transport unauthenticated, because those uploads need node's explicit `Content-Length`. Alchemy's `PrismaEnvironment` and Distilled's `Credentials` carry only the base URL, so neither sets an `Authorization` header (both made the token optional upstream for this, alchemy-run/alchemy#3101 and alchemy-run/distilled#1076). A `prisma login` session therefore deploys like a service token, and deploy writes no credential into `process.env`. A caller of the `deploy` operation that passes no `credentials`, such as a script, gets a client the extension builds from `PRISMA_SERVICE_TOKEN`, used the same way. Deploy still sets each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. +- **Every Management API request goes through `ctx.api`.** `@prisma/cli-engine` keeps token material away from commands: a command never holds a token, `ctx.api` (the Management API SDK client over a fetch that sets the bearer token and refreshes it) is the only API client in the process, and the engine's `process.env` is never changed (prisma-cli `docs/architecture/credential-manager.md`). So `prisma deploy` passes `ctx.api` and the active credential's workspace to the operation as `credentials`, and the operation hands them to `lower()` as a value (`LowerOptions.credentials`). Core gives them to each extension's `providers({ credentials })` and to the state descriptor's `create(container, credentials)`. The Prisma Cloud extension builds its provider bundle and its state store over that client: Composer's own resources call it directly, and Alchemy's Prisma providers and state store get an Effect `HttpClient` that sends each request to the Management API origin through the client's per-method calls, with the body, query string and headers unchanged except `Authorization`, which it removes so that only the client authenticates, and the response read as a stream. The client decides the host: `ctx.api` uses `PRISMA_MANAGEMENT_API_URL`, or `https://api.prisma.io` when it is unset, so with a supplied client Composer recognises Management API requests by that origin. `PRISMA_API_URL` cannot move a supplied client, so when it names another origin the deploy fails with `DEPLOY.MANAGEMENT_API_URL_CONFLICT` before it creates or plans anything. Requests to any other host, such as the presigned artifact upload URLs, go to node's transport unauthenticated, because those uploads need node's explicit `Content-Length`. Alchemy's `PrismaEnvironment` and Distilled's `Credentials` carry only the base URL, so neither sets an `Authorization` header. `PrismaEnvironment` still says `type: 'serviceToken'`, the only type upstream allows, and its `source` names the caller's client as where the credential is (both made the token optional upstream for this, alchemy-run/alchemy#3101 and alchemy-run/distilled#1076). A `prisma login` session therefore deploys like a service token, and deploy writes no credential into `process.env`. A caller of the `deploy` operation that passes no `credentials`, such as a script, gets a client the extension builds from `PRISMA_SERVICE_TOKEN`, used the same way. Deploy still sets each reporter's variables in `process.env` for the length of the plan and apply, and changes to the deploy's working directory when the process is elsewhere, and restores both on every exit path. - **One converge per process.** Because of the reporter variables and the working directory, a second converge while one runs is refused with `DEPLOY.CONVERGE_RUNNING`. The flag lives on `globalThis` under a `Symbol.for` key, so it holds across every install of the entry a host loads, such as two apps deployed from one script; and a call from a worker thread whose `cwd` is not the process's working directory, which a worker cannot change, with `DEPLOY.WORKER_THREAD`. - **Stdout stays the engine's.** Alchemy's log lines and events go to the caller as values, and Alchemy's non-interactive `Interaction` writes to stderr. The `prisma deploy` command, not the operation, sends `console`'s stdout methods to stderr while the operation runs, so a provider's `console.log` cannot corrupt JSON output; a host calling the operation keeps its own `console`, as ADR-0043 requires. A provider that writes to `process.stdout` directly is out of reach. diff --git a/packages/0-framework/1-core/core/src/caller-credentials.ts b/packages/0-framework/1-core/core/src/caller-credentials.ts new file mode 100644 index 000000000..f755777f5 --- /dev/null +++ b/packages/0-framework/1-core/core/src/caller-credentials.ts @@ -0,0 +1,20 @@ +/** + * What the caller has already authenticated: a workspace and a platform API + * client. The framework hands it, unchanged, to every extension hook that + * talks to the platform: the container lifecycle, `preflight`, reporters, + * `providers` and the state descriptor's `create`. A caller that holds neither + * omits it, and the extension falls back to whatever credential protocol it + * defines for itself (for Prisma Cloud, its environment variables). + * + * `C` is the extension's own platform-client type, left as a type parameter + * because this shared-plane module must not import a platform SDK; the + * framework only carries the value, never calls it. The framework stores the + * erased `C = unknown` form and the extension recovers its concrete client + * through method bivariance on the hooks that receive it. + */ +export interface CallerCredentials { + /** The workspace the caller acts in. `undefined` when the caller has no workspace to name — the extension decides whether it can proceed. */ + readonly workspaceId: string | undefined; + /** An already-authenticated platform API client. Present means the extension must not build its own from the environment. */ + readonly client?: C | undefined; +} diff --git a/packages/0-framework/1-core/core/src/container-transport.ts b/packages/0-framework/1-core/core/src/container-transport.ts index abf75c252..7458bb248 100644 --- a/packages/0-framework/1-core/core/src/container-transport.ts +++ b/packages/0-framework/1-core/core/src/container-transport.ts @@ -9,6 +9,8 @@ * descriptor. The framework owns the vars; it never reads their contents. */ +import type { CallerCredentials } from './caller-credentials.ts'; + /** The key an extension resolves a container from: which app, which environment. */ export interface LocateContainerInput { /** The application name (root node's name, or `--name`). */ @@ -30,26 +32,6 @@ export interface ContainerInstance { serialize(): string; } -/** - * What the caller has already authenticated, handed to one container - * lifecycle call. A caller that holds neither omits the argument, and the - * extension falls back to whatever credential protocol it defines for - * itself (for Prisma Cloud, its env vars — which is how the alchemy child - * process still resolves containers). - * - * `C` is the extension's own platform-client type, left as a type parameter - * because this shared-plane module must not import a platform SDK; the - * framework only carries the value, never calls it. The framework stores the - * erased `C = unknown` form and the extension recovers its concrete client - * through the same method bivariance that erases `I` below. - */ -export interface ContainerCredentials { - /** The workspace the container lives in. `undefined` when the caller has no workspace to name — the extension decides whether it can proceed. */ - readonly workspaceId: string | undefined; - /** An already-authenticated platform API client. Present means the extension must not build its own from the environment. */ - readonly client?: C | undefined; -} - /** * The platform containers an app deploys into, as one lifecycle. `I` is * the extension's own instance type — the same descriptor produces and @@ -62,14 +44,11 @@ export interface ContainerCredentials { */ export interface ContainerDescriptor { /** Resolve the container for (appName, environment), creating anything absent. Called by `deploy`. */ - ensure(input: LocateContainerInput, credentials?: ContainerCredentials): Promise; + ensure(input: LocateContainerInput, credentials?: CallerCredentials): Promise; /** Find the container for (appName, environment); `undefined` when nothing exists. Called by `destroy` — never creates. */ - locate( - input: LocateContainerInput, - credentials?: ContainerCredentials, - ): Promise; + locate(input: LocateContainerInput, credentials?: CallerCredentials): Promise; /** Remove the container after a successful destroy, after every extension's `teardown` has run. Failure policy is the extension's. */ - remove(instance: I, credentials?: ContainerCredentials): Promise; + remove(instance: I, credentials?: CallerCredentials): Promise; /** Reconstruct an instance from its own `serialize()` output — the far end of the framework's parent→child transport. */ deserialize(serialized: string): I; } diff --git a/packages/0-framework/1-core/core/src/control/app-config.ts b/packages/0-framework/1-core/core/src/control/app-config.ts index f3fe8c199..0afab0067 100644 --- a/packages/0-framework/1-core/core/src/control/app-config.ts +++ b/packages/0-framework/1-core/core/src/control/app-config.ts @@ -1,10 +1,7 @@ /** The `composer` section of `prisma.config.ts` (ADR-0017): statically imports each extension's node-descriptor registry plus the state store; core defines only the types. */ import type * as Layer from 'effect/Layer'; -import type { - ContainerCredentials, - ContainerDescriptor, - ContainerInstance, -} from '../container-transport.ts'; +import type { CallerCredentials } from '../caller-credentials.ts'; +import type { ContainerDescriptor, ContainerInstance } from '../container-transport.ts'; import type { Graph } from '../graph.ts'; import type { ServiceNode } from '../node.ts'; import type { PreflightPayload } from '../preflight-transport.ts'; @@ -21,8 +18,8 @@ import type { ServiceLowering, } from './deploy.ts'; +export type { CallerCredentials } from '../caller-credentials.ts'; export type { - ContainerCredentials, ContainerDescriptor, ContainerInstance, LocateContainerInput, @@ -57,10 +54,9 @@ export interface ExtensionDescriptor { readonly application?: ApplicationDescriptor; /** * The extension's Alchemy providers — merged across all configured extensions (config order). - * `credentials` is what the caller already authenticated (`LowerOptions.credentials`); absent means the extension falls back to its own credential protocol. - * METHOD SYNTAX REQUIRED, like `preflight`: an extension types `credentials` against its own client, which assigns here only through method bivariance. + * METHOD SYNTAX REQUIRED, like `preflight`: an extension types `input` against its own client, which assigns here only through method bivariance. */ - providers?(credentials?: ContainerCredentials): Layer.Layer; + providers?(input: ProvidersInput): Layer.Layer; /** * Deploy-time prerequisite check — the CLI runs it once, after the app's * Project/Branch are resolved and BEFORE any stack file is written or Alchemy @@ -129,11 +125,17 @@ export interface StateDescriptor { /** Build the state layer. `container` is the owning extension's resolved instance; `undefined` when it declared no container descriptor. `credentials` is as `ExtensionDescriptor.providers` receives it. */ create( container: ContainerInstance | undefined, - credentials?: ContainerCredentials, + credentials?: CallerCredentials, ): AlchemyStateLayer; } -/** The resolved deploy context handed to an extension's `preflight` hook. `C` erases to `unknown` at the framework boundary — see ContainerCredentials. */ +/** What `ExtensionDescriptor.providers` receives. `C` erases to `unknown` at the framework boundary, exactly as on `PreflightInput`. */ +export interface ProvidersInput { + /** What the caller already authenticated (`LowerOptions.credentials`). Absent means the extension falls back to its own credential protocol. */ + readonly credentials?: CallerCredentials | undefined; +} + +/** The resolved deploy context handed to an extension's `preflight` hook. `C` erases to `unknown` at the framework boundary — see CallerCredentials. */ export interface PreflightInput { /** The loaded application graph — the manifest of prerequisites is read from it (`provisionManifest`). */ readonly graph: Graph; @@ -144,7 +146,7 @@ export interface PreflightInput { /** The pools the command read for this environment. Preflight fills a missing `envSecret()` platform variable from `pools.secrets` and an `envParam()` param's from `pools.config`. */ readonly pools: ConfigPools; /** What the caller has already authenticated, for the platform calls preflight makes. Absent means the extension falls back to its own credential protocol. */ - readonly credentials?: ContainerCredentials | undefined; + readonly credentials?: CallerCredentials | undefined; /** Hands the deploy's progress output what the check covered, as counts (e.g. `{ checked: 53, filled: 0, missing: 0 }`). Counts only: never a name or a value. */ readonly report?: ((counts: Readonly>) => void) | undefined; /** Shows the user a warning that does not stop the run. Only `prisma dev` passes it and shows the warnings; deploy passes none. */ @@ -187,7 +189,7 @@ export interface ReportBeginInput { */ readonly reportId: string | undefined; /** What the caller has already authenticated, exactly as `preflight` and the container lifecycle receive it. Present means the reporter must not build a client from the environment. */ - readonly credentials?: ContainerCredentials | undefined; + readonly credentials?: CallerCredentials | undefined; } /** The deploy context handed to `RunReporter.attach`, once containers exist. */ diff --git a/packages/0-framework/1-core/core/src/control/deploy.ts b/packages/0-framework/1-core/core/src/control/deploy.ts index 3742b19a7..d04c703fa 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -19,7 +19,7 @@ import { } from '../node.ts'; import { type PreflightPayload, readPreflightPayload } from '../preflight-transport.ts'; import type { - ContainerCredentials, + CallerCredentials, ContainerInstance, ExtensionDescriptor, NodeDescriptor, @@ -242,7 +242,7 @@ export interface LowerOptions { /** Each extension's preflight payload, by extension id. A generated stack file reads them with `transportedLowerInputs`. */ readonly preflight: ReadonlyMap; /** What the caller already authenticated, handed to every extension's `providers()` and to the state descriptor's `create()`. Absent means each extension falls back to its own credential protocol. */ - readonly credentials?: ContainerCredentials | undefined; + readonly credentials?: CallerCredentials | undefined; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -634,10 +634,10 @@ export function resolveStateLayer( */ export function mergedProviders( config: PrismaAppConfig, - credentials?: ContainerCredentials, + credentials?: CallerCredentials, ): Layer.Layer { const layers = config.extensions.flatMap((extension) => - extension.providers !== undefined ? [extension.providers(credentials)] : [], + extension.providers !== undefined ? [extension.providers({ credentials })] : [], ); const [first, ...rest] = layers; return first === undefined ? Layer.empty : Layer.mergeAll(first, ...rest); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts index cc7f7c195..e0f42138c 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/deploy.test.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type { - ContainerCredentials, + CallerCredentials, ContainerDescriptor, LocateContainerInput, PrismaAppConfig, @@ -315,7 +315,7 @@ describe('a stored session', () => { test('deploys with no PRISMA_SERVICE_TOKEN: the engine client reaches the providers and state, and no credential enters process.env', async () => { const app = appDir(); - const seen: { credentials: ContainerCredentials[]; envDuringApply: unknown[] } = { + const seen: { credentials: CallerCredentials[]; envDuringApply: unknown[] } = { credentials: [], envDuringApply: [], }; @@ -345,7 +345,7 @@ describe('a stored session', () => { }, }, container, - providers: (credentials) => { + providers: ({ credentials }) => { if (credentials !== undefined) seen.credentials.push(credentials); return Layer.empty; }, diff --git a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts index 6de1b9704..0b80a6b7d 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/execute-deploy-destroy.ts @@ -10,7 +10,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Graph } from '@internal/core'; import type { - ContainerCredentials, + CallerCredentials, ContainerInstance, ReporterDescriptor, RunReporter, @@ -212,7 +212,7 @@ async function beginReporters( readonly environment: string; readonly cwd: string; readonly reportId: string | undefined; - readonly credentials: ContainerCredentials | undefined; + readonly credentials: CallerCredentials | undefined; }, ): Promise { const opened = await Promise.all( diff --git a/packages/0-framework/3-tooling/cli/src/operations/shared.ts b/packages/0-framework/3-tooling/cli/src/operations/shared.ts index 4034fdb82..db9b42657 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/shared.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/shared.ts @@ -6,7 +6,7 @@ */ import type { RunAssembler } from '@internal/assemble'; import type { - ContainerCredentials, + CallerCredentials, ContainerDescriptor, ContainerInstance, } from '@internal/core/config'; @@ -63,7 +63,7 @@ export interface OperationDeps { * call", and both commands are credential-free by contract: there is no * authenticated call on those paths for this to reach. */ - readonly credentials?: ContainerCredentials | undefined; + readonly credentials?: CallerCredentials | undefined; } /** diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-http-client.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-http-client.test.ts new file mode 100644 index 000000000..8d2953f9b --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-http-client.test.ts @@ -0,0 +1,520 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as path from 'node:path'; +import * as Prisma from '@alchemy.run/prisma'; +import type { Credentials, Retry } from '@distilled.cloud/prisma'; +import { getWorkspaces } from '@distilled.cloud/prisma/management'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import * as ConfigProvider from 'effect/ConfigProvider'; +import * as Effect from 'effect/Effect'; +import * as Exit from 'effect/Exit'; +import * as Fiber from 'effect/Fiber'; +import * as HttpClient from 'effect/http/HttpClient'; +import * as HttpClientRequest from 'effect/http/HttpClientRequest'; +import type * as Layer from 'effect/Layer'; +import * as Stream from 'effect/Stream'; +import type { ManagementApiClient } from '../client.ts'; +import { deployHttpClientLayer } from '../deploy-http-client.ts'; +import { providers } from '../providers.ts'; +import { refreshingEngineClient } from './fixtures/refreshing-engine-client.ts'; + +const ORIGIN = 'https://api.prisma.test'; + +/** + * The SDK client the engine hands a command as `ctx.api`, over a fetch that + * records each request. The engine wraps the client in a Proxy that turns + * every property into an async call, so this does too: the adapter may only + * call the client's methods. + */ +function fakeEngineClient(respond: (request: Request) => Promise | Response) { + const requests: Request[] = []; + const sdk = createManagementApiClient({ + baseUrl: ORIGIN, + fetch: async (request: Request) => { + requests.push(request); + return respond(request); + }, + }); + const client = new Proxy( + {}, + { + get: (_target, property) => + typeof property === 'symbol' || property === 'then' + ? undefined + : async (...args: unknown[]) => Reflect.apply(Reflect.get(sdk, property), sdk, args), + }, + ) as ManagementApiClient; + return { client, requests }; +} + +const run = ( + client: ManagementApiClient, + effect: Effect.Effect, +) => + Effect.runPromise( + effect.pipe(Effect.scoped, Effect.provide(deployHttpClientLayer(client, ORIGIN))), + ); + +describe('deployHttpClientLayer', () => { + test('a Management API request goes through the client with its body, query and headers intact and no Authorization', async () => { + const { client, requests } = fakeEngineClient(() => + Response.json({ data: { id: 'proj_1' } }, { status: 201, headers: { 'x-request-id': 'r1' } }), + ); + const body = '{"name":"shop", "region":"eu-central-1"}'; + + const response = await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.post(`${ORIGIN}/v1/projects`).pipe( + HttpClientRequest.setUrlParams({ limit: '5', cursor: 'a b' }), + HttpClientRequest.bodyText(body, 'application/json'), + HttpClientRequest.setHeader('x-custom', 'kept'), + ), + ); + return { + status: response.status, + header: response.headers['x-request-id'], + text: yield* response.text, + }; + }), + ); + + expect(response).toEqual({ status: 201, header: 'r1', text: '{"data":{"id":"proj_1"}}' }); + expect(requests).toHaveLength(1); + const [request] = requests; + if (request === undefined) throw new Error('no request'); + expect(request.method).toBe('POST'); + expect(request.url).toBe(`${ORIGIN}/v1/projects?limit=5&cursor=a+b`); + expect(await request.text()).toBe(body); + expect(request.headers.get('content-type')).toBe('application/json'); + expect(request.headers.get('x-custom')).toBe('kept'); + expect(request.headers.get('x-prisma-client-name')).toBe('composer'); + expect(request.headers.has('authorization')).toBe(false); + }); + + test('an error response keeps its status and body', async () => { + const { client } = fakeEngineClient( + () => new Response('{"error":{"code":"quota"}}', { status: 422 }), + ); + + const result = await run( + client, + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).get(`${ORIGIN}/v1/projects/p1`); + return { status: response.status, json: yield* response.json }; + }), + ); + + expect(result).toEqual({ status: 422, json: { error: { code: 'quota' } } }); + }); + + test('a failure in the client is a TransportError carrying the cause', async () => { + const failure = new Error('CLI.CREDENTIALS_REQUIRED'); + const { client } = fakeEngineClient(() => Promise.reject(failure)); + + const error = await run( + client, + Effect.flip( + Effect.flatMap(HttpClient.HttpClient, (http) => http.get(`${ORIGIN}/v1/projects`)), + ), + ); + + expect(error.reason._tag).toBe('TransportError'); + expect(error.reason.cause).toBe(failure); + }); + + test('interrupting the request aborts the client call', async () => { + let signal: AbortSignal | undefined; + const { client } = fakeEngineClient( + (request) => + new Promise((_resolve, reject) => { + signal = request.signal; + request.signal.addEventListener('abort', () => reject(request.signal.reason)); + }), + ); + + await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const fiber = yield* Effect.forkChild(http.get(`${ORIGIN}/v1/projects`)); + yield* Effect.promise(async () => { + while (signal === undefined) await new Promise((resolve) => setTimeout(resolve, 1)); + }); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(signal?.aborted).toBe(true); + }); + + test("a request's own Authorization never reaches the client", async () => { + const { client, requests } = fakeEngineClient(() => Response.json({})); + + await run( + client, + Effect.flatMap(HttpClient.HttpClient, (http) => + http.execute( + HttpClientRequest.get(`${ORIGIN}/v1/projects`).pipe( + HttpClientRequest.bearerToken('stale'), + ), + ), + ), + ); + + expect(requests[0]?.headers.has('authorization')).toBe(false); + }); + + test('a client built from a token keeps its own Authorization when the request carries one', async () => { + const requests: Request[] = []; + const client = createManagementApiClient({ + baseUrl: ORIGIN, + token: 'fake-test-token', + fetch: async (request: Request) => { + requests.push(request); + return Response.json({}); + }, + }); + + await run( + client, + Effect.flatMap(HttpClient.HttpClient, (http) => + http.execute( + HttpClientRequest.get(`${ORIGIN}/v1/projects`).pipe(HttpClientRequest.bearerToken('')), + ), + ), + ); + + expect(requests[0]?.headers.get('authorization')).toBe('Bearer fake-test-token'); + }); + + test('a JSON error body that is a string keeps its quotes, so it still decodes', async () => { + const { client } = fakeEngineClient( + () => + new Response('"quota exceeded"', { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ); + + const json = await run( + client, + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).get(`${ORIGIN}/v1/projects`); + return yield* response.json; + }), + ); + + expect(json).toBe('quota exceeded'); + }); + + test('a text error body is passed back as it was', async () => { + const { client } = fakeEngineClient( + () => + new Response('upstream timeout', { + status: 504, + headers: { 'content-type': 'text/plain' }, + }), + ); + + const text = await run( + client, + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).get(`${ORIGIN}/v1/projects`); + return yield* response.text; + }), + ); + + expect(text).toBe('upstream timeout'); + }); + + test('a byte body with no content type reaches the client unlabelled', async () => { + const { client, requests } = fakeEngineClient(() => Response.json({})); + + await run( + client, + Effect.flatMap(HttpClient.HttpClient, (http) => + http.execute( + HttpClientRequest.post(`${ORIGIN}/v1/raw`).pipe( + HttpClientRequest.bodyUint8Array(new TextEncoder().encode('raw')), + ), + ), + ), + ); + + const [request] = requests; + expect(await request?.text()).toBe('raw'); + expect(request?.headers.get('content-type')).not.toBe('application/json'); + }); + + test('a form-data body keeps its multipart boundary', async () => { + const { client, requests } = fakeEngineClient(() => Response.json({})); + const form = new FormData(); + form.append('name', 'shop'); + + await run( + client, + Effect.flatMap(HttpClient.HttpClient, (http) => + http.execute( + HttpClientRequest.post(`${ORIGIN}/v1/forms`).pipe(HttpClientRequest.bodyFormData(form)), + ), + ), + ); + + const received = await requests[0]?.formData(); + expect(received?.get('name')).toBe('shop'); + }); + + test('a streamed body reaches the client whole, and again on its retry after a 401', async () => { + const { client, received } = refreshingEngineClient(ORIGIN); + + const status = await run( + client, + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).execute( + HttpClientRequest.post(`${ORIGIN}/v1/stream`).pipe( + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('chunk1'), new TextEncoder().encode('chunk2')), + { contentType: 'application/octet-stream' }, + ), + ), + ); + return response.status; + }), + ); + + expect(status).toBe(200); + expect(received).toEqual([ + { authorization: 'Bearer expired', body: 'chunk1chunk2' }, + { authorization: 'Bearer refreshed', body: 'chunk1chunk2' }, + ]); + }); + + test('under node, a streamed body reaches the client whole, and again on its retry after a 401', () => { + const result = spawnSync( + 'node', + [path.join(import.meta.dir, 'fixtures', 'stream-under-node.ts'), ORIGIN], + { encoding: 'utf8' }, + ); + + if (result.status !== 0) throw new Error(result.stderr); + expect(JSON.parse(result.stdout)).toEqual({ + status: 200, + received: [ + { authorization: 'Bearer expired', body: 'chunk1chunk2' }, + { authorization: 'Bearer refreshed', body: 'chunk1chunk2' }, + ], + }); + }); + + describe('another host', () => { + const uploads: { method: string; headers: http.IncomingHttpHeaders; body: string }[] = []; + const server = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += String(chunk); + }); + request.on('end', () => { + uploads.push({ method: request.method ?? '', headers: request.headers, body }); + response.writeHead(200).end(); + }); + }); + let uploadUrl = ''; + beforeAll(async () => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + uploadUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/upload?sig=abc`; + }); + afterAll(() => { + server.close(); + }); + + test('goes to the node transport with an explicit Content-Length and no credential, not through the client', async () => { + const { client, requests } = fakeEngineClient(() => new Response(null, { status: 500 })); + + const status = await run( + client, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.put(uploadUrl).pipe( + HttpClientRequest.bodyUint8Array( + new TextEncoder().encode('artifact'), + 'application/gzip', + ), + ), + ); + return response.status; + }), + ); + + expect(status).toBe(200); + expect(requests).toEqual([]); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.method).toBe('PUT'); + expect(uploads[0]?.body).toBe('artifact'); + expect(uploads[0]?.headers['content-length']).toBe('8'); + expect(uploads[0]?.headers['authorization']).toBeUndefined(); + }); + }); +}); + +/** The type lists the stack services the providers need when a stack runs them; these tests only call the clients the bundle exposes. */ +const bundle = (client: ManagementApiClient) => + providers({ client }) as unknown as Layer.Layer< + Prisma.PrismaClient | Credentials | HttpClient.HttpClient | Retry.Retry + >; + +describe('providers({ client })', () => { + test("an upstream provider's request goes through the supplied client, with no token anywhere", async () => { + const saved = process.env['PRISMA_SERVICE_TOKEN']; + delete process.env['PRISMA_SERVICE_TOKEN']; + const { client, requests } = fakeEngineClient(() => + Response.json({ data: { id: 'proj_1', name: 'shop' } }), + ); + try { + const project = await Effect.runPromise( + Effect.gen(function* () { + const prisma = yield* Prisma.PrismaClient; + return yield* prisma.getProject('proj_1'); + }).pipe(Effect.scoped, Effect.provide(bundle(client))), + ); + + expect(project).toMatchObject({ id: 'proj_1' }); + // Alchemy addresses the default origin; the client sends the path to its own server. + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + `GET ${ORIGIN}/v1/projects/proj_1`, + ]); + expect(requests[0]?.headers.has('authorization')).toBe(false); + } finally { + if (saved !== undefined) process.env['PRISMA_SERVICE_TOKEN'] = saved; + } + }); +}); + +describe('providers({ client }) and the generated operations', () => { + test('a Distilled operation goes through the supplied client with no Authorization', async () => { + const { client, requests } = fakeEngineClient(() => + Response.json({ data: [], pagination: { hasMore: false, nextCursor: null } }), + ); + + await Effect.runPromise(getWorkspaces({}).pipe(Effect.scoped, Effect.provide(bundle(client)))); + + expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( + ['GET /v1/workspaces'], + ); + expect(requests[0]?.headers.has('authorization')).toBe(false); + }); +}); + +/** A loopback Management API that records each request's path and Authorization header. */ +function recordingServer(respond: (path: string) => unknown) { + const received: { path: string; authorization: string | undefined }[] = []; + const server = http.createServer((request, response) => { + received.push({ path: request.url ?? '', authorization: request.headers.authorization }); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(respond(request.url ?? ''))); + }); + return { + received, + start: () => + new Promise((resolve) => + server.listen(0, '127.0.0.1', () => + resolve(`http://127.0.0.1:${String((server.address() as AddressInfo).port)}`), + ), + ), + stop: () => server.close(), + }; +} + +describe('providers() with no client', () => { + const api = recordingServer((path) => + path.startsWith('/v1/workspaces') + ? { data: [], pagination: { hasMore: false, nextCursor: null } } + : { data: { id: 'proj_1', name: 'shop' } }, + ); + let origin = ''; + beforeAll(async () => { + origin = await api.start(); + }); + afterAll(() => { + api.stop(); + }); + + const withEnvironment = (effect: Effect.Effect) => + Effect.provideService( + effect, + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ + PRISMA_SERVICE_TOKEN: 'fake-test-token', + PRISMA_API_URL: origin, + }), + ); + + test('an upstream provider request and a Distilled operation carry the token from PRISMA_SERVICE_TOKEN', async () => { + const envBundle = providers() as unknown as Layer.Layer< + Prisma.PrismaClient | Credentials | HttpClient.HttpClient | Retry.Retry + >; + + await Effect.runPromise( + withEnvironment( + Effect.gen(function* () { + yield* (yield* Prisma.PrismaClient).getProject('proj_1'); + yield* getWorkspaces({}); + }).pipe(Effect.scoped, Effect.provide(envBundle)), + ), + ); + + expect(api.received).toEqual([ + { path: '/v1/projects/proj_1', authorization: 'Bearer fake-test-token' }, + { path: '/v1/workspaces', authorization: 'Bearer fake-test-token' }, + ]); + }); +}); + +describe('providers({ client }) and the host settings', () => { + const runWith = (env: Record) => { + const { client, requests } = fakeEngineClient(() => Response.json({ data: { id: 'proj_1' } })); + return Effect.runPromiseExit( + Effect.gen(function* () { + return yield* (yield* Prisma.PrismaClient).getProject('proj_1'); + }).pipe( + Effect.scoped, + Effect.provide(bundle(client)), + Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(env)), + ), + ).then((exit) => ({ exit, requests })); + }; + + test("requests are recognised by the client's own origin, from PRISMA_MANAGEMENT_API_URL", async () => { + const { exit, requests } = await runWith({ PRISMA_MANAGEMENT_API_URL: ` ${ORIGIN} ` }); + + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.map((request) => request.url)).toEqual([`${ORIGIN}/v1/projects/proj_1`]); + }); + + test('PRISMA_API_URL naming the same origin is accepted', async () => { + const { exit } = await runWith({ + PRISMA_MANAGEMENT_API_URL: ORIGIN, + PRISMA_API_URL: `${ORIGIN}/`, + }); + + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test('PRISMA_API_URL naming another origin fails, naming both values', async () => { + const { exit, requests } = await runWith({ + PRISMA_MANAGEMENT_API_URL: ORIGIN, + PRISMA_API_URL: 'https://api.prisma.io', + }); + + expect(Exit.isFailure(exit)).toBe(true); + expect(String(Exit.isFailure(exit) ? exit.cause : '')).toContain( + 'PRISMA_API_URL is https://api.prisma.io', + ); + expect(requests).toEqual([]); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/refreshing-engine-client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/refreshing-engine-client.ts new file mode 100644 index 000000000..e6d7181f5 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/refreshing-engine-client.ts @@ -0,0 +1,30 @@ +import { createManagementApiClient } from '@prisma/management-api-sdk'; + +/** + * An SDK client over a fetch that behaves like the engine's: it sets + * `Authorization` last, clones the request before sending it, and on a 401 + * sends the clone again with a refreshed token. + */ +export function refreshingEngineClient(origin: string) { + const received: { authorization: string | null; body: string }[] = []; + let token = 'expired'; + const client = createManagementApiClient({ + baseUrl: origin, + fetch: async (request: Request) => { + const send = async (attempt: Request) => { + attempt.headers.set('Authorization', `Bearer ${token}`); + received.push({ + authorization: attempt.headers.get('authorization'), + body: await attempt.text(), + }); + return new Response('{}', { status: token === 'expired' ? 401 : 200 }); + }; + const retry = request.clone(); + const first = await send(request); + if (first.status !== 401) return first; + token = 'refreshed'; + return send(retry); + }, + }); + return { client, received }; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/stream-under-node.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/stream-under-node.ts new file mode 100644 index 000000000..5ffb87e1e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/fixtures/stream-under-node.ts @@ -0,0 +1,25 @@ +import * as Effect from 'effect/Effect'; +import * as HttpClient from 'effect/http/HttpClient'; +import * as HttpClientRequest from 'effect/http/HttpClientRequest'; +import * as Stream from 'effect/Stream'; +import { deployHttpClientLayer } from '../../deploy-http-client.ts'; +import { refreshingEngineClient } from './refreshing-engine-client.ts'; + +const origin = process.argv[2] ?? ''; +const { client, received } = refreshingEngineClient(origin); + +const status = await Effect.runPromise( + Effect.gen(function* () { + const response = yield* (yield* HttpClient.HttpClient).execute( + HttpClientRequest.post(`${origin}/v1/stream`).pipe( + HttpClientRequest.bodyStream( + Stream.make(new TextEncoder().encode('chunk1'), new TextEncoder().encode('chunk2')), + { contentType: 'application/octet-stream' }, + ), + ), + ); + return response.status; + }).pipe(Effect.scoped, Effect.provide(deployHttpClientLayer(client, origin))), +); + +process.stdout.write(JSON.stringify({ status, received })); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts deleted file mode 100644 index 76eb72a9f..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/management-http-client.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import * as http from 'node:http'; -import type { AddressInfo } from 'node:net'; -import * as Prisma from '@alchemy.run/prisma'; -import type { Credentials, Retry } from '@distilled.cloud/prisma'; -import { getWorkspaces } from '@distilled.cloud/prisma/management'; -import { createManagementApiClient } from '@prisma/management-api-sdk'; -import * as Effect from 'effect/Effect'; -import * as Fiber from 'effect/Fiber'; -import * as HttpClient from 'effect/http/HttpClient'; -import * as HttpClientRequest from 'effect/http/HttpClientRequest'; -import type * as Layer from 'effect/Layer'; -import type { ManagementApiClient } from '../client.ts'; -import { managementHttpClientLayer } from '../management-http-client.ts'; -import { providers } from '../providers.ts'; - -const ORIGIN = 'https://api.prisma.test'; - -/** - * The SDK client the engine hands a command as `ctx.api`, over a fetch that - * records each request. The engine wraps the client in a Proxy that turns - * every property into an async call, so this does too: the adapter may only - * call the client's methods. - */ -function fakeEngineClient(respond: (request: Request) => Promise | Response) { - const requests: Request[] = []; - const sdk = createManagementApiClient({ - baseUrl: ORIGIN, - fetch: async (request: Request) => { - requests.push(request); - return respond(request); - }, - }); - const client = new Proxy( - {}, - { - get: (_target, property) => - typeof property === 'symbol' || property === 'then' - ? undefined - : async (...args: unknown[]) => Reflect.apply(Reflect.get(sdk, property), sdk, args), - }, - ) as ManagementApiClient; - return { client, requests }; -} - -const run = ( - client: ManagementApiClient, - effect: Effect.Effect, -) => - Effect.runPromise( - effect.pipe(Effect.scoped, Effect.provide(managementHttpClientLayer(client, ORIGIN))), - ); - -describe('managementHttpClientLayer', () => { - test('a Management API request goes through the client with its body, query and headers intact and no Authorization', async () => { - const { client, requests } = fakeEngineClient(() => - Response.json({ data: { id: 'proj_1' } }, { status: 201, headers: { 'x-request-id': 'r1' } }), - ); - const body = '{"name":"shop", "region":"eu-central-1"}'; - - const response = await run( - client, - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient; - const response = yield* http.execute( - HttpClientRequest.post(`${ORIGIN}/v1/projects`).pipe( - HttpClientRequest.setUrlParams({ limit: '5', cursor: 'a b' }), - HttpClientRequest.bodyText(body, 'application/json'), - HttpClientRequest.setHeader('x-custom', 'kept'), - ), - ); - return { - status: response.status, - header: response.headers['x-request-id'], - text: yield* response.text, - }; - }), - ); - - expect(response).toEqual({ status: 201, header: 'r1', text: '{"data":{"id":"proj_1"}}' }); - expect(requests).toHaveLength(1); - const [request] = requests; - if (request === undefined) throw new Error('no request'); - expect(request.method).toBe('POST'); - expect(request.url).toBe(`${ORIGIN}/v1/projects?limit=5&cursor=a+b`); - expect(await request.text()).toBe(body); - expect(request.headers.get('content-type')).toBe('application/json'); - expect(request.headers.get('x-custom')).toBe('kept'); - expect(request.headers.get('x-prisma-client-name')).toBe('composer'); - expect(request.headers.has('authorization')).toBe(false); - }); - - test('an error response keeps its status and body', async () => { - const { client } = fakeEngineClient( - () => new Response('{"error":{"code":"quota"}}', { status: 422 }), - ); - - const result = await run( - client, - Effect.gen(function* () { - const response = yield* (yield* HttpClient.HttpClient).get(`${ORIGIN}/v1/projects/p1`); - return { status: response.status, json: yield* response.json }; - }), - ); - - expect(result).toEqual({ status: 422, json: { error: { code: 'quota' } } }); - }); - - test('a failure in the client is a TransportError carrying the cause', async () => { - const failure = new Error('CLI.CREDENTIALS_REQUIRED'); - const { client } = fakeEngineClient(() => Promise.reject(failure)); - - const error = await run( - client, - Effect.flip( - Effect.flatMap(HttpClient.HttpClient, (http) => http.get(`${ORIGIN}/v1/projects`)), - ), - ); - - expect(error.reason._tag).toBe('TransportError'); - expect(error.reason.cause).toBe(failure); - }); - - test('interrupting the request aborts the client call', async () => { - let signal: AbortSignal | undefined; - const { client } = fakeEngineClient( - (request) => - new Promise((_resolve, reject) => { - signal = request.signal; - request.signal.addEventListener('abort', () => reject(request.signal.reason)); - }), - ); - - await run( - client, - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient; - const fiber = yield* Effect.forkChild(http.get(`${ORIGIN}/v1/projects`)); - yield* Effect.promise(async () => { - while (signal === undefined) await new Promise((resolve) => setTimeout(resolve, 1)); - }); - yield* Fiber.interrupt(fiber); - }), - ); - - expect(signal?.aborted).toBe(true); - }); - - describe('another host', () => { - const uploads: { method: string; headers: http.IncomingHttpHeaders; body: string }[] = []; - const server = http.createServer((request, response) => { - let body = ''; - request.on('data', (chunk) => { - body += String(chunk); - }); - request.on('end', () => { - uploads.push({ method: request.method ?? '', headers: request.headers, body }); - response.writeHead(200).end(); - }); - }); - let uploadUrl = ''; - beforeAll(async () => { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - uploadUrl = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}/upload?sig=abc`; - }); - afterAll(() => { - server.close(); - }); - - test('goes to the node transport with an explicit Content-Length and no credential, not through the client', async () => { - const { client, requests } = fakeEngineClient(() => new Response(null, { status: 500 })); - - const status = await run( - client, - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient; - const response = yield* http.execute( - HttpClientRequest.put(uploadUrl).pipe( - HttpClientRequest.bodyUint8Array( - new TextEncoder().encode('artifact'), - 'application/gzip', - ), - ), - ); - return response.status; - }), - ); - - expect(status).toBe(200); - expect(requests).toEqual([]); - expect(uploads).toHaveLength(1); - expect(uploads[0]?.method).toBe('PUT'); - expect(uploads[0]?.body).toBe('artifact'); - expect(uploads[0]?.headers['content-length']).toBe('8'); - expect(uploads[0]?.headers['authorization']).toBeUndefined(); - }); - }); -}); - -/** The type lists the stack services the providers need when a stack runs them; these tests only call the clients the bundle exposes. */ -const bundle = (client: ManagementApiClient) => - providers({ client }) as unknown as Layer.Layer< - Prisma.PrismaClient | Credentials | HttpClient.HttpClient | Retry.Retry - >; - -describe('providers({ client })', () => { - test("an upstream provider's request goes through the supplied client, with no token anywhere", async () => { - const saved = process.env['PRISMA_SERVICE_TOKEN']; - delete process.env['PRISMA_SERVICE_TOKEN']; - const { client, requests } = fakeEngineClient(() => - Response.json({ data: { id: 'proj_1', name: 'shop' } }), - ); - try { - const project = await Effect.runPromise( - Effect.gen(function* () { - const prisma = yield* Prisma.PrismaClient; - return yield* prisma.getProject('proj_1'); - }).pipe(Effect.scoped, Effect.provide(bundle(client))), - ); - - expect(project).toMatchObject({ id: 'proj_1' }); - // Alchemy addresses the default origin; the client sends the path to its own server. - expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([ - `GET ${ORIGIN}/v1/projects/proj_1`, - ]); - expect(requests[0]?.headers.has('authorization')).toBe(false); - } finally { - if (saved !== undefined) process.env['PRISMA_SERVICE_TOKEN'] = saved; - } - }); -}); - -describe('providers({ client }) and the generated operations', () => { - test('a Distilled operation goes through the supplied client with no Authorization', async () => { - const { client, requests } = fakeEngineClient(() => - Response.json({ data: [], pagination: { hasMore: false, nextCursor: null } }), - ); - - await Effect.runPromise(getWorkspaces({}).pipe(Effect.scoped, Effect.provide(bundle(client)))); - - expect(requests.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual( - ['GET /v1/workspaces'], - ); - expect(requests[0]?.headers.has('authorization')).toBe(false); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts index f91c7e5fb..b18218cbc 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/builds/reporter.ts @@ -107,7 +107,7 @@ async function beginSession( // The caller's already-authenticated client, when the engine supplied one // — "present means the extension must not build its own from the - // environment" (ContainerCredentials). The env token is the standalone + // environment" (CallerCredentials). The env token is the standalone // fallback, for hosts driving `@prisma/composer/control` without an engine. const injected = input.credentials?.client; const token = env['PRISMA_SERVICE_TOKEN']; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index 6c7740c24..a553f100b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -1,5 +1,5 @@ import { createManagementApiClient } from '@prisma/management-api-sdk'; -import type * as Config from 'effect/Config'; +import * as Config from 'effect/Config'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; @@ -9,6 +9,7 @@ import { fromEnv, managementApiBaseUrl, PrismaCredentials, + suppliedClientOrigin, } from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -23,10 +24,8 @@ export class ManagementClient extends Context.Service => + supplied === undefined + ? managementApiBaseUrl() + : Effect.gen(function* () { + const env = { + PRISMA_API_URL: yield* Config.String('PRISMA_API_URL').pipe(Config.withDefault('')), + PRISMA_MANAGEMENT_API_URL: yield* Config.String('PRISMA_MANAGEMENT_API_URL').pipe( + Config.withDefault(''), + ), + }; + return yield* Effect.try({ + try: () => suppliedClientOrigin(env), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }); + }); + +/** The caller's already-authenticated client, or one built from `PRISMA_SERVICE_TOKEN` on `apiOrigin` when the caller supplies none. */ export const suppliedOrFromEnv = ( supplied: ManagementApiClient | undefined, + apiOrigin: string, ): Layer.Layer => supplied === undefined - ? layer().pipe(Layer.provide(fromEnv())) + ? layer({ apiOrigin }).pipe(Layer.provide(fromEnv())) : Layer.succeed(ManagementClient, supplied); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts index 7d8c0a340..3385421b6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts @@ -1,3 +1,4 @@ +import { CliStructuredError } from '@internal/foundation/errors'; import * as Config from 'effect/Config'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; @@ -33,32 +34,32 @@ const isLoopbackHost = (hostname: string) => hostname === '[::1]'; /** Same validation as upstream alchemy's `PrismaEnvironment`: an HTTP(S) origin, HTTPS unless loopback, no credentials, no path/query/fragment. */ +const normalizedOrigin = (value: string): string => { + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Prisma Management API URL must use HTTP or HTTPS.'); + } + if (url.username.length > 0 || url.password.length > 0) { + throw new Error('Prisma Management API URL must not contain credentials.'); + } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { + throw new Error('Prisma Management API URL must use HTTPS unless it targets a loopback host.'); + } + if ( + (url.pathname !== '/' && url.pathname !== '') || + url.search.length > 0 || + url.hash.length > 0 + ) { + throw new Error( + 'Prisma Management API URL must be an origin without a path, query, or fragment.', + ); + } + return url.origin; +}; + const normalizeBaseUrl = (value: string): Effect.Effect => Effect.try({ - try: () => { - const url = new URL(value); - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - throw new Error('Prisma Management API URL must use HTTP or HTTPS.'); - } - if (url.username.length > 0 || url.password.length > 0) { - throw new Error('Prisma Management API URL must not contain credentials.'); - } - if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { - throw new Error( - 'Prisma Management API URL must use HTTPS unless it targets a loopback host.', - ); - } - if ( - (url.pathname !== '/' && url.pathname !== '') || - url.search.length > 0 || - url.hash.length > 0 - ) { - throw new Error( - 'Prisma Management API URL must be an origin without a path, query, or fragment.', - ); - } - return url.origin; - }, + try: () => normalizedOrigin(value), catch: (cause) => cause instanceof Error ? cause @@ -77,12 +78,12 @@ export const deploySourceHeaders = (): Record => ({ }); /** - * The Management API origin every Prisma-Cloud client in this package uses — - * Composer's own SDK client AND upstream alchemy's postgres providers resolve - * it through this one function, so `PRISMA_API_URL` can never point them at - * different hosts. Mirrors upstream alchemy's `PrismaEnvironment` resolution: - * `PRISMA_API_URL`, then `PRISMA_MANAGEMENT_API_URL`, then the public origin, - * normalized and validated identically. + * The Management API origin when no client is supplied: `PRISMA_API_URL`, then + * `PRISMA_MANAGEMENT_API_URL`, then the public origin, normalized and validated + * as upstream alchemy's `PrismaEnvironment` does. Composer's client built from + * `PRISMA_SERVICE_TOKEN` and upstream's providers both use it, so they always + * address the same host. A supplied client decides its own host; see + * {@link suppliedClientOrigin}. */ export const managementApiBaseUrl = ( env?: Readonly>, @@ -98,3 +99,27 @@ export const managementApiBaseUrl = ( Config.withDefault(DEFAULT_BASE_URL), Effect.flatMap(normalizeBaseUrl), ); + +/** + * The origin a supplied client sends to. The `prisma` CLI builds `ctx.api` on + * `PRISMA_MANAGEMENT_API_URL` alone, trimmed, or the public origin (prisma-cli + * `getApiBaseUrl`), so Management API requests are recognised by that origin. + * `PRISMA_API_URL` cannot move a supplied client, so when it names another + * origin this throws `DEPLOY.MANAGEMENT_API_URL_CONFLICT` instead of ignoring it. + */ +export const suppliedClientOrigin = (env: Readonly>): string => { + const engineValue = env['PRISMA_MANAGEMENT_API_URL']?.trim() || DEFAULT_BASE_URL; + const origin = normalizedOrigin(engineValue); + const composerValue = env['PRISMA_API_URL']; + if (composerValue && normalizedOrigin(composerValue) !== origin) { + throw new CliStructuredError( + 'DEPLOY.MANAGEMENT_API_URL_CONFLICT', + `PRISMA_API_URL is ${composerValue}, but the deploy's Management API client sends to ${engineValue}.`, + { + why: "The deploy sends every Management API request through the client it was given. For `prisma deploy` that is the `prisma` CLI's client, which reads PRISMA_MANAGEMENT_API_URL (default https://api.prisma.io) and ignores PRISMA_API_URL.", + fix: 'Set PRISMA_MANAGEMENT_API_URL to the host you want, and unset PRISMA_API_URL.', + }, + ); + } + return origin; +}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts similarity index 77% rename from packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts rename to packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts index da4783b8f..62797f7fb 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/management-http-client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts @@ -32,19 +32,30 @@ const rawCalls = (client: ManagementApiClient) => "openapi-fetch's per-method calls take any path string at runtime; its typed signature only narrows the path to the documented ones, and this client forwards paths Alchemy built" >(client); -/** openapi-fetch reads an error body to give it back parsed, so the response it returns has no body left. */ +const isJson = (contentType: string | null): boolean => + contentType !== null && /[/+]json\b/i.test(contentType); + +/** + * openapi-fetch reads an error body and gives it back parsed, so the response + * it returns has no body left. A JSON body is serialized again, so a JSON + * string keeps its quotes; any other body was left as text. + */ const withErrorBody = (response: Response, error: unknown): Response => { if (!response.bodyUsed) return response; const headers = new Headers(response.headers); headers.delete('content-length'); - return new Response(typeof error === 'string' ? error : JSON.stringify(error), { + const body = + typeof error === 'string' && !isJson(response.headers.get('content-type')) + ? error + : JSON.stringify(error); + return new Response(body, { status: response.status, statusText: response.statusText, headers, }); }; -/** A `fetch` that sends each request through the SDK client, which owns authentication. */ +/** A `fetch` that sends each request through the SDK client. The client owns authentication, so any `Authorization` the caller set is dropped. */ const fetchThrough = (client: ManagementApiClient): typeof globalThis.fetch => { const calls = rawCalls(client); const send = async (input: RequestInfo | URL, init?: RequestInit): Promise => { @@ -53,9 +64,9 @@ const fetchThrough = (client: ManagementApiClient): typeof globalThis.fetch => { if (!isMethod(method)) { return Promise.reject(new Error(`The Management API client cannot send ${method}.`)); } - const headers: Record = Object.fromEntries( - new Headers(init?.headers).entries(), - ); + const forwarded = new Headers(init?.headers); + forwarded.delete('authorization'); + const headers: Record = Object.fromEntries(forwarded.entries()); // Without a content type of its own, openapi-fetch would label the body JSON. if (init?.body != null && headers['content-type'] === undefined) headers['content-type'] = null; const { response, error } = await calls[method](`${url.pathname}${url.search}`, { @@ -71,13 +82,14 @@ const fetchThrough = (client: ManagementApiClient): typeof globalThis.fetch => { }; /** - * The deploy's `HttpClient`. Requests to the Management API origin go through - * `client`, which sets and refreshes the credential; every other host, such as - * a presigned artifact upload URL, goes to node's transport unauthenticated, + * The deploy's `HttpClient`, for every host. Requests to the Management API + * `origin` go through `client`, which alone authenticates them: their own + * `Authorization` header is removed first. Every other host, such as a + * presigned artifact upload URL, goes to node's transport unauthenticated, * because those uploads need node's explicit Content-Length. Every request * carries the deploy-source headers. */ -export const managementHttpClientLayer = ( +export const deployHttpClientLayer = ( client: ManagementApiClient, origin: string, ): Layer.Layer => diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts index d453c9f45..507634008 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -5,43 +5,41 @@ import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import type { ManagementApiClient } from './client.ts'; import * as client from './client.ts'; -import { managementApiBaseUrl } from './credentials.ts'; -import { managementHttpClientLayer } from './management-http-client.ts'; +import { deployHttpClientLayer } from './deploy-http-client.ts'; /** The collection of Prisma resource providers. */ export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} /** - * Upstream's `PrismaEnvironment` and the generated operations' `Credentials`, - * with the base URL from `managementApiBaseUrl()` (the resolver `client.ts` - * uses, so `PRISMA_API_URL` moves every client together) and no token: the - * bundle's `HttpClient` sends Management API requests through - * `ManagementClient`, which authenticates them. + * Upstream's `PrismaEnvironment`, with no token: the bundle's `HttpClient` + * sends Management API requests through `ManagementClient`, which + * authenticates them. Upstream's type has no case for "the HTTP client + * authenticates", so `type` is the only value it allows, and `source` says + * where the credential really is. */ -const prismaEnvironment = () => - Layer.effect( - Prisma.PrismaEnvironment, - Effect.map(managementApiBaseUrl(), (baseUrl) => ({ - type: 'serviceToken' as const, - source: { type: 'env' as const, details: "the deploy's Management API client" }, - baseUrl, - })), - ); +const prismaEnvironment = (baseUrl: string, clientSupplied: boolean) => + Layer.succeed(Prisma.PrismaEnvironment, { + type: 'serviceToken' as const, + source: clientSupplied + ? { + type: 'stored' as const, + details: + "held by the caller's Management API client (for `prisma deploy`, the `prisma` CLI's signed-in credential), which authenticates each request", + } + : { type: 'env' as const, details: 'PRISMA_SERVICE_TOKEN' }, + baseUrl, + }); -const operationCredentials = () => - Layer.effect( - Credentials, - Effect.map(managementApiBaseUrl(), (apiBaseUrl) => Effect.succeed({ apiBaseUrl })), - ); +const operationCredentials = (apiBaseUrl: string) => + Layer.succeed(Credentials, Effect.succeed({ apiBaseUrl })); -/** The bundle's `HttpClient`, over `ManagementClient` (see `managementHttpClientLayer`). */ -const prismaManagementHttpLayer = Layer.unwrap( - Effect.gen(function* () { - const management = yield* client.ManagementClient; - const origin = yield* managementApiBaseUrl(); - return managementHttpClientLayer(management, origin); - }), -); +/** The bundle's `HttpClient`, over `ManagementClient` (see `deployHttpClientLayer`). */ +const prismaDeployHttpLayer = (origin: string) => + Layer.unwrap( + Effect.gen(function* () { + return deployHttpClientLayer(yield* client.ManagementClient, origin); + }), + ); /** * Upstream alchemy's live providers for the postgres family (Project, @@ -55,7 +53,7 @@ const prismaManagementHttpLayer = Layer.unwrap( * (`AlchemyProfile`/`CredentialsStore`), and Composer deliberately runs * without one — no TTY prompt, no non-interactive hard-fail. */ -const upstreamPrismaProviders = () => +const upstreamPrismaProviders = (baseUrl: string, clientSupplied: boolean) => Layer.mergeAll( Prisma.ProjectProvider(), Prisma.DatabaseProvider(), @@ -68,8 +66,8 @@ const upstreamPrismaProviders = () => Prisma.CustomDomainProvider(), ).pipe( Layer.provideMerge(Prisma.PrismaClientLive), - Layer.provideMerge(prismaEnvironment()), - Layer.provideMerge(operationCredentials()), + Layer.provideMerge(prismaEnvironment(baseUrl, clientSupplied)), + Layer.provideMerge(operationCredentials(baseUrl)), ); /** @@ -78,9 +76,10 @@ const upstreamPrismaProviders = () => * * `options.client` is the caller's authenticated Management API client; without * one, the bundle builds its own from `PRISMA_SERVICE_TOKEN`. Either way, every - * Management API request, upstream's and Composer's, goes through that client. + * Management API request, upstream's and Composer's, goes through that client, + * at the origin `client.managementApiOrigin` resolves. * - * The bundle's ambient `HttpClient` is {@link prismaManagementHttpLayer}: + * The bundle's ambient `HttpClient` is {@link prismaDeployHttpLayer}: * upstream's operations resolve it when they run. Upstream's providers run * their operations against this bundle's output, so their credentials are * merged into it. Invariant: no Composer provider may resolve the ambient @@ -88,22 +87,25 @@ const upstreamPrismaProviders = () => * override. */ export const providers = (options?: { readonly client?: ManagementApiClient | undefined }) => - Layer.effect( - Providers, - Provider.collection([ - Prisma.Project, - Prisma.Database, - Prisma.Connection, - Prisma.App, - Prisma.Deployment, - Prisma.EnvironmentVariable, - Prisma.Bucket, - Prisma.BucketAccessKey, - Prisma.CustomDomain, - ]), - ).pipe( - Layer.provideMerge(upstreamPrismaProviders()), - Layer.provideMerge(prismaManagementHttpLayer), - Layer.provideMerge(client.suppliedOrFromEnv(options?.client)), - Layer.orDie, - ); + Layer.unwrap( + Effect.map(client.managementApiOrigin(options?.client), (origin) => + Layer.effect( + Providers, + Provider.collection([ + Prisma.Project, + Prisma.Database, + Prisma.Connection, + Prisma.App, + Prisma.Deployment, + Prisma.EnvironmentVariable, + Prisma.Bucket, + Prisma.BucketAccessKey, + Prisma.CustomDomain, + ]), + ).pipe( + Layer.provideMerge(upstreamPrismaProviders(origin, options?.client !== undefined)), + Layer.provideMerge(prismaDeployHttpLayer(origin)), + Layer.provideMerge(client.suppliedOrFromEnv(options?.client, origin)), + ), + ), + ).pipe(Layer.orDie); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index 3de91cd42..e6e935643 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -16,6 +16,7 @@ import * as Headers from 'effect/http/Headers'; import * as HttpClientRequest from 'effect/http/HttpClientRequest'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; +import type { ManagementApiClient } from '../../client.ts'; import { stateLayerAgainst } from '../layer.ts'; import { acquireDeployLease, @@ -303,8 +304,9 @@ describe('prismaStateLayer against the platform state API', () => { projectId: PROJECT_ID, branchId: BRANCH_ID, }, + supplied?: ManagementApiClient, ): Promise => { - const layer = stateLayerAgainst(fake.origin, ids).pipe( + const layer = stateLayerAgainst(fake.origin, ids, supplied).pipe( Layer.provide(stackContext), ) as unknown as Layer.Layer; return Effect.runPromise( @@ -337,6 +339,33 @@ describe('prismaStateLayer against the platform state API', () => { expect(fake.liveLeaseIds()).toEqual([]); }); + test('over a supplied client, every state request goes through it with the lease header and no Authorization', async () => { + const received: Request[] = []; + const supplied = createManagementApiClient({ + baseUrl: fake.origin, + fetch: (request: Request) => { + received.push(request.clone()); + return fetch(request); + }, + }); + const value = createdResource({ fqn: 'app/db' }); + + await runLayer( + (service) => service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value }), + undefined, + supplied, + ); + + const stateRequests = received.filter((request) => + request.url.includes('/alchemy-state/state/'), + ); + expect(stateRequests.map((request) => request.method)).toContain('PUT'); + expect(received.every((request) => !request.headers.has('authorization'))).toBe(true); + for (const request of stateRequests) { + expect(request.headers.get(LEASE_HEADER)).not.toBeNull(); + } + }); + test('the layer’s merged redaction entry hides the lease header from anything rendered in its context', async () => { const rendered = await runLayer(() => Effect.sync(() => JSON.stringify(Headers.fromInput({ [LEASE_HEADER]: 'lease-secret-1' }))), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index b85b10dc5..b20400f7d 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -16,8 +16,7 @@ import { BUILD_ID_ENV } from '../builds/resources.ts'; import { withResourceReporting } from '../builds/state-store.ts'; import * as client from '../client.ts'; import { resolveDefaultBranchId } from '../container.ts'; -import * as credentials from '../credentials.ts'; -import { managementHttpClientLayer } from '../management-http-client.ts'; +import { deployHttpClientLayer } from '../deploy-http-client.ts'; import { failOnEmptyScopeWithLiveResources, scopeOccupied } from './empty-scope.ts'; import { hostedStateBootstrapError } from './errors.ts'; import { @@ -59,12 +58,9 @@ export const prismaStateLayer = ( }, supplied?: client.ManagementApiClient, ): Layer.Layer => - // The origin comes from the same resolver upstream's providers use - // (credentials.managementApiBaseUrl), so PRISMA_API_URL moves the state - // client and the resource providers together, never one without the other. Layer.unwrap( - credentials - .managementApiBaseUrl() + client + .managementApiOrigin(supplied) .pipe(Effect.map((origin) => stateLayerAgainst(origin, ids, supplied))), ).pipe(Layer.orDie); @@ -106,10 +102,7 @@ export const stateLayerAgainst = ( supplied?: client.ManagementApiClient, ): Layer.Layer => { const { projectId, branchId, defaultBranchId } = ids; - const dependencies = - supplied === undefined - ? client.layer({ apiOrigin }).pipe(Layer.provide(credentials.fromEnv())) - : Layer.succeed(client.ManagementClient, supplied); + const dependencies = client.suppliedOrFromEnv(supplied, apiOrigin); return Layer.effect( State, @@ -152,18 +145,14 @@ export const stateLayerAgainst = ( ); } - // The store's requests go through `mgmt`, which authenticates them, so - // the bearer header Alchemy's store always sets is removed. + // `mgmt` authenticates the store's requests, and the deploy's HttpClient + // drops the empty bearer header the store sets from `authToken`. const service = yield* makeHttpStateStore({ url: `${apiOrigin}/v1/projects/${projectId}/branches/${stateBranchId}/alchemy-state`, authToken: '', - transformClient: (req) => - req.pipe( - HttpClientRequest.removeHeader('authorization'), - HttpClientRequest.setHeader(LEASE_HEADER, Redacted.value(lease.leaseId)), - ), + transformClient: HttpClientRequest.setHeader(LEASE_HEADER, Redacted.value(lease.leaseId)), id: 'prisma-postgres', - }).pipe(Effect.provide(managementHttpClientLayer(mgmt, apiOrigin))); + }).pipe(Effect.provide(deployHttpClientLayer(mgmt, apiOrigin))); const migrated = migrateRowsOnRead(service); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/container.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/container.test.ts index cc2a8d781..7094b552c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/container.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/container.test.ts @@ -314,6 +314,47 @@ describe('containerDescriptor().ensure()', () => { }); }); +describe('a supplied client and PRISMA_API_URL', () => { + const urls = (apiUrl: string | undefined) => ({ + PRISMA_API_URL: apiUrl, + PRISMA_MANAGEMENT_API_URL: 'https://api.staging.test', + }); + + test('naming another host than the client, ensure and locate fail with a structured error before any request', async () => { + const state = newFakeState(); + await withEnv(urls('https://api.prisma.io'), async () => { + const descriptor = containerDescriptor(); + const credentials = { workspaceId: 'ws-1', client: fakeClient(state) }; + for (const call of [ + () => descriptor.ensure({ appName: 'storefront', environment: 'staging' }, credentials), + () => descriptor.locate({ appName: 'storefront', environment: 'staging' }, credentials), + ]) { + const error = await call().then( + () => undefined, + (thrown: unknown) => thrown, + ); + expect(error).toMatchObject({ + code: 'DEPLOY.MANAGEMENT_API_URL_CONFLICT', + message: expect.stringContaining('https://api.staging.test'), + fix: expect.stringContaining('PRISMA_MANAGEMENT_API_URL'), + }); + } + expect(state.projectCreateCalls).toBe(0); + }); + }); + + test("naming the client's own host, ensure proceeds", async () => { + const state = newFakeState(); + await withEnv(urls('https://api.staging.test/'), async () => { + const instance = await containerDescriptor({ region: () => 'us-east-1' }).ensure( + { appName: 'storefront', environment: 'staging' }, + { workspaceId: 'ws-1', client: fakeClient(state) }, + ); + expect(instance.projectId).toBe('proj-1'); + }); + }); +}); + describe('a request from an older CLI, with no environment name', () => { const olderInput = { appName: 'storefront', stage: 'staging' } as unknown as LocateContainerInput; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 6c76a4d54..bafa078b2 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -127,7 +127,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { // Why each file reads the environment: serializer.ts (params, the input // document's env-sourced leaves and boot reads), control/extension.ts - // (optional PRISMA_WORKSPACE_ID and PRISMA_REGION), container.ts (PRISMA_WORKSPACE_ID, PRISMA_SERVICE_TOKEN), + // (optional PRISMA_WORKSPACE_ID and PRISMA_REGION), container.ts (PRISMA_WORKSPACE_ID, PRISMA_SERVICE_TOKEN, and the Management API URLs a supplied client is checked against), // preflight.ts (PRISMA_SERVICE_TOKEN; values come from the pools), local-target/attach.ts (the shell environment a dev // server keeps under its Composer rows, ADR-0056), compute.ts (run() // applies stashRows to the environment), testing.ts (bootstrapService). @@ -143,7 +143,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'compute.ts', count: 2 }, - { file: 'container.ts', count: 3 }, + { file: 'container.ts', count: 4 }, { file: 'control/extension.ts', count: 2 }, { file: 'local-target/attach.ts', count: 1 }, { file: 'preflight.ts', count: 1 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/container.ts b/packages/1-prisma-cloud/1-extensions/target/src/container.ts index 8f1036474..78abb7e45 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/container.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/container.ts @@ -8,7 +8,7 @@ * not import. */ import type { - ContainerCredentials, + CallerCredentials, ContainerDescriptor, ContainerInstance, LocateContainerInput, @@ -23,6 +23,7 @@ import { managementClientLayer, type ProjectRegion, resolveContainer, + suppliedClientOrigin, } from '@internal/lowering'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; @@ -147,7 +148,7 @@ export function deserialize(serialized: string): PrismaCloudContainer { } /** The credentials this extension's container lifecycle accepts per call. */ -type PrismaCloudCredentials = ContainerCredentials; +type PrismaCloudCredentials = CallerCredentials; /** Construction-time injection. Per-call credentials outrank it wherever both are present. */ interface ContainerDeps { @@ -185,6 +186,11 @@ function requireTokenUnlessInjected(client: ManagementApiClient | undefined): vo } } +/** A supplied client sends to its own host, so a `PRISMA_API_URL` naming another one fails here, before anything is created or planned. */ +function requireOneManagementApiOrigin(credentials: PrismaCloudCredentials | undefined): void { + if (credentials?.client !== undefined) suppliedClientOrigin(process.env); +} + function clientFor( credentials: PrismaCloudCredentials | undefined, deps: ContainerDeps | undefined, @@ -220,6 +226,7 @@ async function ensureContainer( deps: ContainerDeps | undefined, ): Promise { requireEnvironmentName(input); + requireOneManagementApiOrigin(credentials); const workspaceId = requireWorkspaceId(credentials); const client = clientFor(credentials, deps); requireTokenUnlessInjected(client); @@ -262,6 +269,7 @@ async function locateContainer( deps: ContainerDeps | undefined, ): Promise { requireEnvironmentName(input); + requireOneManagementApiOrigin(credentials); const workspaceId = requireWorkspaceId(credentials); const client = clientFor(credentials, deps); requireTokenUnlessInjected(client); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index 503620933..9d24f2ae9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -4,8 +4,9 @@ */ import type { - ContainerCredentials, + CallerCredentials, ExtensionDescriptor, + ProvidersInput, StateDescriptor, } from '@internal/core/config'; import type { ProvisionerDescriptor } from '@internal/core/deploy'; @@ -173,7 +174,7 @@ const selfOriginValue: ServiceProviderParam['valueForService'] = (provisioned, a /** The user-facing state descriptor: `state: prismaState()` in the `composer` section of `prisma.config.ts` (ADR-0017). */ export const prismaState = (): StateDescriptor => ({ extension: PRISMA_CLOUD_EXTENSION_ID, - create: (container, credentials?: ContainerCredentials) => { + create: (container, credentials?: CallerCredentials) => { const { projectId, branchId, defaultBranchId } = prismaCloudContainerOf(container); return prismaStateLayer( { @@ -336,7 +337,7 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor container: containerDescriptor({ region: () => o().region }), - providers: (credentials?: ContainerCredentials) => + providers: ({ credentials }: ProvidersInput) => asProvidersLayer( Layer.mergeAll( Prisma.providers({ client: credentials?.client }), From d7125d31a3743dcf02b13ab195e34cef3ae00e7e Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 14:41:30 +0100 Subject: [PATCH 12/14] fix(prisma-cloud): send state store requests through ctx.api in a real deploy The deploy HTTP client provided its custom Fetch only while building FetchHttpClient.layer. The fetch client reads Fetch when it sends, from the context it was first built in, and a real deploy has already built FetchHttpClient.layer in the same layer memo map. The state store reused that instance, sent its requests with global fetch and the empty bearer token, and got HTTP 401 under a prisma login session. Provide Fetch on each Management API request instead. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../lowering/src/deploy-http-client.ts | 12 ++++--- .../src/state/__tests__/state-api.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts index 62797f7fb..31aab89ef 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts @@ -96,15 +96,17 @@ export const deployHttpClientLayer = ( Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { - const management = yield* HttpClient.HttpClient.pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, fetchThrough(client)), - ); + // The fetch client reads `Fetch` when it sends, not when it is built, and + // `FetchHttpClient.layer` may be an instance the deploy already built. + const throughClient = fetchThrough(client); + const management = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer)); const other = yield* NodeHttpClient.makeNodeHttp; const routed = HttpClient.makeWith( Effect.flatMap((request) => URL.parse(request.url)?.origin === origin - ? management.execute(request) + ? management + .execute(request) + .pipe(Effect.provideService(FetchHttpClient.Fetch, throughClient)) : other.execute(request), ), Effect.succeed, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index e6e935643..01ab11957 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -366,6 +366,39 @@ describe('prismaStateLayer against the platform state API', () => { } }); + test('state requests go through a supplied client even when the deploy already built a FetchHttpClient', async () => { + const received: Request[] = []; + const supplied = createManagementApiClient({ + baseUrl: fake.origin, + fetch: (request: Request) => { + received.push(request.clone()); + return fetch(request); + }, + }); + const layer = stateLayerAgainst( + fake.origin, + { projectId: PROJECT_ID, branchId: BRANCH_ID }, + supplied, + ).pipe( + Layer.provide(Layer.merge(stackContext, FetchHttpClient.layer)), + ) as unknown as Layer.Layer; + const value = createdResource({ fqn: 'app/db' }); + + await Effect.runPromise( + Effect.gen(function* () { + const service = yield* yield* State; + yield* service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value }); + }).pipe(Effect.orDie, Effect.provide(layer)), + ); + + expect( + received + .filter((request) => request.url.includes('/alchemy-state/state/')) + .map((request) => request.method), + ).toContain('PUT'); + expect(fake.countRequests(/PUT .*\/alchemy-state\/state\//)).toBe(1); + }); + test('the layer’s merged redaction entry hides the lease header from anything rendered in its context', async () => { const rendered = await runLayer(() => Effect.sync(() => JSON.stringify(Headers.fromInput({ [LEASE_HEADER]: 'lease-secret-1' }))), From e958c4e238ba74f00ff8310e9988cc2735cd8ff8 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 16:33:23 +0100 Subject: [PATCH 13/14] ci: build lowering dependencies before the logical-id resolution check The check runs lowering source directly, which now imports @internal/foundation/errors from its built dist. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/e2e-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e-deploy.yml b/.github/workflows/e2e-deploy.yml index 24fc5bf1c..6f7ddad75 100644 --- a/.github/workflows/e2e-deploy.yml +++ b/.github/workflows/e2e-deploy.yml @@ -182,4 +182,5 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup - run: pnpm install --frozen-lockfile + - run: pnpm turbo run build --filter '@internal/lowering^...' - run: bun scripts/logical-id-resolution.ts From 98460cd52b7feb2265bb71035cd43657c020ee77 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 6 Nov 2026 17:22:19 +0100 Subject: [PATCH 14/14] fix(cli): keep the converge contract at 2 No release has shipped contract 2, so the merge with main does not need a new number. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md | 2 +- packages/0-framework/3-tooling/cli/src/converge-contract.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md index f0b6766d5..4ed0fe17e 100644 --- a/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -19,7 +19,7 @@ yield* Alchemist.apply(plan); This supersedes [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) for `deploy`. `destroy` and `prisma dev` keep the generated stack file and the Alchemy child until they move. - **The app's install runs the converge.** The executor loads `lower` and `convergeInProcess` (together, the app converge) from `@prisma/composer/internal/converge`, resolved from the directory of the app's `prisma.config.ts`, as the old child path resolved Alchemy's bin. The extension descriptors, providers and state store come from that install through `prisma.config.ts`, and the `prisma` host pins its own Composer version ([ADR-0050](ADR-0050-composer-runs-as-prisma-deploy-and-prisma-dev.md)). Taking the converge from the host's copy would put two copies of `alchemy` and `effect` in one process whenever the two versions differ; taking it from the app's install keeps one. An app whose `@prisma/composer` is missing or predates the entries fails with `DEPS.CONVERGE_UNRESOLVABLE` before anything is created. -- **Host and app speak one converge contract.** The host's operation code calls the app's `lower` and converge and reads back their results, so both sides must agree on the shapes between them: `LowerOptions` (contract 2 added `credentials`, which an older app would ignore and then look for a token in the environment; contract 3 replaced `configPool` with `appConfigurationRead`, which an older app would ignore and then lower with no app configuration), `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and `DeploymentResult`. They need not run the same package version. The `prisma` host pins its Composer and ships on its own schedule (ADR-0050), and create-prisma pins the app's, so the two versions differ between releases, when an app keeps an older Composer, and on dev builds; an exact version match would refuse every deploy in those cases. So the app's install exports a `CONVERGE_CONTRACT` integer from `@prisma/composer/internal/converge-contract`, an entry that imports nothing. The host reads it before loading anything else and refuses a different number with `DEPS.CONVERGE_CONTRACT_MISMATCH`, which names both contract numbers and both package versions and says which side to upgrade. The number is bumped only when one of those shapes changes in a way the other side cannot read. +- **Host and app speak one converge contract.** The host's operation code calls the app's `lower` and converge and reads back their results, so both sides must agree on the shapes between them: `LowerOptions` (contract 2 added `credentials`, which an older app would ignore and then look for a token in the environment), `ConvergeRequest`, `ConvergeOutcome`, `ConvergeEvent` and `DeploymentResult`. They need not run the same package version. The `prisma` host pins its Composer and ships on its own schedule (ADR-0050), and create-prisma pins the app's, so the two versions differ between releases, when an app keeps an older Composer, and on dev builds; an exact version match would refuse every deploy in those cases. So the app's install exports a `CONVERGE_CONTRACT` integer from `@prisma/composer/internal/converge-contract`, an entry that imports nothing. The host reads it before loading anything else and refuses a different number with `DEPS.CONVERGE_CONTRACT_MISMATCH`, which names both contract numbers and both package versions and says which side to upgrade. The number is bumped only when one of those shapes changes in a way the other side cannot read. - **Values, not environment variables.** `lower()` takes the resolved containers and the preflight payloads as required values (`LowerOptions.containers`, `LowerOptions.preflight`), and each extension reads its own payload as `LowerContext.preflight`. Only the generated stack files for `destroy` and `prisma dev` read the environment transports, through one core helper, `transportedLowerInputs(config)`. This amends [ADR-0038](ADR-0038-containers-are-an-extension-descriptor.md) for `deploy`: no container crosses a process boundary. - **The summary is a value.** The report hook runs inside the apply, in the same process, so `deploy` returns the `DeploymentSummary` directly. This amends [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` and the failure-cause file are not used by `deploy`. - **Progress is engine step events.** Alchemy's typed events become steps under `deploy.apply`, one per resource, and its log lines become messages. Alchemy's console sink is replaced and `SigilRenderer` is not used, so human and JSON output keep one format and nothing but the engine writes to stdout. diff --git a/packages/0-framework/3-tooling/cli/src/converge-contract.ts b/packages/0-framework/3-tooling/cli/src/converge-contract.ts index e319d659e..6dce29636 100644 --- a/packages/0-framework/3-tooling/cli/src/converge-contract.ts +++ b/packages/0-framework/3-tooling/cli/src/converge-contract.ts @@ -6,4 +6,4 @@ * other side cannot read. Imports nothing, so a host can read it before * loading the converge. */ -export const CONVERGE_CONTRACT = 3; +export const CONVERGE_CONTRACT = 2;