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 diff --git a/architecture.config.json b/architecture.config.json index c6bae9ce5..e50b7c099 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -768,6 +768,18 @@ "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/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 e74de067e..05b05b674 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -271,7 +271,7 @@ The values one command knows are secrets: the value under each secrets key the g ### The app configuration file -The JSON file a command's first process writes for its second process. It holds what Load read from the app configuration (the root input fields the root's body passes on, and the text of each configuration key the graph reads) and the first process's node addresses. It never holds a secret: Load and the CLI refuse to write one into it. Deploy and destroy write it to `.prisma-composer/app-configuration--.json` and delete it when the run ends. `prisma dev` writes it to `/app-configuration-.json` before each converge and deletes it as soon as that converge ends. Only its owner can read it. The CLI also deletes it when its process exits. A file left by a process killed outright is deleted at the start of a later deploy, destroy or `prisma dev`, once it is ten minutes old. It is not `prisma.config.ts`, which is "the config file". In code: `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`, `AppConfigurationRead`, `readAppConfigurationFile`. +The JSON file a command's first process writes for its second process. It holds what Load read from the app configuration (the root input fields the root's body passes on, and the text of each configuration key the graph reads) and the first process's node addresses. It never holds a secret: Load and the CLI refuse to write one into it. Destroy writes it to `.prisma-composer/app-configuration--.json` and deletes it when the run ends. Deploy writes none: it runs Alchemy in its first process and hands `lower()` what Load read as a value (ADR-0064). `prisma dev` writes it to `/app-configuration-.json` before each converge and deletes it as soon as that converge ends. Only its owner can read it. The CLI also deletes it when its process exits. A file left by a process killed outright is deleted at the start of a later deploy, destroy or `prisma dev`, once it is ten minutes old. It is not `prisma.config.ts`, which is "the config file". In code: `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`, `AppConfigurationRead`, `readAppConfigurationFile`. ### Config adapter @@ -410,6 +410,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_CONTRACT_MISMATCH`. + ### Control plane / Execution plane Two the framework modes. **Control plane**: import the topology, validate, build the @@ -456,6 +460,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/05-prisma-cloud/build-reporting.md b/docs/design/05-prisma-cloud/build-reporting.md index aaab265a5..daa753f77 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 known secret and credential-looking value removed (ADR-0062). - **`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/core-model.md b/docs/design/10-domains/core-model.md index e17b178e8..fe475d5f9 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -694,7 +694,7 @@ interface LowerOptions { readonly name: string // stack name (+ Load's root id override) readonly environment?: string // the environment being deployed, as --environment gave it; // Load's environment.name. The generated stack files always pass it. - readonly appConfigurationRead?: AppConfigurationRead // passed to Load and the serializer; the generated stack files read it from the app configuration file + readonly appConfigurationRead?: AppConfigurationRead // passed to Load and the serializer; deploy passes its own Load's, the generated stack files read it from the app configuration file readonly firstProcessNodeAddresses?: readonly string[] // from the same file; lowering refuses a graph whose addresses differ // `prisma deploy` runs each service's build-adapter assembler and writes // the resulting bundle dirs here, into the generated stack file it hands to diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 57510ab43..9333df536 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`) ([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 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). Beside it the CLI writes the app configuration file, `.prisma-composer/app-configuration--.json`, mode 0600, and deletes it when the run ends. It holds what Load read from the app configuration and the first process's node addresses; it is not `prisma.config.ts`. The CLI passes its path in `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`. The second process reads it, runs no adapter, and refuses a graph whose node addresses differ from the first process's. It never receives the app secrets. The CLI refuses to write the file when any value in it contains a known secret (`CONFIG.SECRET_AS_PLAIN_VALUE`). It also deletes the file when its process exits. At the start of each `deploy` and `destroy` it deletes the app configuration files an earlier run left in `.prisma-composer/`, when the process id in the file name is not running and the file is older than ten minutes. The CLI engine gives it the terminal, so its output is not redacted; Composer redacts its failure cause. +9. **Lower and apply.** `deploy` calls core's `lower()` with the pipeline's results as values: the name, the environment, what Load read from the app configuration (`appConfigurationRead`), 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, with every known secret and credential-looking value removed (ADR-0062). 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 known secret and credential-looking value removed. The deployment summary is redacted the same way. + - **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 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). Beside it the CLI writes the app configuration file, `.prisma-composer/app-configuration--.json`, mode 0600, and deletes it when the run ends. It holds what Load read from the app configuration and the first process's node addresses; it is not `prisma.config.ts`. The CLI passes its path in `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`. The second process reads it, runs no adapter, and refuses a graph whose node addresses differ from the first process's. It never receives the app secrets. The CLI refuses to write the file when any value in it contains a known secret (`CONFIG.SECRET_AS_PLAIN_VALUE`). It also deletes the file when its process exits. At the start of each `deploy` and `destroy` it deletes the app configuration files an earlier run left in `.prisma-composer/`, when the process id in the file name is not running and the file is older than ten minutes. The CLI engine gives the child the terminal, so its output is not redacted; Composer redacts its failure cause. `deploy` writes no app configuration file: it has no second process, and `lower()` receives what Load read as a value. 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,7 +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 app configuration 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 | -| The app configuration file would hold a known secret (`CONFIG.SECRET_AS_PLAIN_VALUE`) | which root input field or configuration key holds a known secret, and the secrets key whose value matched, never the value; a secret of eight characters or more counts anywhere in a value, a shorter one only as the whole value. The fix: read the secret with `envSecret()`, or give the plain value text that does not contain it | +| 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 | +| `destroy` or `prisma dev`: the app configuration file would hold a known secret (`CONFIG.SECRET_AS_PLAIN_VALUE`) | which root input field or configuration key holds a known secret, and the secrets key whose value matched, never the value; a secret of eight characters or more counts anywhere in a value, a shorter one only as the whole value. The fix: read the secret with `envSecret()`, or give the plain value text that does not contain it | ## Out of scope (designed around) @@ -329,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..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 @@ -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`. @@ -25,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 b0ae47b02..1e0f07d31 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 @@ -60,10 +60,10 @@ Closed subcode registry: - `CONFIG` — SECTION_MISSING, FIELD_INVALID, FIELD_UNKNOWN, FIELD_RETIRED, FILE_MISSING, FILE_RETIRED, EXTENSION_DUPLICATE, EXTENSION_MISSING, DESCRIPTOR_MISSING, DESCRIPTOR_KIND_MISMATCH, ENTRY_MISSING, ADAPTER_FAILED, ADAPTER_INVALID, SECRET_AS_PLAIN_VALUE *(EXPORT_INVALID, PATH_MISMATCH and EVALUATION_FAILED retired by [ADR-0049](ADR-0049-composers-configuration-is-the-composer-section-of-prisma-config.md))*. ENTRY_MISSING: neither `entry` in the `composer` section nor `--entry`, or the operation's `entry` input, names the entry module. ADAPTER_FAILED: a config or secrets adapter threw or rejected; its message is redacted. ADAPTER_INVALID: an adapter returned something other than an object of values, or a secrets adapter returned a value that is not text. SECRET_AS_PLAIN_VALUE: a value in the app configuration file would contain a known secret, a value under a secrets key the graph names; the error names the field or configuration key and the secrets key, never the value. - `COMPOSE` — ENTRY_UNLOADABLE, ENTRY_EXPORT_INVALID, ROOT_NOT_MODULE, NAME_MISSING, GRAPH_INVALID - `ASSEMBLE` — EXTENSION_MISSING, DESCRIPTOR_MISSING, DESCRIPTOR_KIND_MISMATCH, SERVICE_MISSING, BUILD_FAILED -- `DEPLOY` — FLAG_INVALID, TARGET_CONFLICT, TARGET_MISSING, TARGET_NOT_FOUND, 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 *(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. STACK_WRITE_FAILED covers the app configuration file as well as the stack file. `prisma dev` starts the deploy engine the same way `deploy` does, so it raises ALCHEMY_BIN_MISSING and NODE_MISSING under `DEPLOY` too. +- `DEPLOY` — FLAG_INVALID, TARGET_CONFLICT, TARGET_MISSING, TARGET_NOT_FOUND, 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, 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. ENGINE_FAILED: Alchemy failed. `deploy` runs Alchemy in process ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)) 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`. 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`. STACK_WRITE_FAILED covers the app configuration file as well as the stack file. `destroy` and `prisma dev` start the deploy engine as a child, so they raise ALCHEMY_BIN_MISSING, NODE_MISSING and STACK_WRITE_FAILED; `deploy` does not. - `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))*. 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. +- `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_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 edit to this record. @@ -71,7 +71,7 @@ Adding a subcode is an edit to this list; adding a namespace is an edit to this The exit-code convention is the shared one (prisma/prisma's CLI Style Guide), and it 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 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 number, not a statement in the CLI's own code space: an expected engine failure may therefore surface as `1` (or any other value) without contradicting the convention above. +**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 number, not a statement in the CLI's own code space: an expected engine failure may therefore surface as `1` (or any other value) without contradicting the convention above. The human rendering of an envelope (`render-error.ts`) is the shared layout: @@ -106,5 +106,6 @@ The `code` is the machine-branching surface: hosts driving `@prisma/composer/con - prisma/prisma's [ADR 239](https://github.com/prisma/prisma/blob/main/docs/architecture%20docs/adrs/ADR%20239%20-%20Errors%20are%20structural%20envelopes%20with%20dotted%20namespace%20codes.md) and [ADR 245](https://github.com/prisma/prisma/blob/main/docs/architecture%20docs/adrs/ADR%20245%20-%20Errors%20are%20structured%20at%20origin%3B%20results%20carry%20one%20ok%20discriminator.md): the parents of this record. - [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md): the programmatic surface these failures cross. -- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md): the spawned child behind the exit-status exception. +- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md): the spawned child behind the exit-status exception, for `destroy` and `prisma dev`. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md): `deploy` runs Alchemy in process, so its failures exit 2. - [ADR-0029](ADR-0029-a-secret-is-an-environment-sourced-value-the-framework-never-stores.md): what Composer redacts. 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 270e7004d..f8edf9991 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 @@ -26,7 +26,7 @@ Composer reads its configuration from one place: the `composer` section of the a The section holds the `PrismaAppConfig` that [ADR-0017](ADR-0017-control-plane-loads-through-the-app-config.md) describes: the extension descriptors the deploy looks nodes up in, and the one state store. It also takes three optional fields: `entry`, the module whose default export is the application root; `config`, the config adapters; and `secrets`, the secrets adapters ([ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md)). The CLI engine (the command-line runtime every Prisma command family mounts into) loads the file, and Composer's section validator hands the command a `ComposerConfigSource`: `{ value, file }`, the validated section and the file that declared it. The programmatic operations on `@prisma/composer/control` take the same `ComposerConfigSource` as a required `config` input, and run the same checks on it. - `entry` is an input to the tool, like the ORM section's schema path, not an application fact. It resolves against the directory of the declaring file. `--entry ` on `prisma deploy` and `prisma dev`, or an operation's `entry` input, overrides it and resolves against the working directory. With neither, the command fails with `CONFIG.ENTRY_MISSING`. -- `config` and `secrets` are arrays of adapters. Both default to `[environmentVariables()]`, applied by the command when it reads them, not by the validator. Only the command's first process calls an adapter. A deploy's, a `destroy`'s and `prisma dev`'s second process, which runs Alchemy, reads what the first process's Load read from the app configuration file instead (ADR-0062). +- `config` and `secrets` are arrays of adapters. Both default to `[environmentVariables()]`, applied by the command when it reads them, not by the validator. Only the command's first process calls an adapter. `deploy` runs Alchemy in that process and hands `lower()` what Load read as a value ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). A `destroy`'s and `prisma dev`'s second process, which runs Alchemy, reads what the first process's Load read from the app configuration file instead (ADR-0062). ## Reasoning @@ -34,13 +34,13 @@ The Prisma CLI engine already discovers, evaluates and merges `prisma.config.ts` The section holds executable values. Extension descriptors carry provider layers, container lifecycles and preflight hooks, and adapters are functions; none of these can be described by a schema. So the validator checks only what identifies each value: `extensions` is an array of objects, each with a non-empty string `id` and an object `nodes`, and the ids are distinct; `state` is an object with a string `extension` and a `create` function; `entry`, when present, is a non-empty string; `config` and `secrets`, when present, are arrays of functions. The validator never calls an adapter. Everything else passes through untouched: the command receives the descriptors and adapters as the very objects the config file built, never copies. Any other top-level key of the section is an error, because a key the validator does not know is a key the deploy would silently ignore. -The engine resolves a section over the chain of config files it loaded, nearest first, and by default merges a section key by key. Composer's section declares its own merge, which takes the nearest declaring file's section whole. A section therefore always comes from exactly one file, and that matters beyond tidiness: the deploy runs its infrastructure engine (Alchemy) in a second process, and the stack file generated for that process imports the declaring `prisma.config.ts` and reads its `composer` export. A section assembled from two files would have no single file to import. +The engine resolves a section over the chain of config files it loaded, nearest first, and by default merges a section key by key. Composer's section declares its own merge, which takes the nearest declaring file's section whole. A section therefore always comes from exactly one file, and that matters beyond tidiness: `destroy` and `prisma dev` run the infrastructure engine (Alchemy) in a second process, and the stack file generated for that process imports the declaring `prisma.config.ts` and reads its `composer` export. A section assembled from two files would have no single file to import. That import is also why the command needs the declaring file, not only the section. The engine gives the validator the section's provenance, the files that declared it, nearest first, and the engine's own guidance is that a validator resolves what it needs from provenance and returns it in its value. So the validator returns `{ value, file: provenance.files[0] }`, and every command that declares the section receives the file without doing anything itself. The second process imports the file for its descriptors, not for its values. It runs no adapter: the first process writes what its Load read from the app configuration, and its node addresses, to the app configuration file, readable only by its owner and deleted when the converge ends, and the stack file reads it from `@prisma/composer/internal/app-configuration-file`, an entry that is not public API. The app configuration file is not `prisma.config.ts`. So a deploy calls each adapter once, in its first process, a `.env` file edited during a deploy cannot change what is deployed, and a secrets manager is asked once per deploy. `prisma dev` calls the adapters in its first process at start and again at each rebuild. -What makes a section valid lives in one module that does not import the engine, because `@prisma/composer` must not depend on it. The validator calls that module and turns its findings into engine diagnostics. The programmatic operations call the same module at the start of the pipeline, before the entry is loaded and before any container, preflight or deploy engine runs: a host that drives a deploy from code evaluates `prisma.config.ts` itself and passes `config: { value, file }`, and gets the same refusals a CLI user would. Passing the whole export instead of its `composer` property is caught this way, with a fix that says so. One invariant cannot be checked in-process: the second process imports `file` and uses its `composer` export, while the first process uses `value`, so the two must be the same section. Checking it would mean evaluating the user's config file inside `@prisma/composer`, which is the private loader this decision removes. +What makes a section valid lives in one module that does not import the engine, because `@prisma/composer` must not depend on it. The validator calls that module and turns its findings into engine diagnostics. The programmatic operations call the same module at the start of the pipeline, before the entry is loaded and before any container, preflight or deploy engine runs: a host that drives a deploy from code evaluates `prisma.config.ts` itself and passes `config: { value, file }`, and gets the same refusals a CLI user would. Passing the whole export instead of its `composer` property is caught this way, with a fix that says so. One invariant cannot be checked in-process: under `destroy` and `prisma dev`, the second process imports `file` and uses its `composer` export, while the first process uses `value`, so the two must be the same section. Checking it would mean evaluating the user's config file inside `@prisma/composer`, which is the private loader this decision removes. The old setup is refused, not migrated. A project that still has only `prisma-composer.config.ts` gets `CONFIG.SECTION_MISSING` from the validator, which names the old file because the engine gives the validator no directory to look in when no file declares the section. A section that still carries the old `configPath` pointer gets `CONFIG.FIELD_RETIRED`. A declaring file with `prisma-composer.config.{ts,mts,mjs,js}` beside it gets `CONFIG.FILE_RETIRED`, from the validator on the CLI and from the pipeline's check in code, so an old file is never silently ignored. All three name the fix: move `extensions` and `state` into the section and delete the old file. Only that directory is checked; searching further would be guessing. @@ -49,7 +49,8 @@ Moving evaluation into the engine also retired Composer's effect pre-flight. The ## Consequences - `prisma.config.ts` is the only config file Composer reads. Examples and fixtures declare the section there, next to any `orm` section. -- The generated deploy and dev stack files import the declaring `prisma.config.ts` and pass its `composer` export to `lower()`. The second process evaluates the whole file, not only Composer's section: the imports of every section in it, such as the ORM's config module and `definePrismaConfig`'s package, run there with the second process's environment, and a failure in any of them fails the deploy. This is accepted because the section's descriptors are functions that cannot cross a process boundary, so the second process must import the file that built them. +- `deploy` loads no stack file and starts no second process: it passes the section `value` to the `lower()` of the app's own install ([ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md)). +- The generated destroy and dev stack files import the declaring `prisma.config.ts` and pass its `composer` export to `lower()`. The second process evaluates the whole file, not only Composer's section: the imports of every section in it, such as the ORM's config module and `definePrismaConfig`'s package, run there with the second process's environment, and a failure in any of them fails the command. This is accepted because the section's descriptors are functions that cannot cross a process boundary, so the second process must import the file that built them. - The second process reads what the first process's Load read, and the first process's node addresses, from the app configuration file the first process names in `PRISMA_COMPOSER_APP_CONFIGURATION_FILE`. A stack file run by hand after the run ended cannot read the app configuration file, because it is gone. - `dev` reads the section once, at start, and watches the declaring file. After an edit it reports that the file changed and pauses rebuilds until it is restarted, so the two processes never run against different versions of the file. When what an adapter returns changes, it pauses the same way. - `deploy`, `destroy`, `dev` and `log` on `@prisma/composer/control` take a required `config: ComposerConfigSource` and never look for a config file. Their `entry` input is optional and overrides `config.value.entry`. `log` reads only `value`; it takes the same input so a host builds it once. @@ -80,3 +81,4 @@ Moving evaluation into the engine also retired Composer's effect pre-flight. The - [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — the programmatic surface that takes the `config` input. - [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — the `CONFIG` and `DEPS` namespaces. - [ADR-0062](ADR-0062-environment-values-come-from-resolve-and-config-adapters.md) — the config and secrets adapters, the app configuration and the app configuration file. +- [ADR-0064](ADR-0064-deploy-runs-alchemy-in-process.md) — `deploy` runs Alchemy in its own process, so only `destroy` and `prisma dev` import the declaring file in a second process. 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 e3ea1e25a..01f57683a 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 what Load read from the app configuration to `lower()` as a value, so it starts no second process and writes no app configuration file. What this record says about a deploy's second process, the app configuration file and `CONFIG.SECRET_AS_PLAIN_VALUE` now holds for `prisma dev` and `destroy` only. Alchemy's output under `deploy` reaches Composer as events and log lines, which it redacts like everything else it prints. + ```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 new file mode 100644 index 000000000..4ed0fe17e --- /dev/null +++ b/docs/design/90-decisions/ADR-0064-deploy-runs-alchemy-in-process.md @@ -0,0 +1,60 @@ +# 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 { lower, convergeInProcess } = await import(/* the app's @prisma/composer/internal/converge */); +const stack = lower(root, config, { + name, environment, appConfigurationRead, 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. + +- **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), `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`). +- **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. + +## 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`, `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 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 + +- **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. +- **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 + +- 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 c1ffbd1bb..fd079658b 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,18 +59,18 @@ _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 all of its input, plain values and secrets together, as one Standard Schema (`input`), read back through `service.input()`. The binding at `provision()` is typed from the schema's input type, with the same type a module's binding has, so a wrong literal or a source in the wrong kind of field fails to compile. Secret fields are `secretString()`; a field that may receive a value known only at boot is wrapped in `field()`. The deploy checks the binding with the schema, each value known only at boot in place as a pointer, and writes one self-describing input document per service with `$secret` and `$generated` markers; boot reads it, boxes the secrets and checks it again. Composer never introspects the schema. An unset `envParam()` leaves its key out, and the schema decides whether that is allowed. *(Its current text includes ADR-0061's extension to modules.)* -- [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 from a closed registry, structured at origin with why/fix splits, no catch-all codes; bugs carry no code (exit 1); recognition is structural; operation results ride the `Result` `ok` discriminator; expected failures exit 2, with the deploy engine's status passed through. The one change between origin and consumer is that the operation redacts known secrets and drops the cause. +- [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 from a closed registry, structured at origin with why/fix splits, no catch-all codes; bugs carry no code (exit 1); recognition is structural; operation results ride the `Result` `ok` discriminator; expected failures exit 2, with the deploy engine's status passed through. The one change between origin and consumer is that the operation redacts known secrets and drops the cause. *(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`, `DEPLOY.MANAGEMENT_API_URL_CONFLICT`, `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. - [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 classes (`@alchemy.run/prisma`), 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`. Amended 2026-10-30: the provider is the `@alchemy.run/prisma` package from Alchemy beta.84 (formerly the `alchemy/Prisma` module); resource type ids are unchanged.)* -- [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: `extensions`, `state`, and optional `entry`, `config` and `secrets` adapter lists. The validator checks only what identifies each value, passes the file's own objects through, never calls an adapter, rejects unknown keys, takes the nearest declaring file's section whole, and returns `{ value, file }`; the programmatic operations take the same input. The old config file is refused, not migrated. Only the first process runs adapters; the second process imports the file for its descriptors and reads what Load read from the app configuration file. +- [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: `extensions`, `state`, and optional `entry`, `config` and `secrets` adapter lists. The validator checks only what identifies each value, passes the file's own objects through, never calls an adapter, rejects unknown keys, takes the nearest declaring file's section whole, and returns `{ value, file }`; the programmatic operations take the same input. The old config file is refused, not migrated. Only the first process runs adapters; the second process imports the file for its descriptors and reads what Load read from the app configuration file. *(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,5 +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({ id, 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.)* *(Amended 2026-11-02: `publicUrl()` takes its identity as `id`, not `name`.)* - [ADR-0061](ADR-0061-a-module-declares-its-input-as-a-compute-service-does.md) — A module declares its input as one Standard Schema, `input`, exactly as a compute service does, and whoever provisions it binds that input with a binding typed from the schema. The body receives `input` as a dictionary with one value per field; it passes fields on by name, or lists a plain field in `resolve()`, and never reads their data, so the graph cannot branch on its input. The dictionary itself is not a value: passing it on whole fails to compile and fails at Load, for every module. A field that may receive a value known only at boot is wrapped in `field()`; a secret is a `secretString()` field. The module `params`/`paramNeed()` and `secrets`/`secret()` slots are removed. The root may declare `input`, bound to the app configuration. -- [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; `fn` never sees a pointer. Config adapters produce the app configuration, which is the root's input and what `envParam()` reads; secrets adapters produce the app secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Only the first process runs adapters, once per deploy; the second reads the app configuration file and refuses a graph whose node addresses differ. Secrets stay out through pointers and redaction. Every environment has a name and none is special. +- [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; `fn` never sees a pointer. Config adapters produce the app configuration, which is the root's input and what `envParam()` reads; secrets adapters produce the app secrets, which fill missing `envSecret()` platform variables. Both lists default to `[environmentVariables()]`; `dotenv()` reads `.env` files through `dotenv-flow`. Only the first process runs adapters, once per deploy; the second reads the app configuration file and refuses a graph whose node addresses differ. Secrets stay out through pointers and redaction. Every environment has a name and none is special. *(Amended by ADR-0064: `deploy` runs Alchemy in its first process and writes no app 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. 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/building-an-app.md b/docs/guides/building-an-app.md index 532e52ec2..077c1009d 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -918,11 +918,11 @@ Pass the root's input fields on by name, as `from: input.EMAIL_FROM` is above. N The root's schema checks the app configuration before anything is created, so a missing value fails with the schema's message. -A deploy reads the adapters once, in the command's own process. `prisma dev` reads them at start and again at each rebuild. The deploy engine runs in a second process, which runs no adapter. It reads the values it needs from the app configuration file, which the first process writes to `.prisma-composer/`. Only you can read that file, and it is deleted when the deploy ends. It is not `prisma.config.ts`. A root field your body passes on must hold data JSON can hold exactly: text, numbers, booleans, null, arrays and plain objects. +A deploy reads the adapters once, in the command's own process, and runs the deploy engine in that process too. `prisma dev` reads them at start and again at each rebuild. `prisma dev` and the `destroy` operation run the deploy engine in a second process, which runs no adapter. It reads the values it needs from the app configuration file, which the first process writes. Only you can read that file, and it is deleted when the run ends. It is not `prisma.config.ts`. A root field your body passes on must hold data JSON can hold exactly: text, numbers, booleans, null, arrays and plain objects. -The app configuration file never holds a secret. A deploy fails if a root field your body passes on has the same name as an `envSecret()` in the graph, or if any value in the file contains a secret's value, such as the password in a URL (`CONFIG.SECRET_AS_PLAIN_VALUE`). A secret shorter than eight characters, such as a `dev` placeholder, counts only when it is the whole value, so it does not refuse `dev@example.com`. The error names the field and the secret's name, never the value. Read the secret with `envSecret()`, or give the plain value text that is not a secret. +The app configuration file never holds a secret. `prisma dev` and `destroy` fail if a root field your body passes on has the same name as an `envSecret()` in the graph, or if any value in the file contains a secret's value, such as the password in a URL (`CONFIG.SECRET_AS_PLAIN_VALUE`). A secret shorter than eight characters, such as a `dev` placeholder, counts only when it is the whole value, so it does not refuse `dev@example.com`. The error names the field and the secret's name, never the value. Read the secret with `envSecret()`, or give the plain value text that is not a secret. -Composer replaces a secret's value with `[redacted]` wherever it prints. The secrets it knows are the values under the names an `envSecret()` in the graph reads, in either list. The lists' other values, such as `EMAIL_FROM` in `.env` or the shell's `SHLVL`, are not secrets and print as they are, apart from values that look like credentials. The deploy engine's own output is not redacted yet; Composer redacts its failure message. Nothing is redacted by the known secrets before Load has named them: an adapter that throws, and a root schema failure that echoes a field's value, are redacted only by the credential-looking rule. +Composer replaces a secret's value with `[redacted]` wherever it prints. The secrets it knows are the values under the names an `envSecret()` in the graph reads, in either list. The lists' other values, such as `EMAIL_FROM` in `.env` or the shell's `SHLVL`, are not secrets and print as they are, apart from values that look like credentials. Under `prisma deploy`, the deploy engine's progress and log lines reach you through Composer and are redacted. Under `prisma dev`, the engine's own output is not redacted yet; Composer redacts its failure message. Nothing is redacted by the known secrets before Load has named them: an adapter that throws, and a root schema failure that echoes a field's value, are redacted only by the credential-looking rule. A value in `.env` or `.env.local` is read by every environment, including a `prisma deploy` to `production` from your machine. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index a85c06102..9c4288be6 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`** 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 @@ -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,28 +732,18 @@ 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 every secret Composer knows 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. -- The deploy engine's own output passes through Composer on its way to your terminal, with every secret Composer knows replaced by `[redacted]`: the value under each name an `envSecret()` names, in your `config` and `secrets` lists, however short. Other values in those lists, such as the shell's, are printed as they are, apart from values that look like credentials, such as a long value under a name containing `TOKEN` or `KEY`. So the engine shows plain progress lines, without colour. + 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. A failed `destroy` also carries an optional `diagnostics` object with 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. +- Composer replaces every secret it knows with `[redacted]` in the deploy engine's output: the value under each name an `envSecret()` names, in your `config` and `secrets` lists, however short. Other values in those lists, such as the shell's, are printed as they are, apart from values that look like credentials, such as a long value under a name containing `TOKEN` or `KEY`. `deploy` redacts each engine event and log line before `onEvent` receives it. `destroy` passes its engine child's output through Composer on its way to your terminal, so that engine shows plain progress lines, without colour. - **`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 each deploy reporter's variables 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. - **`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..74beb21bb 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 signed-in `prisma`. Sign in once; it opens a browser and stores a session for one workspace: ```sh pnpm prisma auth login pnpm prisma deploy ``` -In CI, set `PRISMA_SERVICE_TOKEN` to a service token from the -[Prisma Console](https://console.prisma.io) instead. +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/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..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 @@ -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'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 contai 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 d5c703dda..bc7df4aa1 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, nodeAddresses } from '../graph.ts'; import { @@ -42,6 +43,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'; @@ -92,6 +94,8 @@ const stateSentinel = (tag: string): AlchemyStateLayer => const opts = (extra: Partial = {}): LowerOptions => ({ name: 'hello', bundles: {}, + containers: new Map(), + preflight: new Map(), ...extra, }); @@ -647,11 +651,15 @@ describe('lowering a module root — a single service', () => { expect(() => run( - lowering(root, config, { - name: 'shop', - bundles, - firstProcessNodeAddresses: ['shop', 'shop.gone'], - }), + lowering( + root, + config, + opts({ + name: 'shop', + bundles, + firstProcessNodeAddresses: ['shop', 'shop.gone'], + }), + ), ), ).toThrow( 'The deploy\'s second process built a different graph from the one Composer checked in its first process. Only in the second process: "shop.echo". Only in the first process: "shop.gone". A module body must build the same graph every time; one that reads the process environment, a file or the clock can build a different one.', @@ -668,11 +676,15 @@ describe('lowering a module root — a single service', () => { expect(() => run( - lowering(root, config, { - name: 'shop', - bundles, - firstProcessNodeAddresses: [...addresses].reverse(), - }), + lowering( + root, + config, + opts({ + name: 'shop', + bundles, + firstProcessNodeAddresses: [...addresses].reverse(), + }), + ), ), ).not.toThrow(); }); @@ -763,6 +775,7 @@ describe('lowering a module root — a single service', () => { opts: opts(), application: undefined, container: undefined, + preflight: undefined, lowered, provisioned: new Map(), }; @@ -909,13 +922,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); @@ -939,13 +956,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'); @@ -962,13 +983,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'); @@ -983,13 +1008,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'); @@ -1002,13 +1031,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( @@ -1023,13 +1056,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'); @@ -1061,13 +1098,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'); @@ -1089,10 +1130,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); @@ -1103,13 +1148,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(); @@ -1129,7 +1178,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'); @@ -1149,13 +1198,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(); @@ -1207,13 +1256,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}`); @@ -1581,24 +1630,26 @@ 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) }, + { phase: 'application', container: passed }, + { phase: 'resource', container: passed }, ]); }); - test('the same extension with its transport var unset sees ctx.container as undefined', () => { - const { config, seen } = fakeExtensionWithContainer(); + test('an extension with no container passed sees ctx.container as undefined, whatever the environment holds', () => { + const { descriptor, config, seen } = fakeExtensionWithContainer(); - run(lowering(dbModule(), config, opts())); + withEnv({ [containerEnvVarName(descriptor.id)]: JSON.stringify({ appName: 'shop' }) }, () => { + run(lowering(dbModule(), config, opts())); + }); expect(seen).toEqual([ { phase: 'application', container: undefined }, @@ -1606,6 +1657,54 @@ describe('container threading through lowering() (the parent→child transport r ]); }); + 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', + 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())); + }); + + 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', () => { const seen: unknown[] = []; const descriptor: ExtensionDescriptor = { @@ -1797,11 +1896,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}`)); @@ -1825,11 +1928,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({ @@ -1841,11 +1948,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); @@ -1858,11 +1969,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); @@ -1873,11 +1988,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/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 c4d6a85e2..efff7fb26 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, @@ -55,8 +52,11 @@ 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). + * METHOD SYNTAX REQUIRED, like `preflight`: an extension types `input` against its own client, which assigns here only through method bivariance. + */ + 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 @@ -122,11 +122,20 @@ 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?: CallerCredentials, + ): AlchemyStateLayer; +} + +/** 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 ContainerCredentials. */ +/** 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; @@ -139,7 +148,7 @@ export interface PreflightInput { /** The app secrets the command read for this environment. Preflight fills a missing `envSecret()` platform variable from it. */ readonly secrets: AppSecrets; /** 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. */ @@ -182,7 +191,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 55eeaf637..a129c09d8 100644 --- a/packages/0-framework/1-core/core/src/control/deploy.ts +++ b/packages/0-framework/1-core/core/src/control/deploy.ts @@ -18,7 +18,9 @@ import { type ServiceNode, slotsOf, } from '../node.ts'; +import { type PreflightPayload, readPreflightPayload } from '../preflight-transport.ts'; import type { + CallerCredentials, ContainerInstance, ExtensionDescriptor, NodeDescriptor, @@ -144,6 +146,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). */ @@ -236,6 +240,12 @@ 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. 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; + /** 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?: CallerCredentials | undefined; } /** A build descriptor's normalized output: the produced bundle dir plus the app's runnable entry within it. */ @@ -626,7 +636,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) + ); } /** @@ -634,9 +646,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?: CallerCredentials, +): 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); @@ -680,10 +695,8 @@ export function lowering( assertSameNodeAddresses(nodeAddresses(graph), opts.firstProcessNodeAddresses); } 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 = 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 @@ -711,6 +724,7 @@ export function lowering( opts, application: undefined, container: containers.get(descriptor.id), + preflight: preflightOf(descriptor.id), lowered, provisioned, }; @@ -793,6 +807,7 @@ export function lowering( opts, application: applications.get(node.extension), container: containers.get(node.extension), + preflight: preflightOf(node.extension), lowered, provisioned, services, @@ -891,21 +906,37 @@ export function lowering( }) as Effect.Effect; } +/** + * 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, +): 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 }; +} + /** * 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 providers = opts.providers ?? mergedProviders(config); + const providers = opts.providers ?? mergedProviders(config, opts.credentials); 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 70ae3f9ec..45edf4b8a 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -8,6 +8,8 @@ "./app-identity": "./dist/app-identity.mjs", "./config": "./dist/config.mjs", "./control": "./dist/control.mjs", + "./converge": "./dist/converge.mjs", + "./converge-contract": "./dist/converge-contract.mjs", "./family": "./dist/family.mjs", "./report": "./dist/report.mjs", "./testing": "./dist/testing.mjs", @@ -35,6 +37,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__/app-converge.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts new file mode 100644 index 000000000..44726197e --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/app-converge.test.ts @@ -0,0 +1,157 @@ +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 { HOST_COMPOSER_VERSION, loadAppConverge } from '../app-converge.ts'; +import { CONVERGE_CONTRACT } from '../converge-contract.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; + 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-'))); + 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', + version: composer.version ?? HOST_COMPOSER_VERSION, + type: 'module', + 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); + } + } + return dir; +} + +async function failureOf(dir: string): Promise { + try { + await loadAppConverge(dir); + } catch (error) { + if (CliStructuredError.is(error)) return error; + throw error; + } + throw new Error('expected loadAppConverge to fail'); +} + +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' }, + files: { + 'dist/internal-converge.mjs': + "export const lower = () => {}; export const convergeInProcess = async () => ({ kind: 'applied' });", + }, + }); + + const engine = await loadAppConverge(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.CONVERGE_UNRESOLVABLE', async () => { + const failure = await failureOf(appWith(undefined)); + + 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.CONVERGE_UNRESOLVABLE', async () => { + const failure = await failureOf( + appWith({ exports: { './deploy': './dist/deploy.mjs' }, files: { 'dist/deploy.mjs': '' } }), + ); + + expect(failure.code).toBe('DEPS.CONVERGE_UNRESOLVABLE'); + }); + + test('an entry that exports neither function is DEPS.CONVERGE_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'); + }); + + 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({ ...entry, version: '0.0.1', contract: CONVERGE_CONTRACT - 1 }), + ); + + 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 new file mode 100644 index 000000000..a42b7302c --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/__tests__/converge-in-process.test.ts @@ -0,0 +1,505 @@ +/** + * 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, 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 Alchemist from 'alchemy/Alchemist'; +import * as Interaction from 'alchemy/Interaction'; +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, + failureReasons, + 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 { + operation: 'deploy', + stack: lower(app(), config, { + name: 'shop', + bundles: {}, + containers: new Map(), + preflight: new Map(), + ...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', + }), + ); + }); + + 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('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 and keeps the outcome 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).toEqual({ kind: 'applied' }); + 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( + 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', + reasons: [{ tag: 'ThingRefused', message: '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' }), + converge: { lower, converge: convergeInProcess }, + credentials: { workspaceId: 'ws_1', client: {} }, + }, + ); + + 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('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' })), + Cause.combine( + Cause.die(new Error('boom')), + Cause.fail(new Refused({ message: 'quota exceeded' })), + ), + ); + + expect(failureReasons(cause)).toEqual([ + { tag: 'Refused', message: 'quota exceeded' }, + { tag: 'Error', message: 'boom' }, + ]); + }); + + 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 6a7c00df8..f444a0d08 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, appConfigurationRead: opts.appConfigurationRead, firstProcessNodeAddresses: opts.firstProcessNodeAddresses, ...(globalThis.stubLocal === undefined ? {} : { local: globalThis.stubLocal }) })); 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, appConfigurationRead: opts.appConfigurationRead, firstProcessNodeAddresses: opts.firstProcessNodeAddresses, ...(globalThis.stubLocal === undefined ? {} : { local: globalThis.stubLocal }) })); 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/app-configuration-file', 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 dfe039392..b4e867045 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";'); @@ -157,7 +159,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-converge.ts b/packages/0-framework/3-tooling/cli/src/app-converge.ts new file mode 100644 index 000000000..ae1a46ece --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/app-converge.ts @@ -0,0 +1,131 @@ +/** + * 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, 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'; +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 { 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; + +/** 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; + } +} + +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. 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, + 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, `${CONTRACT_ENTRY} or ${ENTRY} does not resolve.`, error); + } + const { CONVERGE_CONTRACT: appContract } = await importFile(appDir, contractFile); + if (typeof appContract !== 'number') { + throw unresolvable(appDir, `${contractFile} exports no CONVERGE_CONTRACT.`); + } + if (appContract !== host.contract) { + throw contractMismatch(host, { contract: appContract, version: installedVersion(require) }); + } + 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 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..6dce29636 --- /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 = 2; 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..858ce1c89 --- /dev/null +++ b/packages/0-framework/3-tooling/cli/src/converge-in-process.ts @@ -0,0 +1,267 @@ +/** + * 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, and Alchemy's own output goes to + * stderr, so the caller renders progress in its own format. + * + * 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 { blindCast } from '@internal/foundation/casts'; +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'; +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 { + /** 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; `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 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; + +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 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, message: error.message }]; + } + return [{ tag: 'Unknown', message: typeof error === 'string' ? error : String(error) }]; + }); + 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) }]; +} + +/** 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`, then the working directory. + * A directory that cannot be restored never leaves a variable behind and + * never changes what `run` returned. + */ +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(); + 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); + changedDirectory = true; + } + return await run(); + } finally { + for (const [name, value] of previousEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + if (changedDirectory) { + try { + process.chdir(previousCwd); + } catch { + // The old directory is gone, so there is nowhere to return to; the outcome stands. + } + } + } +} + +function program(request: ConvergeRequest, emit: (event: ConvergeEvent) => void) { + return Effect.gen(function* () { + const plan = yield* Alchemist.plan({ + stack: request.stack, + operation: request.operation, + 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(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( + effect, + 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 { + flag[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 c3287a798..92c51c19c 100644 --- a/packages/0-framework/3-tooling/cli/src/deployment-summary.ts +++ b/packages/0-framework/3-tooling/cli/src/deployment-summary.ts @@ -239,3 +239,10 @@ export function engineFailureCause( ? cause : `${cause.slice(0, ENGINE_FAILURE_CAUSE_LIMIT - 1)}…`; } + +/** 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)}…`; +} 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 5384a8b06..1da11fc69 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 { namedSecretsFromEnvironment, readAppConfigurationFile } from '@prisma/composer/internal/app-configuration-file';", ); @@ -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, file.appConfigurationRead.configurationKeyText, namedSecrets)', + 'providers: localTargetProviders(resolved, transported.containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets)', ); expect(content).toContain('state: devState()'); expect(content).not.toContain('dev: true'); @@ -107,7 +107,7 @@ describe('renderDevStackFile() — the environment', () => { ' environment: "test",\n appConfigurationRead: file.appConfigurationRead,\n firstProcessNodeAddresses: file.nodeAddresses,', ); expect(content).toContain( - ' providers: localTargetProviders(resolved, containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets),', + ' providers: localTargetProviders(resolved, transported.containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets),', ); }); 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 7dd7b2cce..984d24f1a 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 @@ -71,8 +71,9 @@ function renderOptions(input: DevStackFileInput): string { lines.push(` unassembled: [${unassembled.map(quote).join(', ')}],`); } + lines.push(' ...transported,'); lines.push( - ' providers: localTargetProviders(resolved, containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets),', + ' providers: localTargetProviders(resolved, transported.containers, devDir, file.appConfigurationRead.configurationKeyText, namedSecrets),', ); lines.push(' state: devState(),'); @@ -89,9 +90,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 { namedSecretsFromEnvironment, readAppConfigurationFile } from '@prisma/composer/internal/app-configuration-file';", - "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)};`, @@ -112,7 +112,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 file = readAppConfigurationFile(process.env); 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/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__/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..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 @@ -3,14 +3,23 @@ * 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'; +import type { + CallerCredentials, + 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 { ScriptedChildProgram, TestCli } from '@prisma/cli-engine/testing'; +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'; @@ -18,35 +27,14 @@ 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() { +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, @@ -63,8 +51,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' | 'both' | false; } = {}, ): Harness { const double = createOperationsDouble(spec.fixtures ?? {}); @@ -76,8 +64,16 @@ 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() } + : spec.signedIn === 'both' + ? { + credential: activeCredential('ws_session'), + environmentCredential: activeCredential('ws_env'), + } + : { environmentCredential: activeCredential() }), }); return { cli, double }; } @@ -89,16 +85,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 +179,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 +216,226 @@ describe('credentials', () => { expect(result.exitCode).toBe(2); expect(plain(result.stderr)).toContain('CLI.CREDENTIALS_REQUIRED'); - expect(result.spawns).toEqual([]); 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. - */ - test('the converge child is given the credential by the engine', async () => { - const { cli } = composerCli({ spawnScript: childExiting(0) }); + 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?.client).toBeDefined(); + expect(Object.keys(credentials ?? {}).sort()).toEqual(['client', 'workspaceId']); + }); + + 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[0]?.envKeys).toContain('PRISMA_SERVICE_TOKEN'); + expect(result.spawns).toEqual([]); + const credentials = double.calls.deps.deploy[0]?.credentials; + expect(credentials?.workspaceId).toBe('ws_1'); + expect(Object.keys(credentials ?? {}).sort()).toEqual(['client', 'workspaceId']); + 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); + 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); - expect(result.exitCode).toBe(7); + expect(double.calls.deps.deploy[0]?.credentials?.workspaceId).toBeOneOf([ + 'ws_session', + 'ws_env', + ]); }); - test('a failed converge renders the reproduce hint and no failure envelope', async () => { - const { cli } = composerCli({ spawnScript: childExiting(1) }); - const result = await cli.run(['deploy', '--entry', 'src/service.ts'], AS_TTY); + 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); + }); +}); - 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', +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'), ); - expect(plain(result.stderr)).not.toContain('✖'); + 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: CallerCredentials[]; 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 }); + } }); +}); - /** - * 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(''); +describe('settlement', () => { + 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(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'); + }); /** - * 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. */ @@ -334,6 +460,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({ @@ -490,6 +672,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/__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 c34318183..66b5051b8 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 f71052d30..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 @@ -1,5 +1,6 @@ /** - * `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; @@ -7,16 +8,17 @@ */ import type { EngineEvent } from '@prisma/cli-engine'; import { defineCommand, flag } 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 { withConsoleOnStderr } from '../console-to-stderr.ts'; import { dataLine } from '../data-lines.ts'; import type { ComposerOperations } from '../family.ts'; import { composerSection } from '../section.ts'; -import { workspaceIdOf } from '../workspace.ts'; +import { toEngineError } from '../translate-error.ts'; const STEP_LABELS = { prepare: 'load config and app', @@ -28,13 +30,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 +72,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,38 +147,61 @@ 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 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)), - }, - operationDeps({ - alchemy, - workspaceId: await workspaceIdOf(ctx), - client: ctx.api, - }), + // 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 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, + }, + }, + ), ); + 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)); + } - 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 +231,7 @@ export const createDeployCommand = (operations: ComposerOperations) => json: () => ({ summary: summary ?? null, durationMs }), next: () => [], }, - ); - }); + ), + ); }, }); 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 50% 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 index 867620537..2594f6e0a 100644 --- a/packages/0-framework/3-tooling/cli/src/family/converge.ts +++ b/packages/0-framework/3-tooling/cli/src/family/dev-converge-child.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 { @@ -79,19 +57,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. @@ -110,14 +75,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, @@ -129,34 +94,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/generate-stack.ts b/packages/0-framework/3-tooling/cli/src/generate-stack.ts index 8832a891e..23b1254af 100644 --- a/packages/0-framework/3-tooling/cli/src/generate-stack.ts +++ b/packages/0-framework/3-tooling/cli/src/generate-stack.ts @@ -40,6 +40,7 @@ function renderOptions(input: StackFileInput): string { lines.push(` environment: ${quote(input.environment)},`); lines.push(' appConfigurationRead: file.appConfigurationRead,'); lines.push(' firstProcessNodeAddresses: file.nodeAddresses,'); + lines.push(' ...transportedLowerInputs(config),'); lines.push(' bundles: {'); for (const [id, bundle] of Object.entries(input.assembled.bundles)) { @@ -74,7 +75,7 @@ export function renderStackFile(input: StackFileInput): string { // // bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). // It runs no config adapter: it reads what the first process's Load read from the app configuration file named in PRISMA_COMPOSER_APP_CONFIGURATION_FILE. -import { lower } from '@prisma/composer/deploy'; +import { lower, transportedLowerInputs } from '@prisma/composer/deploy'; import * as report from '@prisma/composer/report'; import { readAppConfigurationFile } from '@prisma/composer/internal/app-configuration-file'; 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 75f0a6798..2b9921ae4 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,7 +20,7 @@ import type { PrismaAppConfig, RunOutcome, } from '@internal/core/config'; -import { nodeAddresses } from '@internal/core/deploy'; +import { type LowerOptions, lower } from '@internal/core/deploy'; import type { LocalTargetAttachment, LocalTargetCurrentApp, @@ -33,15 +33,13 @@ import { LEFTOVER_APP_CONFIGURATION_FILE_AGE_MS, NAMED_SECRETS_ENV, } from '../../app-configuration-file.ts'; +import { type AppConverge, HOST_COMPOSER_VERSION } from '../../app-converge.ts'; import type { ComposerConfigSource } from '../../composer-config.ts'; import { dotenv, environmentVariables } from '../../config-adapters.ts'; -import { - DEPLOYMENT_RESULT_FILE_ENV, - type DeploymentSummary, - engineFailureFilePath, -} from '../../deployment-summary.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'; -import { type AlchemyInvocation, alchemyCommandLine, spawnCommandLine } 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'; @@ -148,7 +146,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 }, }; } @@ -216,15 +215,41 @@ 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' }; + +/** 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 }; +} + +// 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'; +}); +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 Alchemy 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( @@ -236,25 +261,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: appConverge(recordingConverge(requests)), + alchemy: async () => { + spawned = true; return { exitCode: 0, signal: null }; }, + credentials: { workspaceId: 'ws_handler', client: {} }, }, ), ); - 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: {}, + }); + 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 () => { @@ -277,10 +304,10 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => { + converge: appConverge(async () => { ran = true; - return { exitCode: 0, signal: null }; - }, + return APPLIED; + }), }, ), ); @@ -306,7 +333,7 @@ describe('deploy()', () => { ...(input.entry === undefined ? {} : { entry: input.entry }), cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -338,10 +365,10 @@ describe('deploy()', () => { { config: composerConfig(fakeConfig()), cwd: app.dir }, { runAssembler: fakeAssembler, - alchemy: async () => { + converge: appConverge(async () => { ran = true; - return { exitCode: 0, signal: null }; - }, + return APPLIED; + }), }, ), ); @@ -354,177 +381,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( @@ -535,10 +395,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - invocations.push(input); - return { exitCode: 0, signal: null }; - }, + converge: appConverge(recordingConverge(requests)), }, ), ); @@ -548,7 +405,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 () => { @@ -565,7 +422,7 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -581,7 +438,7 @@ describe('deploy()', () => { const app = makeAppDir(); const containerCalls: ContainerCall[] = []; let assembled = false; - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => deployWithDeps( @@ -596,10 +453,10 @@ describe('deploy()', () => { assembled = true; return fakeAssembler(node); }, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; - }, + converge: appConverge(async () => { + convergeRan = true; + return APPLIED; + }), }, ), ); @@ -608,7 +465,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([]); }); @@ -617,21 +474,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: appConverge(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 () => { @@ -639,7 +496,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', }); @@ -650,7 +507,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 () => { @@ -658,7 +515,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 })), ); @@ -668,7 +525,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 () => { @@ -722,9 +579,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( @@ -742,10 +599,10 @@ describe('deploy()', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => { - alchemyRan = true; - return { exitCode: 0, signal: null }; - }, + converge: appConverge(async () => { + convergeRan = true; + return APPLIED; + }), }, ), ); @@ -754,11 +611,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(() => @@ -771,91 +627,27 @@ 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', - ), - }), - }, - ), - ); - - 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 }; - }, + converge: appConverge(async () => ({ + kind: 'failed', + 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.', + }, + ], + })), }, ), ); - 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 known secret read from a dotenv file, and keeps a dotenv value no envSecret() names', async () => { @@ -885,15 +677,15 @@ 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: appConverge(async () => ({ + kind: 'failed', + reasons: [ + { + tag: 'Error', + message: `error: rejected ${onlyInDotenvFile}, ${inBothPools} and ${secretNamedConfigValue}`, + }, + ], + })), }, ), ); @@ -907,8 +699,11 @@ describe('deploy()', () => { expect(JSON.stringify(result.failure)).not.toContain(secretNamedConfigValue); }); - test('an engine failure with no recorded cause keeps the bare status sentence and no engineCause', 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( @@ -917,77 +712,21 @@ describe('deploy()', () => { environment: 'ci-7', cwd: app.dir, config: composerConfig(fakeConfig()), + onEvent: (event) => events.push(event), + signal: controller.signal, }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 1, signal: null }), + converge: appConverge(recordingConverge(requests, { kind: 'interrupted' })), }, ), ); - 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 () => { - const app = makeAppDir(); - - 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'); - fs.writeFileSync(engineFailureFilePath(resultFile), 'lease held by another deploy'); - return { exitCode: null, signal: 'SIGTERM' }; - }, - }, - ), - ); - - 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', () => { @@ -1050,21 +789,214 @@ describe('deploy()', () => { expect(result.envelope.summary).toBe( 'Could not load the deploy executor: Schedule.either is not a function', ); - expect(result.cause).toEqual({ - name: 'Error', - message: 'Schedule.either is not a function', + expect(result.cause).toEqual({ + name: 'Error', + message: 'Schedule.either is not a function', + }); + }); +}); + +/** 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 === 'apply-resource' + ? `apply-resource:${event.step.fqn}` + : event.step.name; + return event.kind === 'step-started' ? `started ${name}` : `finished ${name} ${event.outcome}`; +} + +describe('deploy() credentials, app converge 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("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: { + lower: (root, config, opts) => { + lowered.push(opts); + return lower(root, config, opts); + }, + converge: recordingConverge(requests), + }, + credentials, + }, + fakeConfig({}, { calls: containerCalls }), + ); + + 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 () => { + 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.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', + './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 });", + ); + + 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'); }); -}); -/** Each event as `kind name[:address] outcome`, for asserting order without timings. */ -function stepLine(event: DeployEvent): string { - const name = - event.step.name === 'assemble-service' - ? `assemble-service:${event.step.address}` - : event.step.name; - return event.kind === 'step-started' ? `started ${name}` : `finished ${name} ${event.outcome}`; -} + 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: {} }; + // 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, + }, + }, + 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]'); + } finally { + process.env['PRISMA_SERVICE_TOKEN'] = saved; + } + }); + + 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, { converge: appConverge(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 () => { @@ -1093,7 +1025,7 @@ describe('deploy() step events', () => { report?.({ bytes: 4096 }); return fakeAssembler(node); }, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -1128,6 +1060,97 @@ 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: appConverge(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: appConverge(async (request) => { + request.onEvent?.({ + kind: 'resource', + fqn: 'site', + resourceType: 'Prisma.CustomDomain', + status: 'fail', + phase: 'failed', + message: 'domain-ownership-required', + }); + return { + kind: 'failed', + reasons: [{ tag: 'PrismaApiError', message: '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[] = []; @@ -1160,7 +1183,7 @@ describe('deploy() step events', () => { cwd: app.dir, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ).catch((error: unknown) => error), ); @@ -1193,6 +1216,7 @@ describe('deploy() step events', () => { onEvent: (event) => events.push(event), }, { + converge: appConverge(applied), runAssembler: async () => { throw new Error('no built entry'); }, @@ -1229,7 +1253,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: appConverge(applied) }, ); } finally { warnSpy.mockRestore(); @@ -1253,7 +1277,7 @@ describe('deploy() step events', () => { throw new Error('renderer bug'); }, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -1460,6 +1484,10 @@ describe('destroy()', () => { ran.push('alchemy'); return { exitCode: 0, signal: null }; }, + converge: appConverge(async () => { + ran.push('alchemy'); + return APPLIED; + }), }; const result = action === 'deploy' @@ -2564,7 +2592,7 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { endpoints: () => Promise.resolve([]), logs: async function* () {}, }; - let alchemyRan = false; + let convergeRan = false; const result = await silently(() => devWithDeps( @@ -2576,7 +2604,7 @@ describe.skipIf(process.platform === 'win32')('dev()', () => { { runAssembler: fakeAssembler, alchemy: async () => { - alchemyRan = true; + convergeRan = true; return { exitCode: 0, signal: null }; }, }, @@ -2586,7 +2614,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); }); @@ -2978,10 +3006,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( @@ -2993,10 +3021,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async (invocation) => { - invocations.push(invocation); - return { exitCode: 0, signal: null }; - }, + converge: appConverge(recordingConverge(requests)), }, ), ); @@ -3007,7 +3032,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 () => { @@ -3025,7 +3050,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), credentials: { workspaceId: 'ws-1', client: {} }, }, ), @@ -3064,7 +3089,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -3090,7 +3115,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: null, signal: 'SIGINT' }), + converge: appConverge(async () => ({ kind: 'interrupted' })), }, ), ); @@ -3119,12 +3144,10 @@ 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: appConverge(async () => ({ + kind: 'failed', + reasons: [{ tag: 'PrismaApiError', message: '409 Conflict' }], + })), }, ), ); @@ -3132,7 +3155,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', ); }); @@ -3151,7 +3174,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -3198,7 +3221,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -3226,7 +3249,7 @@ describe('deploy-run reporting', () => { }, { runAssembler: fakeAssembler, - alchemy: async () => ({ exitCode: 0, signal: null }), + converge: appConverge(applied), }, ), ); @@ -3556,7 +3579,6 @@ describe('the operations read the app configuration and the app secrets from the 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( @@ -3576,16 +3598,12 @@ describe('the operations read the app configuration and the app secrets from the }, { runAssembler: fakeAssembler, - alchemy: async (invocation) => { - stackFileSeen = fs.existsSync(path.join(app.dir, invocation.stackFileRelativePath)); - return { exitCode: 0, signal: null }; - }, + converge: appConverge(applied), }, ), ); expect(result.ok).toBe(true); - expect(stackFileSeen).toBe(true); expect(bindings).toEqual([{ greeting: 'from-adapter' }]); }); @@ -3610,7 +3628,7 @@ describe('the operations read the app configuration and the app secrets from the environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -3622,7 +3640,7 @@ describe('the operations read the app configuration and the app secrets from the 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( @@ -3638,7 +3656,7 @@ describe('the operations read the app configuration and the app secrets from the { runAssembler: fakeAssembler, alchemy: async () => { - alchemyRan = true; + convergeRan = true; return { exitCode: 0, signal: null }; }, }, @@ -3646,7 +3664,7 @@ describe('the operations read the app configuration and the app secrets from the ); expect(result.ok).toBe(true); - expect(alchemyRan).toBe(true); + expect(convergeRan).toBe(true); }); test('an app secrets value appears in no event, result or report', async () => { @@ -3676,7 +3694,7 @@ describe('the operations read the app configuration and the app secrets from the reportPath, onEvent: (event) => events.push(event), }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -3703,7 +3721,7 @@ describe('the operations read the app configuration and the app secrets from the environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -3930,6 +3948,13 @@ function filesUnder(dir: string): string[] { }); } +/** The service node at `address` in `graph`, for a report hook a test calls itself. */ +function serviceNodeAt(graph: Graph | undefined, address: string): ServiceNode { + const node = graph?.nodes.find((candidate) => candidate.id === address)?.node; + if (node?.kind !== 'service') throw new Error(`no service at ${address}`); + return node; +} + describe('the second process reads what the first process read', () => { const values = () => ({ GREETING: 'hello', @@ -3937,58 +3962,46 @@ describe('the second process reads what the first process read', () => { UNUSED: 'x', }); - for (const exitCode of [0, 1]) { - test(`deploy writes the app configuration file before the second process starts and deletes it after, mode 0600, holding only what Load read (exit ${String(exitCode)})`, async () => { - const app = makeSecondProcessAppDir(); - let graph: Graph | undefined; - let file: string | undefined; - let seen: { mode: number; json: unknown } | undefined; + test('deploy hands lower() only what Load read from the app configuration, and writes no app configuration file', async () => { + const app = makeSecondProcessAppDir(); + const lowered: LowerOptions[] = []; + let writtenDuringConverge: string[] | undefined; - await silently(() => - deployWithDeps( - { - config: composerConfig({ - ...fakeConfig({ - preflight: async (input) => { - graph = input.graph; - return undefined; - }, - }), - config: [values], - }), - entry: app.entryPath, - environment: 'staging', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async (input) => { - file = input.env[APP_CONFIGURATION_FILE_ENV]; - if (file === undefined) throw new Error('no app configuration file named'); - seen = { - mode: fs.statSync(file).mode & 0o777, - json: JSON.parse(fs.readFileSync(file, 'utf8')), - }; - return { exitCode, signal: null }; + const result = await silently(() => + deployWithDeps( + { + config: composerConfig({ ...fakeConfig(), config: [values] }), + entry: app.entryPath, + environment: 'staging', + cwd: app.dir, + }, + { + runAssembler: fakeAssembler, + converge: { + lower: (root, config, options) => { + lowered.push(options); + return lower(root, config, options); + }, + converge: async () => { + writtenDuringConverge = filesUnder(app.dir).filter((file) => + path.basename(file).startsWith('app-configuration'), + ); + return APPLIED; }, }, - ), - ); - - if (graph === undefined || file === undefined) throw new Error('the deploy did not run'); - expect(seen).toEqual({ - mode: process.platform === 'win32' ? expect.any(Number) : 0o600, - json: { - appConfigurationRead: { - rootInput: [{ path: ['GREETING'], value: 'hello' }], - configurationKeyText: { APP_ORIGIN: 'https://origin.example' }, - }, - nodeAddresses: nodeAddresses(graph), }, - }); - expect(fs.existsSync(file)).toBe(false); - }); - } + ), + ); + + if (!result.ok) throw result.failure; + expect(lowered.map((options) => options.appConfigurationRead)).toEqual([ + { + rootInput: [{ path: ['GREETING'], value: 'hello' }], + configurationKeyText: { APP_ORIGIN: 'https://origin.example' }, + }, + ]); + expect(writtenDuringConverge).toEqual([]); + }); test('destroy writes and deletes the app configuration file the same way, and reads no secrets', async () => { const app = makeSecondProcessAppDir(); @@ -4059,7 +4072,7 @@ describe('the second process reads what the first process read', () => { environment: 'staging', cwd: dir, }, - { runAssembler: fakeAssembler, alchemy }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), destroy: (dir: string) => destroyWithDeps( @@ -4115,43 +4128,53 @@ describe('the second process reads what the first process read', () => { }, ]; for (const { what, configuration, secrets, where } of refusedPlainValues) { - test(`deploy refuses ${what}, before writing any file or starting the second process`, async () => { - const app = makeSecondProcessAppDir(); - let started = false; - - const result = await silently(() => - deployWithDeps( - { - config: composerConfig({ - ...fakeConfig(), - config: [() => configuration], - secrets: [() => secrets], - }), - entry: app.entryPath, - environment: 'staging', - cwd: app.dir, - }, - { - runAssembler: fakeAssembler, - alchemy: async () => { - started = true; - return { exitCode: 0, signal: null }; + test.skipIf(process.platform === 'win32')( + `dev refuses ${what}, before writing any file or starting the second process`, + async () => { + const app = makeSecondProcessAppDir(); + const attachment: LocalTargetAttachment = { + startServices: () => Promise.resolve(), + stopApp: () => Promise.resolve(), + endpoints: () => Promise.resolve([]), + logs: async function* () {}, + }; + let started = false; + + const result = await silently(() => + devWithDeps( + { + config: composerConfig({ + ...devConfigWith(attachment), + config: [() => configuration], + secrets: [() => secrets], + }), + entry: app.entryPath, + cwd: app.dir, + detach: true, }, - }, - ), - ); + { + runAssembler: fakeAssembler, + alchemy: async () => { + started = true; + return { exitCode: 0, signal: null }; + }, + }, + ), + ); - if (result.ok) throw new Error('the deploy should fail'); - expect(result.failure.code).toBe('CONFIG.SECRET_AS_PLAIN_VALUE'); - expect(result.failure.message).toBe( - `${where} holds the value of the secret "S13_SECRET", which an envSecret() in the graph reads. Composer hands the second process every plain value Load read, in a file, and never writes a secret to a file.`, - ); - expect(JSON.stringify(result.failure.toEnvelope())).not.toContain('SECRET-91'); - expect(started).toBe(false); - expect( - filesUnder(app.dir).filter((file) => path.basename(file).startsWith('app-configuration')), - ).toEqual([]); - }); + if (result.ok) throw new Error('dev should fail'); + expect(result.failure.code).toBe('CONFIG.SECRET_AS_PLAIN_VALUE'); + expect(result.failure.message).toBe( + `${where} holds the value of the secret "S13_SECRET", which an envSecret() in the graph reads. Composer hands the second process every plain value Load read, in a file, and never writes a secret to a file.`, + ); + expect(JSON.stringify(result.failure.toEnvelope())).not.toContain('SECRET-91'); + expect(started).toBe(false); + expect( + filesUnder(app.dir).filter((file) => path.basename(file).startsWith('app-configuration')), + ).toEqual([]); + }, + 15_000, + ); } test('a known limit: an adapter failure before Load is not redacted by a value only an envSecret() names', async () => { @@ -4169,7 +4192,7 @@ describe('the second process reads what the first process read', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ); @@ -4238,21 +4261,23 @@ describe('the second process reads what the first process read', () => { describe('no known secret is printed', () => { const SENTINEL = 'e2e-SENTINEL-5f2a'; - test("no known secret reaches the app configuration file, the stack file, the deploy report, the errors or the second process's output", async () => { + test("no known secret reaches the deploy's events, summary, report or errors, and the deploy writes no file", async () => { const app = makeSecondProcessAppDir(); - const script = - "process.stdout.write('e2e-SENTI'); setTimeout(() => { process.stdout.write('NEL-5f2a and more\\n'); process.stderr.write('boom e2e-SENTINEL-5f2a\\n'); }, 50);"; - const runOnce = async (status: number, reportPath: string) => { - const seen = { configurationFile: '', stackFile: '', stdout: '', stderr: '' }; - const outputs = { - stdout: { write: (text: string) => (seen.stdout += text) }, - stderr: { write: (text: string) => (seen.stderr += text) }, - }; + const runOnce = async (outcome: ConvergeOutcome, reportPath: string) => { + const events: DeployEvent[] = []; + let graph: Graph | undefined; + let lowered: LowerOptions | undefined; + let written: string[] = []; const result = await silently(() => deployWithDeps( { config: composerConfig({ - ...fakeConfig(), + ...fakeConfig({ + preflight: async (input) => { + graph = input.graph; + return undefined; + }, + }), config: [ () => ({ GREETING: 'hello', APP_ORIGIN: 'https://origin.example', UNUSED: 'x' }), ], @@ -4262,70 +4287,74 @@ describe('no known secret is printed', () => { environment: 'staging', cwd: app.dir, reportPath, + onEvent: (event) => void events.push(event), }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const configurationFile = input.env[APP_CONFIGURATION_FILE_ENV]; - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (configurationFile === undefined || resultFile === undefined) { - throw new Error('the second process was not told where its files are'); - } - seen.configurationFile = fs.readFileSync(configurationFile, 'utf8'); - seen.stackFile = fs.readFileSync( - path.join(app.dir, input.stackFileRelativePath), - 'utf8', - ); - await spawnCommandLine( - { command: process.execPath, args: ['-e', script], cwd: app.dir, env: input.env }, - input.redactedValues, - outputs, - ); - const summary: DeploymentSummary = { - app: 'hello-second', - nodes: [ - { - address: 'hello-second.app', - entities: [{ kind: 'compute-service', id: 'svc', details: { note: SENTINEL } }], - }, - ], - }; - fs.writeFileSync(resultFile, JSON.stringify(summary)); - fs.writeFileSync(engineFailureFilePath(resultFile), `error: rejected ${SENTINEL}`); - return { exitCode: status, signal: null }; + converge: { + lower: (root, config, options) => { + lowered = options; + return lower(root, config, options); + }, + converge: async (request) => { + request.onEvent?.({ kind: 'log', severity: 'info', text: `${SENTINEL} and more` }); + lowered?.report?.({ + app: 'hello-second', + nodes: [ + { + address: 'hello-second.app', + node: serviceNodeAt(graph, 'hello-second.app'), + entities: [ + { kind: 'compute-service', id: 'svc', details: { note: SENTINEL } }, + ], + }, + ], + }); + written = filesUnder(app.dir).filter((file) => + /^(app-configuration|alchemy\.run)/.test(path.basename(file)), + ); + return outcome; + }, }, }, ), ); - return { result, seen, report: fs.readFileSync(reportPath, 'utf8') }; + return { result, events, written, report: fs.readFileSync(reportPath, 'utf8') }; }; - const succeeded = await runOnce(0, path.join(app.dir, 'succeeded.json')); - const failed = await runOnce(1, path.join(app.dir, 'failed.json')); + const succeeded = await runOnce(APPLIED, path.join(app.dir, 'succeeded.json')); + const failed = await runOnce( + { kind: 'failed', reasons: [{ tag: 'Error', message: `error: rejected ${SENTINEL}` }] }, + path.join(app.dir, 'failed.json'), + ); - if (!succeeded.result.ok) throw new Error('the first deploy should succeed'); + if (!succeeded.result.ok) throw succeeded.result.failure; if (failed.result.ok) throw new Error('the second deploy should fail'); const texts = [ - ...[succeeded, failed].flatMap(({ seen, report }) => [ - seen.configurationFile, - seen.stackFile, - seen.stdout, - seen.stderr, - report, - ]), + ...[succeeded, failed].flatMap(({ events, report }) => [JSON.stringify(events), report]), JSON.stringify(succeeded.result.value.summary), JSON.stringify(failed.result.failure.toEnvelope()), String(failed.result.failure.meta?.['engineCause']), ]; for (const text of texts) expect(text).not.toContain('SENTINEL'); - expect(succeeded.seen.stdout).toBe('[redacted] and more\n'); - expect(failed.seen.stdout).toBe('[redacted] and more\n'); + expect(succeeded.result.value.summary?.nodes[0]?.entities[0]?.details).toEqual({ + note: '[redacted]', + }); + for (const { events, written } of [succeeded, failed]) { + expect(events).toContainEqual({ + kind: 'message', + severity: 'info', + text: '[redacted] and more', + }); + expect(written).toEqual([]); + } }, 15_000); - test('with the default lists, a secret known only because an envSecret() names it reaches no file, report, reporter, error or output', async () => { + test('with the default lists, a secret known only because an envSecret() names it reaches no event, report, reporter or error', async () => { const sentinel = 'default-SENTINEL-7c1e'; const app = makeSecondProcessAppDir('MAILER_X'); const finished: string[] = []; + const events: DeployEvent[] = []; const reporter: NonNullable = { begin: async () => ({ childEnv: () => ({}), @@ -4333,11 +4362,6 @@ describe('no known secret is printed', () => { finish: async (outcome) => void finished.push(JSON.stringify(outcome)), }), }; - const seen = { configurationFile: '', stdout: '', stderr: '' }; - const outputs = { - stdout: { write: (text: string) => (seen.stdout += text) }, - stderr: { write: (text: string) => (seen.stderr += text) }, - }; const names = ['GREETING', 'APP_ORIGIN', 'MAILER_X'] as const; const previous = names.map((name) => process.env[name]); Object.assign(process.env, { @@ -4356,29 +4380,17 @@ describe('no known secret is printed', () => { environment: 'staging', cwd: app.dir, reportPath, + onEvent: (event) => void events.push(event), }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const configurationFile = input.env[APP_CONFIGURATION_FILE_ENV]; - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (configurationFile === undefined || resultFile === undefined) { - throw new Error('the second process was not told where its files are'); - } - seen.configurationFile = fs.readFileSync(configurationFile, 'utf8'); - await spawnCommandLine( - { - command: process.execPath, - args: ['-e', `process.stdout.write(${JSON.stringify(`printed ${sentinel}\n`)})`], - cwd: app.dir, - env: input.env, - }, - input.redactedValues, - outputs, - ); - fs.writeFileSync(engineFailureFilePath(resultFile), `error: rejected ${sentinel}`); - return { exitCode: 1, signal: null }; - }, + converge: appConverge(async (request) => { + request.onEvent?.({ kind: 'log', severity: 'info', text: `printed ${sentinel}` }); + return { + kind: 'failed', + reasons: [{ tag: 'Error', message: `error: rejected ${sentinel}` }], + }; + }), }, ), ); @@ -4393,16 +4405,18 @@ describe('no known secret is printed', () => { if (result.ok) throw new Error('the deploy should fail'); expect(finished).toHaveLength(1); const texts = [ - seen.configurationFile, - seen.stdout, - seen.stderr, + JSON.stringify(events), fs.readFileSync(reportPath, 'utf8'), ...finished, JSON.stringify(result.failure.toEnvelope()), String(result.failure.meta?.['engineCause']), ]; for (const text of texts) expect(text).not.toContain('SENTINEL'); - expect(seen.stdout).toBe('printed [redacted]\n'); + expect(events).toContainEqual({ + kind: 'message', + severity: 'info', + text: 'printed [redacted]', + }); }, 15_000); test('with dotenv() and environmentVariables() in both lists, only the value an envSecret() names is redacted', async () => { @@ -4441,11 +4455,8 @@ describe('no known secret is printed', () => { 'EMAIL_DELIVERY_MODE=none\nEMAIL_FROM=dev@example.com\nEMAIL_DELIVERY_CREDENTIAL=sk_test_abc123\n', ); const printed = 'mode none from dev@example.com key sk_test_abc123'; - const seen = { configurationFile: '', stdout: '' }; - const outputs = { - stdout: { write: (text: string) => (seen.stdout += text) }, - stderr: { write: () => {} }, - }; + const events: DeployEvent[] = []; + const lowered: LowerOptions[] = []; const result = await silently(() => deployWithDeps( @@ -4461,28 +4472,19 @@ describe('no known secret is printed', () => { entry: app.entryPath, environment: 'staging', cwd: app.dir, + onEvent: (event) => void events.push(event), }, { runAssembler: fakeAssembler, - alchemy: async (input) => { - const configurationFile = input.env[APP_CONFIGURATION_FILE_ENV]; - const resultFile = input.env[DEPLOYMENT_RESULT_FILE_ENV]; - if (configurationFile === undefined || resultFile === undefined) { - throw new Error('the second process was not told where its files are'); - } - seen.configurationFile = fs.readFileSync(configurationFile, 'utf8'); - await spawnCommandLine( - { - command: process.execPath, - args: ['-e', `process.stdout.write(${JSON.stringify(`${printed}\n`)})`], - cwd: app.dir, - env: input.env, - }, - input.redactedValues, - outputs, - ); - fs.writeFileSync(engineFailureFilePath(resultFile), printed); - return { exitCode: 1, signal: null }; + converge: { + lower: (root, config, options) => { + lowered.push(options); + return lower(root, config, options); + }, + converge: async (request) => { + request.onEvent?.({ kind: 'log', severity: 'info', text: printed }); + return { kind: 'failed', reasons: [{ tag: 'Error', message: printed }] }; + }, }, }, ), @@ -4490,12 +4492,16 @@ describe('no known secret is printed', () => { if (result.ok) throw new Error('the deploy should fail'); expect(result.failure.code).toBe('DEPLOY.ENGINE_FAILED'); - expect(JSON.parse(seen.configurationFile).appConfigurationRead.rootInput).toEqual([ + expect(lowered[0]?.appConfigurationRead?.rootInput).toEqual([ { path: ['EMAIL_DELIVERY_MODE'], value: 'none' }, { path: ['EMAIL_FROM'], value: 'dev@example.com' }, ]); - expect(seen.configurationFile).not.toContain('sk_test_abc123'); - expect(seen.stdout).toBe('mode none from dev@example.com key [redacted]\n'); + expect(JSON.stringify(lowered[0]?.appConfigurationRead)).not.toContain('sk_test_abc123'); + expect(events).toContainEqual({ + kind: 'message', + severity: 'info', + text: 'mode none from dev@example.com key [redacted]', + }); expect(result.failure.meta?.['engineCause']).toBe( 'mode none from dev@example.com key [redacted]', ); @@ -4541,7 +4547,7 @@ describe('no known secret is printed', () => { environment: 'staging', cwd: app.dir, }, - { runAssembler: fakeAssembler, alchemy: async () => ({ exitCode: 0, signal: null }) }, + { runAssembler: fakeAssembler, converge: appConverge(applied) }, ), ).catch((error: unknown) => error); 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 f9c0a0767..bb9bb22df 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,14 +10,14 @@ 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, } from '@internal/core/config'; -import { containerEnv, preflightEnv, preflightEnvVarName } from '@internal/core/config'; +import { containerEnv, preflightEnvVarName } from '@internal/core/config'; import { nodeAddresses, secretsKeys } from '@internal/core/deploy'; -import { CliStructuredError } from '@internal/foundation/errors'; +import { CliStructuredError, InternalError } from '@internal/foundation/errors'; import { notOk, ok, okVoid, type Result } from '@internal/foundation/result'; import { deployAppConfigurationFile, @@ -25,17 +25,32 @@ import { removeLeftoverAppConfigurationFiles, type SecondProcessHandover, } from '../app-configuration-file.ts'; +import { type AppConverge, loadAppConverge } from '../app-converge.ts'; import type { ComposerConfigSource } from '../composer-config.ts'; +import type { + ConvergeEvent, + ConvergeFailureReason, + ConvergeOutcome, + LoweredStack, +} from '../converge-in-process.ts'; import { + capFailureCause, DEPLOYMENT_RESULT_FILE_ENV, type DeploymentSummary, engineFailureFilePath, - 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'; -import { redactError, redactSummary, redactThrown, SecretRedaction } from '../redaction.ts'; +import { + credentialLookingValues, + redactError, + redactSummary, + redactThrown, + SecretRedaction, +} from '../redaction.ts'; import { type AlchemyOutcome, alchemyInvocation, @@ -80,7 +95,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); @@ -113,6 +132,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 }); + }, }; } @@ -131,6 +156,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; readonly redaction: SecretRedaction; } @@ -150,6 +177,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, redaction: new SecretRedaction(process.env), }); if (!outcome.ok) return outcome; @@ -172,6 +200,7 @@ export async function executeDestroy( reportId: undefined, reportPath: undefined, steps: deploySteps(undefined), + signal: undefined, redaction: new SecretRedaction(process.env), }); if (!outcome.ok) return outcome; @@ -198,7 +227,7 @@ async function beginReporters( readonly environment: string; readonly cwd: string; readonly reportId: string | undefined; - readonly credentials: ContainerCredentials | undefined; + readonly credentials: CallerCredentials | undefined; }, redaction: SecretRedaction, ): Promise { @@ -240,7 +269,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[], redaction: SecretRedaction, @@ -264,9 +293,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'; } /** @@ -374,9 +403,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, @@ -395,6 +425,14 @@ async function runStackPipelineInner( throw error; } + let converge: ConvergeSetup | undefined; + if (action === 'deploy') { + const setup = await prepareConverge(deps, path.dirname(path.resolve(cwd, composerConfig.file))); + if (!setup.ok) return setup; + converge = setup.value; + } + 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 // unrelated reason (missing config, missing built output — both common @@ -406,11 +444,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, @@ -473,7 +509,7 @@ async function runStackPipelineInner( environment, cwd, reportId: opts.reportId, - credentials: deps.credentials, + credentials: platformCredentials, }, opts.redaction, )), @@ -491,7 +527,7 @@ async function runStackPipelineInner( if (action === 'deploy') { const instance = await extension.container.ensure( { appName: resolvedName, environment }, - deps.credentials, + platformCredentials, ); requireContainerForEnvironment( action, @@ -504,7 +540,7 @@ async function runStackPipelineInner( } else { const instance = await extension.container.locate( { appName: resolvedName, environment }, - deps.credentials, + platformCredentials, ); if (instance === undefined) { throw new CliStructuredError( @@ -540,8 +576,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. @@ -560,7 +596,7 @@ async function runStackPipelineInner( environment, configuration: pipeline.configuration, secrets: pipeline.secrets, - credentials: deps.credentials, + credentials: platformCredentials, report, }); if (payload !== undefined) preflightPayloads.set(extension.id, payload); @@ -568,20 +604,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)] ??= ''; } @@ -590,6 +618,15 @@ async function runStackPipelineInner( throw error; } + if (converge !== undefined) { + return convergeDeploy(opts, pipeline, converge, { + containers, + stage: alchemyStage, + preflight: preflightPayloads, + env: reporterChildEnv(reporters, opts.redaction), + }); + } + // 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 — @@ -761,7 +798,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); } @@ -772,9 +809,6 @@ async function runStackPipelineInner( throw error; } - if (action === 'deploy') { - return ok(redactSummary(readDeploymentSummary(resultFilePath), opts.redaction)); - } return ok(undefined); } finally { try { @@ -786,3 +820,193 @@ async function runStackPipelineInner( handover?.removeFile(); } } + +interface ConvergeSetup { + readonly appConverge: AppConverge; +} + +/** 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> { + try { + return ok({ appConverge: deps.converge ?? (await loadAppConverge(appDir)) }); + } catch (error) { + if (CliStructuredError.is(error)) return notOk(error); + throw error; + } +} + +/** One Alchemy progress event as the deploy's step events: each resource is a step inside `apply`. */ +function reportConvergeEvent( + steps: DeploySteps, + event: ConvergeEvent, + redact: (text: string) => string, +): void { + if (event.kind === 'log') { + steps.message(event.severity, redact(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', redact(`${event.fqn}: ${withComposerAdvice(event.message)}`)); + } + return; + case 'pending': + return; + } +} + +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) })), + }, + }, + ); + } + } + return new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + 'The converge ended with an outcome this Composer does not know.', + ); +} + +/** + * 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, + setup: ConvergeSetup, + resolved: { + readonly containers: ReadonlyMap; + readonly stage: string; + readonly preflight: ReadonlyMap; + readonly env: Readonly>; + }, +): Promise> { + const { cwd, environment, steps } = opts; + const config = pipeline.configSource.value; + const env = resolved.env; + opts.redaction.addCredentialLookingValues(credentialLookingValues(env)); + const redact = (text: string): string => opts.redaction.redact(text); + 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 = setup.appConverge.lower(root, config, { + name: pipeline.name, + environment, + appConfigurationRead: pipeline.graph.appConfigurationRead, + bundles: pipeline.assembled.bundles, + containers: resolved.containers, + preflight: resolved.preflight, + credentials: opts.deps.credentials, + report: (result) => { + summary = toDeploymentSummary(result); + }, + }); + } catch (error) { + // An extension's state or providers factory threw while the stack was built. + return notOk( + new CliStructuredError( + 'DEPLOY.ENGINE_FAILED', + redact(error instanceof Error ? error.message : String(error)), + ), + ); + } + 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(redactSummary(summary, opts.redaction)); +} 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..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,12 +6,13 @@ */ import type { RunAssembler } from '@internal/assemble'; import type { - ContainerCredentials, + CallerCredentials, ContainerDescriptor, ContainerInstance, } from '@internal/core/config'; import { blindCast } from '@internal/foundation/casts'; import { CliStructuredError } from '@internal/foundation/errors'; +import type { AppConverge } from '../app-converge.ts'; import type { startWatch } from '../dev/watch.ts'; import type { RunAlchemy } from '../run-alchemy.ts'; @@ -39,11 +40,13 @@ 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; + /** 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; /** @@ -60,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/0-framework/3-tooling/cli/src/testing/operations-double.ts b/packages/0-framework/3-tooling/cli/src/testing/operations-double.ts index 0169e95c1..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,73 +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: {}, - redactedValues: [], - }; - 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[] = []; @@ -191,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/0-framework/3-tooling/cli/tsdown.config.ts b/packages/0-framework/3-tooling/cli/tsdown.config.ts index 6489f2488..47200bbc7 100644 --- a/packages/0-framework/3-tooling/cli/tsdown.config.ts +++ b/packages/0-framework/3-tooling/cli/tsdown.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ config: 'src/exports/config.ts', 'app-configuration-file': 'src/exports/app-configuration-file.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/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 187d8f829..72ac0381e 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 @@ -34,8 +34,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', @@ -53,8 +53,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/__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__/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/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 2cbee49fc..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,10 +1,16 @@ 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'; import * as Redacted from 'effect/Redacted'; -import { deploySourceHeaders, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { + deploySourceHeaders, + fromEnv, + managementApiBaseUrl, + PrismaCredentials, + suppliedClientOrigin, +} from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -18,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({ apiOrigin }).pipe(Layer.provide(fromEnv())) + : Layer.succeed(ManagementClient, supplied); 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/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/deploy-http-client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts new file mode 100644 index 000000000..31aab89ef --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/deploy-http-client.ts @@ -0,0 +1,116 @@ +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); + +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'); + 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. 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 => { + 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 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}`, { + ...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`, 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 deployHttpClientLayer = ( + client: ManagementApiClient, + origin: string, +): Layer.Layer => + Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + // 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) + .pipe(Effect.provideService(FetchHttpClient.Fetch, throughClient)) + : 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..507634008 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -1,79 +1,59 @@ 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 { deployHttpClientLayer } from './deploy-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`, 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.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, - }; - }), - ); +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 = (apiBaseUrl: string) => + Layer.succeed(Credentials, Effect.succeed({ apiBaseUrl })); -/** The generated Management API operations' credentials: the same token and origin. */ -const operationCredentials = () => - Layer.effect( - Credentials, +/** The bundle's `HttpClient`, over `ManagementClient` (see `deployHttpClientLayer`). */ +const prismaDeployHttpLayer = (origin: string) => + Layer.unwrap( Effect.gen(function* () { - const { token } = yield* PrismaCredentials; - const apiBaseUrl = yield* managementApiBaseUrl(); - return Effect.succeed({ apiToken: token, apiBaseUrl }); + return deployHttpClientLayer(yield* client.ManagementClient, origin); }), ); -/** A node:http transport that adds deploy-source headers to every request. */ -const prismaManagementHttpLayer = Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const base = yield* HttpClient.HttpClient; - return HttpClient.mapRequest(base, HttpClientRequest.setHeaders(deploySourceHeaders())); - }), -).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 * (`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(), @@ -86,41 +66,46 @@ const upstreamPrismaProviders = () => Prisma.CustomDomainProvider(), ).pipe( Layer.provideMerge(Prisma.PrismaClientLive), - Layer.provideMerge(prismaEnvironment()), - Layer.provideMerge(operationCredentials()), + Layer.provideMerge(prismaEnvironment(baseUrl, clientSupplied)), + Layer.provideMerge(operationCredentials(baseUrl)), ); /** - * 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() }`. + * + * `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, + * at the origin `client.managementApiOrigin` resolves. * - * 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. + * 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 + * `HttpClient` — each carries its own client — or it would silently get this + * override. */ -export const providers = () => - 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.layer()), - Layer.provideMerge(fromEnv()), - Layer.orDie, - ); +export const providers = (options?: { readonly client?: ManagementApiClient | undefined }) => + 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/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/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..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 @@ -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,66 @@ 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('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' }))), 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..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 @@ -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'; @@ -17,7 +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 { deployHttpClientLayer } from '../deploy-http-client.ts'; import { failOnEmptyScopeWithLiveResources, scopeOccupied } from './empty-scope.ts'; import { hostedStateBootstrapError } from './errors.ts'; import { @@ -37,7 +36,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 +49,19 @@ 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 => - // 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. +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 => Layer.unwrap( - credentials.managementApiBaseUrl().pipe(Effect.map((origin) => stateLayerAgainst(origin, ids))), + client + .managementApiOrigin(supplied) + .pipe(Effect.map((origin) => stateLayerAgainst(origin, ids, supplied))), ).pipe(Layer.orDie); /** @@ -98,9 +99,10 @@ 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 = client.suppliedOrFromEnv(supplied, apiOrigin); return Layer.effect( State, @@ -111,7 +113,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 +145,14 @@ export const stateLayerAgainst = ( ); } + // `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: Redacted.value(token), - transformClient: (req) => - HttpClientRequest.setHeader(req, LEASE_HEADER, Redacted.value(lease.leaseId)), + authToken: '', + transformClient: HttpClientRequest.setHeader(LEASE_HEADER, Redacted.value(lease.leaseId)), id: 'prisma-postgres', - }).pipe(Effect.provide(FetchHttpClient.layer)); + }).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__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index f2b8b0d19..5bd22e965 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 @@ -25,6 +26,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'; @@ -2273,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' }, @@ -2366,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' }, @@ -2417,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' }, @@ -2457,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' } }, }), @@ -2494,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' }, @@ -2559,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' }, @@ -2671,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' } }, }), @@ -2731,7 +2740,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 +2795,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 +2831,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', @@ -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/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 75cdedeae..132cd48f9 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, and the Management API URLs a supplied client is checked against), // preflight.ts (PRISMA_SERVICE_TOKEN; values come from the app configuration and the app secrets), 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). @@ -144,8 +143,8 @@ 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: 'container.ts', count: 4 }, + { 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 638bf26c5..63aa7243c 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'; @@ -196,7 +196,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'); } @@ -309,8 +311,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'); }; @@ -327,7 +328,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 5e68c42a1..27fc9a9d4 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'; @@ -94,7 +94,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) ?? '?']), ); @@ -189,9 +191,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']])), + ), ), ); @@ -201,12 +205,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' }, + }), + ), ), ); @@ -215,7 +221,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 1abac521c..72d85b021 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/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 3a4b73601..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 @@ -3,7 +3,12 @@ * @internal/lowering is imported; loaded only by `prisma.config.ts`. */ -import type { ExtensionDescriptor, StateDescriptor } from '@internal/core/config'; +import type { + CallerCredentials, + ExtensionDescriptor, + ProvidersInput, + StateDescriptor, +} from '@internal/core/config'; import type { ProvisionerDescriptor } from '@internal/core/deploy'; import { blindCast } from '@internal/foundation/casts'; import * as Prisma from '@internal/lowering'; @@ -43,11 +48,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` @@ -173,13 +174,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?: CallerCredentials) => { 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, + ); }, }); @@ -287,7 +291,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,36 +320,27 @@ 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, container: containerDescriptor({ region: () => o().region }), - providers: () => + providers: ({ credentials }: ProvidersInput) => asProvidersLayer( Layer.mergeAll( - Prisma.providers(), + Prisma.providers({ client: credentials?.client }), PgWarmProvider(), OrmMigrationProvider(), S3CredentialsProvider(), @@ -356,16 +351,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 3b408e92d..205d7cd5e 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.appConfigurationRead !== undefined ? { env: ctx.opts.appConfigurationRead.configurationKeyText } : {}), @@ -421,9 +422,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/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index de949572c..b9e7ff965 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -2,11 +2,13 @@ "name": "@prisma/composer", "version": "0.37.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/app-configuration-file": "./dist/internal-app-configuration-file.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/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 6cb84079e..43459493b 100644 --- a/packages/9-public/composer/tsdown.config.ts +++ b/packages/9-public/composer/tsdown.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ index: 'src/exports/index.ts', config: 'src/exports/config.ts', 'internal-app-configuration-file': 'src/exports/internal-app-configuration-file.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/pnpm-lock.yaml b/pnpm-lock.yaml index 677bac1b8..5361729d1 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/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz 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/-/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.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.37.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/-/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 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/-/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.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.37.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/-/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 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.37.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/-/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 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/-/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.0 + version: 4.1.2 devDependencies: '@internal/tsdown-config': specifier: workspace:0.37.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/-/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.37.0 version: link:../../0-foundation/tsdown-config @@ -865,7 +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/-/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 tsdown: specifier: ^0.22.7 version: 0.22.12(typescript@6.0.3) @@ -926,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/-/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.37.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -947,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/-/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.0 + version: 4.1.2 tar: specifier: ^7.5.21 version: 7.5.21 @@ -987,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/-/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: 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.37.0 version: link:../../../0-framework/2-authoring/bundle-paths @@ -1008,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/-/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.0 + version: 4.1.2 devDependencies: '@internal/tsdown-config': specifier: workspace:0.37.0 @@ -1057,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/-/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.37.0 version: link:../../../0-framework/1-core/core @@ -1096,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/-/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 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -1115,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.37.0 version: link:../../../0-framework/0-foundation/tsdown-config @@ -1375,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/-/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 @@ -1384,7 +1387,7 @@ importers: version: 4.1.0 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 esbuild: specifier: ^0.28.1 version: 0.28.2 @@ -1445,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/-/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 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 esbuild: specifier: ^0.28.1 version: 0.28.2 @@ -1491,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/-/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: 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.37.0 version: link:../composer @@ -1521,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/-/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 effect: specifier: 'catalog:' - version: 4.1.0 + version: 4.1.2 jose: specifier: ^6.1.3 version: 6.2.4 @@ -1630,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/-/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 @@ -1654,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/-/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:* @@ -1683,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: {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: {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 @@ -1711,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' @@ -1989,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: {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: {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/aws@1.0.0-rc.18': - resolution: {integrity: sha512-VZDlOGEXvXvTdY/0sTEoTWnPCGde+fjvq1ZIW3wgiZK/X8ChbbonGArg+Sc9OO4E9tr1WBymASrZ906uLnLWFw==} + '@distilled.cloud/core@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.0.5 + effect: ^4.1.2 - '@distilled.cloud/core@1.0.0-rc.18': - resolution: {integrity: sha512-Iui60+sNl1CwUu7NKGLuFf3VC4H9xYzfm0Fc8ZQvd87qwf1sDMB3EAWZrq/QFgrwzSY16z2mJ0PNHaBUnGet8A==} + '@distilled.cloud/prisma@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.0.5 + effect: ^4.1.2 - '@distilled.cloud/prisma@1.0.0-rc.18': - resolution: {integrity: sha512-hWiMZL5706ONqbBllELI9lsbrIz9ZdgthAxAt5ScFZYOjTKqkWUqNRr2fnqnd1xjYvu+F7VPs0VChvOD2NfB2w==} + '@distilled.cloud/s3@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.0.5 + effect: ^4.1.2 '@durable-streams/client@0.2.3': resolution: {integrity: sha512-609hWTqe8/OXzIFnv+oDdlT57QsCAc3F2c/nAQBcYhSLmmbXk5rHx7rnQSmk9MeGGQ8dsg9UCZf47dTJG3q3ig==} @@ -2024,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==} @@ -2471,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==} @@ -2893,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] @@ -2911,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] @@ -2929,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] @@ -2947,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] @@ -2965,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] @@ -2983,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] @@ -3001,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] @@ -3019,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] @@ -3037,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] @@ -3055,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] @@ -3073,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] @@ -3091,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] @@ -3109,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] @@ -3137,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] @@ -3155,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] @@ -3637,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/-/04fc54f0946f311b795967f90aedfe0f1c15a2ccccb1bb7079434518e35c0f71.tgz: + 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 @@ -4105,8 +4121,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==} @@ -5056,8 +5072,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 @@ -5614,6 +5630,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'} @@ -5660,52 +5688,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)': - 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) + ? '@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: 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/-/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: - '@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)': - 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) + ? '@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: 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/-/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: - '@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 @@ -6069,25 +6097,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': 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) + effect: 4.1.2 - '@distilled.cloud/aws@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)': 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 + '@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/core@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: - effect: 4.1.0 + effect: 4.1.2 - '@distilled.cloud/prisma@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: - '@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/s3@https://pkg.alchemy.run/@distilled.cloud/s3/-/1cecfb055ecd0cae8fcae21b4269af5bca8b9421ad1da52561234e7158ad1e86.tgz(effect@4.1.2)': + dependencies: + '@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: @@ -6119,27 +6154,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: @@ -6449,7 +6484,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: @@ -7109,7 +7144,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': @@ -7118,7 +7153,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': @@ -7127,7 +7162,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': @@ -7136,7 +7171,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': @@ -7145,7 +7180,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': @@ -7154,7 +7189,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': @@ -7163,7 +7198,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': @@ -7172,7 +7207,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': @@ -7181,7 +7216,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': @@ -7190,7 +7225,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': @@ -7199,7 +7234,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': @@ -7208,7 +7243,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': @@ -7217,7 +7252,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': @@ -7240,7 +7275,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': @@ -7249,7 +7284,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': {} @@ -7780,44 +7815,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/-/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': 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/-/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': 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 @@ -8203,7 +8238,7 @@ snapshots: fast-check: 3.23.2 optional: true - effect@4.1.0: {} + effect@4.1.2: {} elkjs@0.11.1: optional: true @@ -9265,26 +9300,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: {} @@ -9808,6 +9843,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..de6afb09d 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/-/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 + '@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..16d8b1db2 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.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`. 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/ @@ -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. diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 9d20b3936..880cd1c8a 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -490,22 +490,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` (2.0.0-beta.85 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 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 8e46820ff..74cac1261 100644 --- a/tsconfig.depcruise.json +++ b/tsconfig.depcruise.json @@ -62,6 +62,10 @@ "@internal/cli/app-configuration-file": [ "./packages/0-framework/3-tooling/cli/src/exports/app-configuration-file.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"], @@ -151,6 +155,12 @@ "@prisma/composer/internal/app-configuration-file": [ "./packages/9-public/composer/src/exports/internal-app-configuration-file.ts" ], + "@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"],