From 150229acdb680a0b0ec3f7303a28881a1f6e67ce Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 16:35:00 +0200 Subject: [PATCH 01/22] =?UTF-8?q?docs(drive):=20open=20alchemy-provider-ad?= =?UTF-8?q?option=20project=20=E2=80=94=20spec,=20plan,=20design=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the upstream alchemy/Prisma provider for the six overlapping resources, keep Composer emulators local, contribute buckets + the generic Postgres state store upstream. Slices TML-3154/3155/3156. Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 47 +++++ .../alchemy-provider-adoption/plan.md | 63 +++++++ .../alchemy-provider-adoption/spec.md | 161 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 .drive/projects/alchemy-provider-adoption/design-notes.md create mode 100644 .drive/projects/alchemy-provider-adoption/plan.md create mode 100644 .drive/projects/alchemy-provider-adoption/spec.md diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md new file mode 100644 index 00000000..8f2675bb --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -0,0 +1,47 @@ +# Design notes — alchemy-provider-adoption + +## Principles + +- Own zero Management-API wrapper code that upstream also owns. +- Composer's local-dev iteration speed must not depend on upstream review + latency (operator decision, 2026-08-03). +- Upstream's opinionated guards are adopted, not fought — each one we checked + (named-DB+branch refusal, system-managed env refusal, pooled-first URL) was + correct or workaroundable on our side. + +## The model + +One provider (upstream's), two provider *layers* on Composer's side: + +- deploy: upstream's live providers (needs the `liveProviderLayer` export or a + local rebuild of its wiring — client layer + individual `*Provider()`s). +- dev: Composer's emulator providers bound to upstream's resource classes, + substituted at `LowerOptions.providers` (`deploy.ts:203`) exactly as ADR-0041 + does today. + +State: hosted Postgres store unchanged; rows migrate off the colliding +type-ids. Auth: `Layer.succeed(PrismaEnvironment, {token, baseUrl})`, skipping +alchemy's profile store. + +## Alternatives considered + +- **Contribute emulators upstream** (original proposal, in wip notes): rejected + for now — couples our dev loop to Sam's dual-mode design and review cadence. +- **Adopt `ProviderLayer.dual`**: solves cross-mode state stamping we don't + need (dev and deploy use disjoint state stores). Revisit if that ever + changes. +- **Vendor `src/Prisma/` into Composer**: works on beta.59 (provider uses no + newer core APIs) but inherits `@prisma/dev` dep + permanent drift. Only a + fallback if the beta bump stalls badly. +- **Keep our six resources**: rejected — the spike showed upstream is strictly + more hardened on deploy lifecycle and we'd keep paying API drift. + +## Open questions + +Tracked in spec.md (Compute vs App+Deployment; state-migration mechanics; +first released beta). + +## References + +`wip/alchemy-prisma-provider-notes-for-aman.md`; spike session artifacts; +upstream PRs #416, #963. diff --git a/.drive/projects/alchemy-provider-adoption/plan.md b/.drive/projects/alchemy-provider-adoption/plan.md new file mode 100644 index 00000000..bbb75671 --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/plan.md @@ -0,0 +1,63 @@ +# Project Plan — alchemy-provider-adoption + +## Summary + +Three slices: two stacked (postgres family, then compute family) and one +parallel (upstream contributions). The spike that grounded this plan is this +project's originating session; call-site inventory is in `spec.md` References. + +**Spec:** `.drive/projects/alchemy-provider-adoption/spec.md` + +## Slices + +### Slice 1 — Postgres family adoption (TML-3154) + +Bump alchemy to the first released beta containing the Prisma provider; wire +upstream live providers + `PrismaEnvironment` auth; rename our collection tag; +swap `Project`/`Database`/`Connection` to upstream classes; rewire +postgres/prisma-next descriptors; create-then-PATCH branch attach; +`directConnectionString`; state-row migration (mechanics decided here: aliases +vs SQL); rebind postgres emulator provider. + +- **Builds on:** nothing (first slice). +- **Hands to:** slice 2 — alchemy bumped, upstream live-provider wiring + + auth layer in place, collection tag renamed, state-migration mechanism + proven on the postgres rows. + +### Slice 2 — Compute family adoption (TML-3155) + +Swap `ComputeService`/`Deployment`/`EnvironmentVariable`; decide Compute vs +App+Deployment; `artifactPath`-only enforcement (ADR-0005); env parity + +`DATABASE_URL` exclusion; state migration on compute rows; rebind compute +emulator provider. + +- **Builds on:** slice 1's hand-off. +- **Hands to:** close-out — Composer fully on upstream for the six resources; + old implementations deleted. + +### Slice 3 — Upstream contributions (TML-3156) — parallel + +Fork alchemy-run/alchemy (wmadden-electric), then two upstream PRs: bucket +resources; generic `postgresState` backend. Plus the one-line +`liveProviderLayer` export ask (filed early — slice 1 consumes it if it lands +in time, otherwise uses the transitional local rebuild). `PgWarm` offered in +the same conversation. + +- **Builds on:** nothing (written against upstream shapes directly). +- **Hands to:** slice-1 dependency softening (the export); Composer bucket + deletion at close-out if the bucket PR merges + releases in time (otherwise + buckets stay per transitional constraint). + +## Sequencing + +- Stack: 1 → 2. +- Parallel: 3 alongside both (start immediately — the export ask is the first + action, since slice 1 benefits from it). + +## Close-out (required) + +- [ ] Verify all acceptance criteria in `.drive/projects/alchemy-provider-adoption/spec.md` +- [ ] Migrate long-lived docs into `docs/` (ADR for the adoption + revised + local-dev seam; alchemy-lowering.md rewrite) +- [ ] Strip repo-wide references to `.drive/projects/alchemy-provider-adoption/**` +- [ ] Delete `.drive/projects/alchemy-provider-adoption/` diff --git a/.drive/projects/alchemy-provider-adoption/spec.md b/.drive/projects/alchemy-provider-adoption/spec.md new file mode 100644 index 00000000..60743437 --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/spec.md @@ -0,0 +1,161 @@ +# Purpose + +Stop maintaining Composer's own Alchemy resources for Prisma Cloud. The official +`alchemy/Prisma` provider (alchemy-run/alchemy, PR #416) now covers the same +Management API surface with a more hardened deployment lifecycle, and it is +written by our own colleague. Every line of API-wrapper code we keep is drift +risk against the Management API and duplicated effort against upstream. After +this project, Composer consumes upstream for everything that is genuinely about +Prisma Cloud, contributes the pieces upstream lacks that are generic, and keeps +locally only what encodes Composer concepts. + +A second aim: keep Composer's **local dev emulation** iterating on our own +timeline. The emulators stay in Composer, driving upstream's live providers and +our emulator providers through the same provider-layer substitution seam we use +today — explicitly *not* blocked on upstream's dev-mode design (Sam's +`ProviderLayer.dual`, #963) settling. + +# At a glance + +Four workstreams: + +1. **Adopt** — replace Composer's six overlapping resources (`Project`, + `Database`, `Connection`, `ComputeService`, `Deployment`, + `EnvironmentVariable`, ~670 lines in + `packages/1-prisma-cloud/0-lowering/lowering/src/`) with upstream's resource + classes and live providers. Requires the alchemy bump beta.59 → beta.66+ + (the provider ships inside the `alchemy` package; beta.59 has no `Prisma/` + directory). +2. **Port** — rewire Composer to upstream's shapes: descriptor call sites to + upstream prop/attribute names; state rows migrated off the five colliding + type-ids; our provider collection tag renamed (upstream also uses + `'Prisma'`, and Effect context merge silently drops one of two same-key + collections); auth via `Layer.succeed(PrismaEnvironment, …)` instead of + `fromProfile()` (which prompts on TTY / hard-fails non-interactive); + `directConnectionString` bound explicitly (upstream's `databaseUrl` resolves + pooled-first); platform-seeded `DATABASE_URL` kept out of the resource graph + (verified `isManagedBySystem: true`); branch attachment via + create-then-PATCH (verified in PDP: create+attach are separate transactions, + no idempotency key). +3. **Contribute upstream** — object storage resources (~161 lines; upstream + deferred exactly the routes we call) and the generic core of the Postgres + state store (~450 lines; only alchemy state backend with distributed + locking, which upstream's own `Compute` docstring asks users to find). + `PgWarm` offered to upstream; drop ours if they solve cold-start in + `Database`/`Connection`. +4. **Keep local** — the dev emulators (~3,200 lines + s3-protocol) and the five + Composer-concept resources (`ServiceKey`, `GeneratedParam`, `S3Credentials`, + `PnMigration`, state-store policy layer). Emulators plug in behind + `LowerOptions.providers` exactly as today, now paired with upstream's live + providers. + +Aman has agreed to the direction (call, 2026-08-03). The one upstream ask that +gates workstream 1+4 composition: export `liveProviderLayer()` (or an +equivalent override on `providers()`) so Composer can compose upstream live +providers with its own local providers — today it is private and only +`providers()` (which hardwires dev-vs-live selection internally) is exported. + +# Non-goals + +- **Contributing the emulators upstream.** Explicitly reversed from the + original proposal: they stay in Composer so we iterate without upstream + review latency. Revisit only after upstream's dev-mode design (dual) settles. +- **Adopting `ProviderLayer.dual` / upstream dev mode.** Composer keeps its + layer-swap seam and split state universes (localState for dev, hosted store + for deploy). Dual solves a problem we don't have yet. +- **Adopting upstream's `Prisma.Compute` build/bundle conveniences.** We hand + upstream pre-built artifacts via `artifactPath` only (ADR-0005: the framework + never bundles/transforms user code). Auto-build, framework detection, and + Effect-native bundling are never exercised by Composer. +- **Adopting alchemy's profile/credential store.** Composer keeps env-var + credentials (`PRISMA_SERVICE_TOKEN` via `Config.redacted`). +- **Deterministic database names with branch attachment in one create.** + Upstream's guard is correct (verified against PDP); we adopt + create-then-PATCH rather than asking for the guard to be relaxed. + +# Place in the larger world + +- Upstream: `alchemy-run/alchemy`, provider at `packages/alchemy/src/Prisma` + (14.5k lines, merged 2026-07-29). Owner-of-record for merges is Sam Goodwin; + Aman authored the Prisma provider. Contributions land there as PRs. +- Composer side: the lowering package + (`packages/1-prisma-cloud/0-lowering/lowering`) shrinks to buckets (until + upstreamed), state store, container resolution; the extension + (`packages/1-prisma-cloud/1-extensions/target`) keeps descriptors + the five + Composer-concept resources; `local-target` + `dev-emulators` unchanged in + ownership, rewired to upstream resource shapes. +- The forcing-function-apps project consumes this: its object-storage and dev + workstreams sit directly on the seams this project moves. + +# Cross-cutting requirements + +- **No regression for deployed stages.** Existing state rows reference the old + type-ids and attribute shapes (`{id, name}` vs upstream's `databaseId`). + Every stage must deploy cleanly across the migration without recreating live + resources; destroy of pre-migration rows must still resolve a provider. +- **ADR-0005 holds everywhere.** Only `artifactPath` (or `Prisma.Deployment`'s + equivalent) is ever exercised; no code path may fall through to upstream + build/bundle/entrypoint inference. +- **Env parity rules survive the port.** ADR-0019/0029/0032 serialization, the + `COMPOSER_*` namespace, and the poison-`DATABASE_URL` exclusion must behave + identically on upstream `EnvironmentVariable`. +- **Local dev keeps working at every intermediate commit** — the emulator + providers must bind to whichever resource classes are current. +- **Pinned upstream version.** Alchemy stays pinned exact (as today); each bump + is a deliberate change with the beta-to-beta breaking-change review this + project's spike established (beta.60–65 were all Cloudflare/AWS-scoped). + +# Transitional-shape constraints + +- Adoption is per-resource-family, not big-bang: postgres family + (Project/Database/Connection) and compute family + (App/Deployment/EnvironmentVariable) may land in separate slices, each + leaving main deployable. +- Until the upstream `liveProviderLayer` export lands in a release, Composer + may carry a small local reimplementation of upstream's provider wiring + (client layer + individual `*Provider()` calls) — accepted drift risk, + removed the moment the export ships. +- Bucket resources stay in Composer until the upstream contribution merges and + releases; the s3/s3-store descriptors must tolerate either home. + +# Project DoD + +- [ ] Composer's six overlapping resource implementations are deleted; + lowering/descriptors consume `alchemy/Prisma` classes. +- [ ] `alchemy` pinned at a released version ≥ the first beta containing the + Prisma provider; CI green. +- [ ] A pre-existing deployed stage (created before the migration) deploys and + destroys cleanly on the new stack. +- [ ] Local dev (`prisma-composer dev`) runs the full example topology on the + emulators against upstream resource shapes. +- [ ] Deployed smoke suite passes (storefront-auth or equivalent example) on + Prisma Cloud. +- [ ] Object-storage resources PR and state-store PR opened upstream (merge is + not in our gift; opened + review-responsive is the bar). +- [ ] The upstream ask (export `liveProviderLayer` or equivalent) is filed and + either landed or worked around per the transitional constraint. +- [ ] ADR recorded documenting the adoption and the revised local-dev seam. + +# Open questions + +- **Compute vs App+Deployment**: adopt upstream's composite `Prisma.Compute` + (gains health-check + auto-rollback; env ordering owned internally) or the + low-level `App`+`Deployment` pair (closer to our current split; needs our own + env dependency edge)? Decide in the compute-family slice with the descriptor + rewiring in front of us. +- **State migration mechanics**: rewrite rows in place (SQL migration in the + hosted store) vs `Provider.aliases` (beta.65+ mechanism) vs + destroy-and-recreate per stage? Decide in the migration slice after testing + aliases against a scratch stage. +- **Which released beta** first contains the provider (beta.66 expected within + days of the spike; verify at slice start). + +# References + +- Session evaluation + notes for Aman: `wip/alchemy-prisma-provider-notes-for-aman.md` +- Memory: `alchemy-upstream-prisma-provider` (blockers now resolved by decisions above) +- Upstream provider: https://github.com/alchemy-run/alchemy/pull/416 +- Engine dual-mode: https://github.com/alchemy-run/alchemy/pull/963 +- Linear project: https://linear.app/prisma-company/project/alchemy-prisma-provider-adoption-79d1f6cc7bff +- ADR-0005 (no bundling), ADR-0019/0023/0024 (containers), ADR-0029/0032 (env + serialization), ADR-0034 (hosted state), ADR-0041 (local dev pipeline) From adab2f8359ae65a600ee933486f7f0153cc6ae32 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 17:03:48 +0200 Subject: [PATCH 02/22] =?UTF-8?q?feat(lowering):=20alchemy=20beta.67=20fou?= =?UTF-8?q?ndation=20=E2=80=94=20bump,=20PrismaComposer=20type-ids,=20alia?= =?UTF-8?q?ses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump alchemy 2.0.0-beta.59 -> .67 (13 pins) + forced effect beta.100 train. Rename the provider collection tag and the eight resource type-ids from Prisma.* to PrismaComposer.* so they cannot collide with the upstream alchemy/Prisma provider; old ids wired as aliases so existing state rows resolve (beta.67 ResourceOptions.aliases). One-line pnpm patch for upstream Aliases typing under exactOptionalPropertyTypes; Schedule.both removal handled with Schedule.upTo (during is inverted in beta.100, verified by probe). Part of alchemy-provider-adoption (TML-3154). Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/plan.md | 10 +- examples/bucket/package.json | 4 +- examples/cron/package.json | 6 +- examples/pn-widgets/package.json | 2 +- examples/storage/package.json | 4 +- examples/store/package.json | 6 +- examples/storefront-auth/package.json | 6 +- packages/0-framework/1-core/core/package.json | 4 +- .../0-lowering/local-target/package.json | 4 +- .../postgres-instance-name-drift.test.ts | 4 + .../0-lowering/lowering/package.json | 4 +- .../src/__tests__/ComputeService.test.ts | 5 +- .../lowering/src/__tests__/ServiceKey.test.ts | 1 + .../0-lowering/lowering/src/buckets/Bucket.ts | 4 +- .../lowering/src/buckets/BucketKey.ts | 6 +- .../lowering/src/compute/ComputeService.ts | 11 +- .../lowering/src/compute/Deployment.ts | 12 +- .../src/compute/EnvironmentVariable.ts | 7 +- .../lowering/src/postgres/Connection.ts | 10 +- .../lowering/src/postgres/Database.ts | 6 +- .../lowering/src/postgres/Project.ts | 4 +- .../0-lowering/lowering/src/providers.ts | 2 +- .../0-lowering/lowering/src/state/layer.ts | 2 +- .../lowering/src/state/transient.ts | 5 +- .../1-extensions/target/package.json | 4 +- .../src/__tests__/generated-param.test.ts | 1 + .../src/__tests__/pg-warm-resource.test.ts | 1 + .../__tests__/pn-migration-resource.test.ts | 1 + .../src/__tests__/s3-credentials.test.ts | 1 + .../composer-prisma-cloud/package.json | 8 +- packages/9-public/composer/package.json | 4 +- patches/alchemy@2.0.0-beta.67.patch | 26 + pnpm-lock.yaml | 1346 +++++++++-------- pnpm-workspace.yaml | 3 + test/integration/package.json | 2 +- website/package.json | 2 +- 36 files changed, 847 insertions(+), 681 deletions(-) create mode 100644 patches/alchemy@2.0.0-beta.67.patch diff --git a/.drive/projects/alchemy-provider-adoption/plan.md b/.drive/projects/alchemy-provider-adoption/plan.md index bbb75671..d8b96e63 100644 --- a/.drive/projects/alchemy-provider-adoption/plan.md +++ b/.drive/projects/alchemy-provider-adoption/plan.md @@ -51,8 +51,14 @@ the same conversation. ## Sequencing - Stack: 1 → 2. -- Parallel: 3 alongside both (start immediately — the export ask is the first - action, since slice 1 benefits from it). +- Parallel: 3 alongside both. +- **Operator overrides (2026-08-03):** all Composer-side slices land on THIS + branch (no per-slice branches; one Composer PR at the end). Slice 3 is ONE + implementation PR to alchemy-run/alchemy — `liveProviderLayer` export, + bucket resources, and the postgres state backend implemented directly, no + asks filed. Upstream branch: `prisma-provider-composer-needs` in + `~/Projects/prisma/alchemy` (push blocked until the wmadden-electric fork + exists). ## Close-out (required) diff --git a/examples/bucket/package.json b/examples/bucket/package.json index ad6bc96e..d5fb4dda 100644 --- a/examples/bucket/package.json +++ b/examples/bucket/package.json @@ -11,8 +11,8 @@ "@aws-sdk/client-s3": "^3.1085.0", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", - "effect": "4.0.0-beta.93" + "alchemy": "2.0.0-beta.67", + "effect": "4.0.0-beta.100" }, "devDependencies": { "@types/bun": "^1.3.13", diff --git a/examples/cron/package.json b/examples/cron/package.json index 71e753d1..1d40c1c9 100644 --- a/examples/cron/package.json +++ b/examples/cron/package.json @@ -13,12 +13,12 @@ "destroy": "( set -a; . \"${PRISMA_DEPLOY_ENV:-../../.env}\"; set +a; bun node_modules/.bin/prisma-composer destroy module.ts ${CRON_STACK_NAME:+--name \"$CRON_STACK_NAME\"} )" }, "dependencies": { - "@effect/platform-bun": "4.0.0-beta.97", + "@effect/platform-bun": "4.0.0-beta.100", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", - "effect": "4.0.0-beta.93" + "effect": "4.0.0-beta.100" }, "devDependencies": { "@prisma/composer": "workspace:0.3.0", diff --git a/examples/pn-widgets/package.json b/examples/pn-widgets/package.json index 99479b04..b53baa63 100644 --- a/examples/pn-widgets/package.json +++ b/examples/pn-widgets/package.json @@ -13,7 +13,7 @@ "@prisma-next/postgres": "0.16.0", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", - "effect": "4.0.0-beta.93", + "effect": "4.0.0-beta.100", "pg": "8.22.0" }, "devDependencies": { diff --git a/examples/storage/package.json b/examples/storage/package.json index 57e67819..649cd0c6 100644 --- a/examples/storage/package.json +++ b/examples/storage/package.json @@ -14,8 +14,8 @@ "@aws-sdk/client-s3": "^3.1085.0", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", - "effect": "4.0.0-beta.93" + "alchemy": "2.0.0-beta.67", + "effect": "4.0.0-beta.100" }, "devDependencies": { "@prisma/composer": "workspace:0.3.0", diff --git a/examples/store/package.json b/examples/store/package.json index 9dbd418e..52230b97 100644 --- a/examples/store/package.json +++ b/examples/store/package.json @@ -11,16 +11,16 @@ "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-composer destroy module.ts --production ${STORE_STACK_NAME:+--name \"$STORE_STACK_NAME\"} )" }, "dependencies": { - "@effect/platform-bun": "4.0.0-beta.97", + "@effect/platform-bun": "4.0.0-beta.100", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", "@store/catalog": "workspace:0.3.0", "@store/orders": "workspace:0.3.0", "@store/promotions": "workspace:0.3.0", "@store/storefront": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", - "effect": "4.0.0-beta.93" + "effect": "4.0.0-beta.100" }, "devDependencies": { "@types/bun": "^1.3.13", diff --git a/examples/storefront-auth/package.json b/examples/storefront-auth/package.json index 3ae95233..19b240cc 100644 --- a/examples/storefront-auth/package.json +++ b/examples/storefront-auth/package.json @@ -11,14 +11,14 @@ "test": "bun test module.test.ts" }, "dependencies": { - "@effect/platform-bun": "4.0.0-beta.97", + "@effect/platform-bun": "4.0.0-beta.100", "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", "@storefront-auth/auth": "workspace:0.3.0", "@storefront-auth/storefront": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", - "effect": "4.0.0-beta.93" + "effect": "4.0.0-beta.100" }, "devDependencies": { "@prisma/composer": "workspace:0.3.0", diff --git a/packages/0-framework/1-core/core/package.json b/packages/0-framework/1-core/core/package.json index 70040f0e..0aca628a 100644 --- a/packages/0-framework/1-core/core/package.json +++ b/packages/0-framework/1-core/core/package.json @@ -19,8 +19,8 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "alchemy": "2.0.0-beta.59", - "effect": "4.0.0-beta.93", + "alchemy": "2.0.0-beta.67", + "effect": "4.0.0-beta.100", "@internal/foundation": "workspace:0.3.0" }, "devDependencies": { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/package.json b/packages/1-prisma-cloud/0-lowering/local-target/package.json index 473b8e34..f7172f06 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/package.json +++ b/packages/1-prisma-cloud/0-lowering/local-target/package.json @@ -17,8 +17,8 @@ "@internal/dev-emulators": "workspace:0.3.0", "@internal/lowering": "workspace:0.3.0", "@internal/s3-protocol": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59", - "effect": "4.0.0-beta.93", + "alchemy": "2.0.0-beta.67", + "effect": "4.0.0-beta.100", "tar": "^7.5.21" }, "devDependencies": { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts index 47522aa8..aea6df54 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -88,6 +88,7 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseAttributes = await Effect.runPromise( databaseService.reconcile({ id: 'db', + fqn: 'db', instanceId: 'db', news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, olds: undefined, @@ -114,6 +115,7 @@ describe('instance-name drift (delta review finding A, #160)', () => { const connectionAttributes = await Effect.runPromise( connectionService.reconcile({ id: 'conn', + fqn: 'conn', instanceId: 'conn', news: { databaseId: databaseAttributes.id, name: 'conn' }, olds: undefined, @@ -158,6 +160,7 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseAttributes = await Effect.runPromise( databaseService.reconcile({ id: 'db', + fqn: 'db', instanceId: 'db', news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, olds: undefined, @@ -178,6 +181,7 @@ describe('instance-name drift (delta review finding A, #160)', () => { const connectionAttributes = await Effect.runPromise( connectionService.reconcile({ id: 'conn', + fqn: 'conn', instanceId: 'conn', news: { databaseId: databaseAttributes.id, name: 'conn' }, olds: undefined, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index 3d9186dd..b124c083 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -20,8 +20,8 @@ "@internal/core": "workspace:0.3.0", "@internal/foundation": "workspace:0.3.0", "@prisma/management-api-sdk": "^1.50.0", - "alchemy": "2.0.0-beta.59", - "effect": "4.0.0-beta.93", + "alchemy": "2.0.0-beta.67", + "effect": "4.0.0-beta.100", "postgres": "^3.4.9" }, "devDependencies": { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts index 97299f44..05038c40 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts @@ -85,9 +85,8 @@ describe('delete retry wiring (Effect.retry({ schedule, while }))', () => { return yield* Effect.fail(deleteNotSafeError); }); - const shortCappedSchedule = Schedule.both( - Schedule.spaced('1 millis'), - Schedule.during('20 millis'), + const shortCappedSchedule = Schedule.spaced('1 millis').pipe( + Schedule.upTo({ duration: '20 millis' }), ); const outcome = await Effect.runPromiseExit( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ServiceKey.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ServiceKey.test.ts index 56f760ee..aecd387a 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ServiceKey.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ServiceKey.test.ts @@ -17,6 +17,7 @@ import { const reconcile = (output: ServiceKeyAttributes | undefined) => serviceKeyProviderService.reconcile({ id: 'key', + fqn: 'key', instanceId: 'key', news: {}, olds: output === undefined ? undefined : {}, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts index e688eb04..2de2b918 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts @@ -17,10 +17,10 @@ export interface BucketAttributes { name: string; } -export type Bucket = Resource<'Prisma.Bucket', BucketProps, BucketAttributes>; +export type Bucket = Resource<'PrismaComposer.Bucket', BucketProps, BucketAttributes>; /** A Prisma **Object Store bucket** inside a project. */ -export const Bucket = Resource('Prisma.Bucket'); +export const Bucket = Resource('PrismaComposer.Bucket', { aliases: ['Prisma.Bucket'] }); export const BucketProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts index 546ef16d..dd7a9bfe 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/BucketKey.ts @@ -37,10 +37,12 @@ export interface BucketKeyAttributes { bucketName: string; } -export type BucketKey = Resource<'Prisma.BucketKey', BucketKeyProps, BucketKeyAttributes>; +export type BucketKey = Resource<'PrismaComposer.BucketKey', BucketKeyProps, BucketKeyAttributes>; /** A **bucket access key** for a Prisma Object Store bucket — yields the S3 credentials. */ -export const BucketKey = Resource('Prisma.BucketKey'); +export const BucketKey = Resource('PrismaComposer.BucketKey', { + aliases: ['Prisma.BucketKey'], +}); export const BucketKeyProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts index 70acc081..64914b70 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts @@ -21,9 +21,8 @@ export const isDeleteNotSafeYet = (error: PrismaApiError): boolean => * for the platform to finish stopping the deployment, short enough to still * fail loudly (rather than hang forever) if it never does. */ -export const deleteSafeRetrySchedule = Schedule.both( - Schedule.exponential('2 seconds', 2), - Schedule.during('5 minutes'), +export const deleteSafeRetrySchedule = Schedule.exponential('2 seconds', 2).pipe( + Schedule.upTo({ duration: '5 minutes' }), ); /** Every region Prisma Compute serves — the runtime source of truth; `ComputeRegion` is derived from it so the two can never drift. */ @@ -54,13 +53,15 @@ export interface ComputeServiceAttributes { } export type ComputeService = Resource< - 'Prisma.ComputeService', + 'PrismaComposer.ComputeService', ComputeServiceProps, ComputeServiceAttributes >; /** A Prisma **Compute service** — the stable app identity behind a project. */ -export const ComputeService = Resource('Prisma.ComputeService'); +export const ComputeService = Resource('PrismaComposer.ComputeService', { + aliases: ['Prisma.ComputeService'], +}); export const ComputeServiceProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts index a71de7d0..6d22dac4 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts @@ -39,14 +39,20 @@ export interface DeploymentAttributes { deployedUrl?: string; } -export type Deployment = Resource<'Prisma.Deployment', DeploymentProps, DeploymentAttributes>; +export type Deployment = Resource< + 'PrismaComposer.Deployment', + DeploymentProps, + DeploymentAttributes +>; /** * A **deployment** of a Prisma app — creates a deployment, uploads * its artifact, starts the VM, waits for it to run, then promotes it to the * app's stable endpoint. */ -export const Deployment = Resource('Prisma.Deployment'); +export const Deployment = Resource('PrismaComposer.Deployment', { + aliases: ['Prisma.Deployment'], +}); export const DeploymentProvider = () => Provider.effect( @@ -73,7 +79,7 @@ export const DeploymentProvider = () => }), ), ), - Effect.retry(Schedule.both(Schedule.spaced('2 seconds'), Schedule.during('2 minutes'))), + Effect.retry(Schedule.spaced('2 seconds').pipe(Schedule.upTo({ duration: '2 minutes' }))), ); return { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts index 94f9e881..67857dac 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts @@ -25,7 +25,7 @@ export interface EnvironmentVariableAttributes { } export type EnvironmentVariable = Resource< - 'Prisma.EnvironmentVariable', + 'PrismaComposer.EnvironmentVariable', EnvironmentVariableProps, EnvironmentVariableAttributes >; @@ -35,7 +35,10 @@ export type EnvironmentVariable = Resource< * project's services from their attached branch (e.g. wiring one module's URL into * another). */ -export const EnvironmentVariable = Resource('Prisma.EnvironmentVariable'); +export const EnvironmentVariable = Resource( + 'PrismaComposer.EnvironmentVariable', + { aliases: ['Prisma.EnvironmentVariable'] }, +); export const EnvironmentVariableProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts index 2de9ffd3..dc079276 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts @@ -20,10 +20,16 @@ export interface ConnectionAttributes { connectionString: Redacted.Redacted; } -export type Connection = Resource<'Prisma.Connection', ConnectionProps, ConnectionAttributes>; +export type Connection = Resource< + 'PrismaComposer.Connection', + ConnectionProps, + ConnectionAttributes +>; /** A **connection** to a Prisma Postgres database — yields the connection string. */ -export const Connection = Resource('Prisma.Connection'); +export const Connection = Resource('PrismaComposer.Connection', { + aliases: ['Prisma.Connection'], +}); export const ConnectionProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts index 2c3957e8..bce46da3 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts @@ -27,10 +27,12 @@ export interface DatabaseAttributes { name: string; } -export type Database = Resource<'Prisma.Database', DatabaseProps, DatabaseAttributes>; +export type Database = Resource<'PrismaComposer.Database', DatabaseProps, DatabaseAttributes>; /** A Prisma **Postgres database** inside a project. */ -export const Database = Resource('Prisma.Database'); +export const Database = Resource('PrismaComposer.Database', { + aliases: ['Prisma.Database'], +}); export const DatabaseProvider = () => Provider.effect( diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts index fe3afcf8..1f0b3b5a 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts @@ -16,10 +16,10 @@ export interface ProjectAttributes { name: string; } -export type Project = Resource<'Prisma.Project', ProjectProps, ProjectAttributes>; +export type Project = Resource<'PrismaComposer.Project', ProjectProps, ProjectAttributes>; /** A Prisma Developer Platform **Project** — the container for databases and compute services. */ -export const Project = Resource('Prisma.Project'); +export const Project = Resource('PrismaComposer.Project', { aliases: ['Prisma.Project'] }); export const ProjectProvider = () => Provider.effect( 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 2746d187..e975520e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -12,7 +12,7 @@ import { Database, DatabaseProvider } from './postgres/Database.ts'; import { Project, ProjectProvider } from './postgres/Project.ts'; /** The collection of Prisma resource providers. */ -export class Providers extends Provider.ProviderCollection()('Prisma') {} +export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} /** * The Prisma provider bundle: every resource provider, the Management API 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 ad325269..e2e85365 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 @@ -67,7 +67,7 @@ export const prismaStateLayer = (ids: { // workspace) can refuse connections for a while after the Management // API returns it, so retry the window out before failing. yield* migratePrismaState(sql).pipe( - Effect.retry(Schedule.both(Schedule.spaced('5 seconds'), Schedule.during('2 minutes'))), + Effect.retry(Schedule.spaced('5 seconds').pipe(Schedule.upTo({ duration: '2 minutes' }))), Effect.mapError(bootstrapError('schema migration')), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts index b6ed2a46..003bd142 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts @@ -46,9 +46,8 @@ export const isColdStartConnectError = (error: unknown): boolean => { }; /** The same ~2-minute budget the bootstrap migration uses (`layer.ts`): retry every 5s, up to 2 minutes. */ -const COLD_START_SCHEDULE = Schedule.both( - Schedule.spaced('5 seconds'), - Schedule.during('2 minutes'), +const COLD_START_SCHEDULE = Schedule.spaced('5 seconds').pipe( + Schedule.upTo({ duration: '2 minutes' }), ); /** diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index 0f9e4c51..3cef2f1c 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -35,9 +35,9 @@ "@prisma-next/postgres": "0.16.0", "@prisma-next/sql-contract": "0.16.0", "@standard-schema/spec": "^1.1.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", - "effect": "4.0.0-beta.93", + "effect": "4.0.0-beta.100", "pathe": "^2.0.3", "pg": "8.22.0" }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/generated-param.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/generated-param.test.ts index 8e41de83..3b5c6690 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/generated-param.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/generated-param.test.ts @@ -19,6 +19,7 @@ import { envSecret } from '../secret.ts'; const reconcile = (bytes: number, output: GeneratedParamAttributes | undefined) => generatedParamProviderService.reconcile({ id: 'gen', + fqn: 'gen', instanceId: 'gen', news: { bytes }, olds: output === undefined ? undefined : { bytes }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pg-warm-resource.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pg-warm-resource.test.ts index e13999ea..41eb5e9b 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pg-warm-resource.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pg-warm-resource.test.ts @@ -34,6 +34,7 @@ describe.skipIf(pg === undefined)('PgWarm reconcile warms a real database', () = const reconcile = (url: string) => pgWarmProviderService.reconcile({ id: 'db', + fqn: 'db', instanceId: 'db', news: { url }, olds: undefined, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-migration-resource.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-migration-resource.test.ts index eb01835f..496733a9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-migration-resource.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pn-migration-resource.test.ts @@ -152,6 +152,7 @@ describe.skipIf(pg === undefined)('PnMigration reconcile routes through applyPnM const reconcile = (contractJson: unknown) => pnMigrationProviderService.reconcile({ id: 'db', + fqn: 'db', instanceId: 'db', news: { url, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/s3-credentials.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/s3-credentials.test.ts index 972e0e13..5ad57d69 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/s3-credentials.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/s3-credentials.test.ts @@ -19,6 +19,7 @@ import { const reconcile = (output: S3CredentialsAttributes | undefined) => s3CredentialsProviderService.reconcile({ id: 'creds', + fqn: 'creds', instanceId: 'creds', news: {}, olds: output === undefined ? undefined : {}, diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index 13d5bb5b..95a55faf 100644 --- a/packages/9-public/composer-prisma-cloud/package.json +++ b/packages/9-public/composer-prisma-cloud/package.json @@ -39,8 +39,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/platform-bun": "4.0.0-beta.97", - "@effect/platform-node": "4.0.0-beta.92", + "@effect/platform-bun": "4.0.0-beta.100", + "@effect/platform-node": "4.0.0-beta.100", "@internal/dev-emulators": "workspace:0.3.0", "@prisma-next/cli": "0.16.0", "@prisma-next/config-loader": "0.16.0", @@ -51,9 +51,9 @@ "@prisma/composer": "workspace:0.3.0", "@prisma/management-api-sdk": "^1.50.0", "@standard-schema/spec": "^1.1.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", - "effect": "4.0.0-beta.93", + "effect": "4.0.0-beta.100", "jose": "^6.1.3", "pathe": "^2.0.3", "pg": "8.22.0", diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 147a4bff..40aeb45b 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -34,11 +34,11 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", "c12": "^3.3.4", "clipanion": "^3.2.1", - "effect": "4.0.0-beta.93", + "effect": "4.0.0-beta.100", "esbuild": "^0.28.1", "postgres": "^3.4.9", "@prisma/management-api-sdk": "^1.50.0" diff --git a/patches/alchemy@2.0.0-beta.67.patch b/patches/alchemy@2.0.0-beta.67.patch new file mode 100644 index 00000000..4c2bd118 --- /dev/null +++ b/patches/alchemy@2.0.0-beta.67.patch @@ -0,0 +1,26 @@ +diff --git a/lib/Resource.d.ts b/lib/Resource.d.ts +index 4ddd84f..e7b23ba 100644 +--- a/lib/Resource.d.ts ++++ b/lib/Resource.d.ts +@@ -35,7 +35,7 @@ export interface ResourceClassLike { + * `ProviderService` by `Provider.succeed`/`Provider.effect` so provider + * lookup can resolve state persisted under a pre-rename type. + */ +- Aliases?: readonly string[]; ++ Aliases?: readonly string[] | undefined; + } + export type ResourceClass = ResourceConstructor : R["Providers"]> & Effect.Effect> & { + Self: Self; +diff --git a/src/Resource.ts b/src/Resource.ts +index 8ab04a8..72bfec6 100644 +--- a/src/Resource.ts ++++ b/src/Resource.ts +@@ -56,7 +56,7 @@ export interface ResourceClassLike { + * `ProviderService` by `Provider.succeed`/`Provider.effect` so provider + * lookup can resolve state persisted under a pre-rename type. + */ +- Aliases?: readonly string[]; ++ Aliases?: readonly string[] | undefined; + } + + export type ResourceClass = ResourceConstructor< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18c7607..d3bc82a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + alchemy@2.0.0-beta.67: + hash: 2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44 + path: patches/alchemy@2.0.0-beta.67.patch + importers: .: @@ -79,11 +84,11 @@ importers: specifier: workspace:0.3.0 version: link:../../packages/9-public/composer-prisma-cloud alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -95,8 +100,8 @@ importers: examples/cron: dependencies: '@effect/platform-bun': - specifier: 4.0.0-beta.97 - version: 4.0.0-beta.97(effect@4.0.0-beta.93) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@prisma/composer': specifier: workspace:0.3.0 version: link:../../packages/9-public/composer @@ -104,14 +109,14 @@ importers: specifier: workspace:0.3.0 version: link:../../packages/9-public/composer-prisma-cloud alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -188,8 +193,8 @@ importers: specifier: workspace:0.3.0 version: link:../../packages/9-public/composer-prisma-cloud effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 pg: specifier: 8.22.0 version: 8.22.0 @@ -216,11 +221,11 @@ importers: specifier: workspace:0.3.0 version: link:../../packages/9-public/composer-prisma-cloud alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -232,8 +237,8 @@ importers: examples/store: dependencies: '@effect/platform-bun': - specifier: 4.0.0-beta.97 - version: 4.0.0-beta.97(effect@4.0.0-beta.93) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@prisma/composer': specifier: workspace:0.3.0 version: link:../../packages/9-public/composer @@ -253,14 +258,14 @@ importers: specifier: workspace:0.3.0 version: link:modules/storefront alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -393,8 +398,8 @@ importers: examples/storefront-auth: dependencies: '@effect/platform-bun': - specifier: 4.0.0-beta.97 - version: 4.0.0-beta.97(effect@4.0.0-beta.93) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@prisma/composer': specifier: workspace:0.3.0 version: link:../../packages/9-public/composer @@ -408,14 +413,14 @@ importers: specifier: workspace:0.3.0 version: link:modules/storefront alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@types/bun': specifier: ^1.3.13 @@ -539,11 +544,11 @@ importers: specifier: ^1.1.0 version: 1.1.0 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 devDependencies: '@internal/tsdown-config': specifier: workspace:0.3.0 @@ -762,11 +767,11 @@ importers: specifier: workspace:0.3.0 version: link:../s3-protocol alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 tar: specifier: ^7.5.21 version: 7.5.21 @@ -802,11 +807,11 @@ importers: specifier: ^1.50.0 version: 1.50.0 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -899,14 +904,14 @@ importers: specifier: ^1.1.0 version: 1.1.0 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -979,7 +984,7 @@ importers: version: 2.2.3 better-auth: specifier: 1.6.24 - version: 1.6.24(mysql2@3.22.5(@types/node@26.1.1))(next@16.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) + version: 1.6.24(@cloudflare/workers-types@5.20260801.1)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(mysql2@3.22.5(@types/node@26.1.1))(next@16.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) jose: specifier: ^6.1.3 version: 6.2.4 @@ -1181,8 +1186,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -1193,8 +1198,8 @@ importers: specifier: ^3.2.1 version: 3.2.1(typanion@3.14.0) effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -1242,11 +1247,11 @@ importers: packages/9-public/composer-prisma-cloud: dependencies: '@effect/platform-bun': - specifier: 4.0.0-beta.97 - version: 4.0.0-beta.97(effect@4.0.0-beta.93) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/platform-node': - specifier: 4.0.0-beta.92 - version: 4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) '@internal/dev-emulators': specifier: workspace:0.3.0 version: link:../../1-prisma-cloud/0-lowering/dev-emulators @@ -1278,14 +1283,14 @@ importers: specifier: ^1.1.0 version: 1.1.0 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 effect: - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93 + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100 jose: specifier: ^6.1.3 version: 6.2.4 @@ -1375,8 +1380,8 @@ importers: specifier: ^26.0.1 version: 26.1.1 alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) prisma: specifier: 7.9.0 version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) @@ -1393,8 +1398,8 @@ importers: specifier: workspace:0.3.0 version: link:../packages/9-public/composer-prisma-cloud alchemy: - specifier: 2.0.0-beta.59 - version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1) + specifier: 2.0.0-beta.67 + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) devDependencies: '@prisma/management-api-sdk': specifier: ^1.47.0 @@ -1657,16 +1662,22 @@ packages: cpu: [x64] os: [win32] - '@clack/core@0.5.0': - resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} - '@clack/prompts@0.11.0': - resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@clack/prompts@1.7.0': resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} @@ -1680,50 +1691,53 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260617.1': - resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} + '@cloudflare/workerd-darwin-64@1.20260704.1': + resolution: {integrity: sha512-XO+vvdhhTNZSsIWCkZ+JaE/JrFUmQAB0H0y/sVkAf32xa2TYBRvFUMwjSqf6WxtlxcKPQvmbLfpO/UQ0l/q4eQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260617.1': - resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} + '@cloudflare/workerd-darwin-arm64@1.20260704.1': + resolution: {integrity: sha512-6iI7nbOOO8PzEQ6UZVBZB/hv95m8jl0yvyjMuWrF5cJbiLb5zPw3KnpqvGi+aeOs2ZmUkc81i1FWKfXwLXzPRA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260617.1': - resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} + '@cloudflare/workerd-linux-64@1.20260704.1': + resolution: {integrity: sha512-3mT0YHtxT7eLjghu3hKSJDUQoz+AYv8FM43nLPYhM0YiHOxr8OlmvnCb2Lp7v/U3bwd3Gnx7WEqjSppR583mGg==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260617.1': - resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} + '@cloudflare/workerd-linux-arm64@1.20260704.1': + resolution: {integrity: sha512-Opo7cPTPg4x0WwK+eZSDszCyFLKQkOGNCWxF005HRTJ5SJH8h0K9KyrC1k4LBwx3haXSVi+E1eGqo1gZ1Hmhkg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260617.1': - resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} + '@cloudflare/workerd-windows-64@1.20260704.1': + resolution: {integrity: sha512-a97Ecnzhy04x3U052VKPCNp863F7Wf00WY7Ga5P0SbtYvaO1PDzylsHrvFMPKeiDFcOwqiredawmJ+v6bhIgeQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@distilled.cloud/aws@0.27.0': - resolution: {integrity: sha512-3yjwnQ15XJJcDuNpeRD0INSLV4tQOulo5zppdp7juT6ODcDCxLBkx6QeVWlxCGPVqKed2LDBxHzVDj+8AxaIjw==} + '@cloudflare/workers-types@5.20260801.1': + resolution: {integrity: sha512-XCv5xWi47WQOK0LpLa6997Mrpz8Ct+nZmp/M5Xp8Z4BFsarf7nYjkznGOcOoYK5m1GfbMFEEuQ2OIZnbIWoe9A==} + + '@distilled.cloud/aws@0.30.3': + resolution: {integrity: sha512-6U/wO+fLNnqBlRnqFpF79edS5t6njDl/6UmnCVUfWpIQ4n23X0VY1ft0lzsaBa506xRtF4vRhMELR9FIp5DKUA==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' - '@distilled.cloud/axiom@0.27.0': - resolution: {integrity: sha512-wVTeDihVSsr3TB9glQ/6DpHZd3L/OoG6oTDMLVZagEYuvohE6BGBbQ6pdj1XaUtnNY1x+ghTAvL/J17tds5Ukw==} + '@distilled.cloud/axiom@0.30.3': + resolution: {integrity: sha512-U4YvXsvz/TDYfIbZNRVAv8Yx1mnbms/rBQ/RHnRQWhL0JzxZOZOQvToxkB4kh/oa8KT+QbjTkDxRJP/f6P0M1g==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' - '@distilled.cloud/cloudflare-rolldown-plugin@0.11.3': - resolution: {integrity: sha512-yr4ZzZFwXcsXz9nadNCJ6TAOMFOFvW0zOn/ynhYXdZ5DCxUKya871pTphY3WFU5A9ouSfKnlZ/3FXn0DZr6RZQ==} + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0': + resolution: {integrity: sha512-cBVl4Ck4Prf9/dPSvyQeGW+2CDLIKs+xbyUm/MgNOSyYDZYOLbsCnDePO4YwdvaxsT0oOZZIkSDDki2ve9DCHA==} peerDependencies: - rolldown: ^1.0.1 + rolldown: ^1.1.5 vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: rolldown: @@ -1731,27 +1745,27 @@ packages: vite: optional: true - '@distilled.cloud/cloudflare-runtime@0.11.3': - resolution: {integrity: sha512-CdA0gRWudvrsE4PSFSYYSjrQWSW6Z+Y1ACG7H9GmdWKqmEzhdY5xxg5Mr6Nt2JrIOGInDZuZRqprhAus6nmTeA==} + '@distilled.cloud/cloudflare-runtime@0.15.0': + resolution: {integrity: sha512-0xx+LCiBzNwmMPN1SnnD4GixVl8WZET3zaMPU51LYRU+VQQrxtetDjcjnI5q83UComjBmbS0c0Bp4j/7hgobyg==} peerDependencies: - '@distilled.cloud/cloudflare': ^0.24.5 - '@effect/platform-bun': '>=4.0.0-beta.78 || >=4.0.0' - '@effect/platform-node': '>=4.0.0-beta.78 || >=4.0.0' - effect: '>=4.0.0-beta.78 || >=4.0.0' + '@distilled.cloud/cloudflare': ^0.29.0 + '@effect/platform-bun': '>=4.0.0-beta.100 || >=4.0.0' + '@effect/platform-node': '>=4.0.0-beta.100 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' peerDependenciesMeta: '@effect/platform-bun': optional: true '@effect/platform-node': optional: true - '@distilled.cloud/cloudflare-vite-plugin@0.11.3': - resolution: {integrity: sha512-XRBtyFtk2cV3YKgqBBQ0YQfjf0fF57TAmHAuttA1Ed2fcIIRkUFaKdbItnRSdk8WCdEIhM41ZxCg4fqBEXw/3w==} + '@distilled.cloud/cloudflare-vite-plugin@0.15.0': + resolution: {integrity: sha512-+JYCTv/1Bqk3GRSb6e+UW61Pn2DFF68EAVvAbTYg5Z6tlztc2E7sd/pC4E2fsnAxtSMjwpFdrzlPqwQUq2HUQA==} peerDependencies: - '@distilled.cloud/cloudflare': ^0.24.5 - '@distilled.cloud/cloudflare-runtime': 0.11.3 - '@effect/platform-bun': '>=4.0.0-beta.78 || >=4.0.0' - '@effect/platform-node': '>=4.0.0-beta.78 || >=4.0.0' - effect: '>=4.0.0-beta.78 || >=4.0.0' + '@distilled.cloud/cloudflare': ^0.29.0 + '@distilled.cloud/cloudflare-runtime': 0.15.0 + '@effect/platform-bun': '>=4.0.0-beta.100 || >=4.0.0' + '@effect/platform-node': '>=4.0.0-beta.100 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' vite: ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@effect/platform-bun': @@ -1759,25 +1773,25 @@ packages: '@effect/platform-node': optional: true - '@distilled.cloud/cloudflare@0.27.0': - resolution: {integrity: sha512-sm4A/XEmN5/Bg7Tpe7C2U59wTpWf4uceOF/NnL8HqB250CWXftG2xGMxRidwFP0TRk41tfp5AkPjP8HtdxcAZw==} + '@distilled.cloud/cloudflare@0.30.3': + resolution: {integrity: sha512-IwQyIZrfzRJ7Dn9Plsk5pMlr50CT72fMnD5YCFy31MKAthh906ewcO3NuJOKHGA8AF9u6SKTlebsQx4Jnj8uwA==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' - '@distilled.cloud/core@0.27.0': - resolution: {integrity: sha512-Ak5e9k14i8PZCEOGlY64famKs9rFjRxpXnnhWRsLIESCOI6YRIggY9BWbHVBRQcRrGWm4rukR9Zmym/irfSrCA==} + '@distilled.cloud/core@0.30.3': + resolution: {integrity: sha512-RupX597cPmceEiOY6csihFbzH2WhDkfbwGCMk+Izx8+f16u+aLm4mgyrYoxj1/dzIHsyIfw8sFKgNPzAg0Rx2Q==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' - '@distilled.cloud/neon@0.27.0': - resolution: {integrity: sha512-KaOmXjvBb+Xtku1BQCYbxiB8TynuHXWnXSLaXL0uyntkTz1a/1U7ePQKTrfbKBdGL4r6p5hrV1PUO48Rg7eqKg==} + '@distilled.cloud/neon@0.30.3': + resolution: {integrity: sha512-4iW6lNvrJ/BuraKQ572bTQPNNtK/pFMqALoKjgozXR5jAZXbohRrD8/3QHLZW9cOAPenaHugwk4YIm0/+u4j5Q==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' - '@distilled.cloud/planetscale@0.27.0': - resolution: {integrity: sha512-Yb5Anj0QflXs1PHQh5U7KmxwbaXBUf4tPukZ/4dkhcT6/cer76qFv+N+W6wc6mX8LuvLEyJvYWQKJMzXv7/dXQ==} + '@distilled.cloud/planetscale@0.30.3': + resolution: {integrity: sha512-0ZcPqoXKl5uhJ6Ytw9UpgHa2t2pjwsuQFZn4OlnjZl0F/lToO/8TdXp4iKyw7Wog5tKTkfRYrQXVzRbaPKP3QQ==} peerDependencies: - effect: '>=4.0.0-beta.66 || >=4.0.0' + effect: '>=4.0.0-beta.100 || >=4.0.0' '@durable-streams/client@0.2.3': resolution: {integrity: sha512-609hWTqe8/OXzIFnv+oDdlT57QsCAc3F2c/nAQBcYhSLmmbXk5rHx7rnQSmk9MeGGQ8dsg9UCZf47dTJG3q3ig==} @@ -1794,65 +1808,75 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - '@effect/platform-bun@4.0.0-beta.97': - resolution: {integrity: sha512-WYjC7nKiWfNywIz1zeBEXnrpuHJM86DOi3lSZSSBeHCPz8HYw7IT2FL7u+aaJHHsJCyFiZVAgg2KFS8aMFSJBQ==} + '@effect/platform-bun@4.0.0-beta.100': + resolution: {integrity: sha512-UyH4bgzlV3aJOXbUr/3Zej3MBPe9TDrdkwStLDpVn9Yc6dKIKOJBd0G/8+OQhunGAuCL9a4SaNvwhkMJajahrg==} peerDependencies: - effect: ^4.0.0-beta.97 + effect: ^4.0.0-beta.100 - '@effect/platform-node-shared@4.0.0-beta.99': - resolution: {integrity: sha512-POBAowafsAAb3bH1x1rJlWnv32yMAazFgEuRW5LhkW/JJA5VGoEk9OnuoUkIH1OW6K/X6IrdNpqcO+5e9lPQJA==} + '@effect/platform-node-shared@4.0.0-beta.102': + resolution: {integrity: sha512-gVd793I72MrkX4dXo7eYtRKfNj0RW4eMRfVEKEJI16h2+mBDCzQ+gqMrog2hSTHQnaIbvbYShNQ4TVGuRCYZeQ==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.99 + effect: ^4.0.0-beta.102 - '@effect/platform-node@4.0.0-beta.92': - resolution: {integrity: sha512-ZNcwKqBb99yw+cj+KQBMgw0xoQl3GDbUtQjnN3cLsIRpfYS/AVcSp4wfURMczvX5PzoR131yOWkqds5Vmm5tLg==} + '@effect/platform-node@4.0.0-beta.100': + resolution: {integrity: sha512-nH5xgxOLfPj5Bi0/o4OaDsR98Z59lHKGty+TDqckg7zRz6jry82hGW982NRPduzX0MJloDsGp/3rfeN4Hu7Keg==} engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.92 + effect: ^4.0.0-beta.100 ioredis: ^5.7.0 - '@effect/vitest@4.0.0-beta.92': - resolution: {integrity: sha512-PCFYRDH56wF73aQWnpQh8Z4CJkI1m64t8ysZcI+oN7Iy/Y7dv5hiYP7psCWhqkCitbGcyB1edhSl61weHZ5Hxw==} + '@effect/sql-d1@4.0.0-beta.102': + resolution: {integrity: sha512-p6V1BqXEKF/EVH1JkWjjslPtg5Knl2TWopMnDdgto2jGfRpCHfhEaI8fA2OmR5J5H/TIH9ihYBiUA6Awru2Kvg==} + peerDependencies: + effect: ^4.0.0-beta.102 + + '@effect/vitest@4.0.0-beta.102': + resolution: {integrity: sha512-4dipFAYG6imOzrY3zy3BgzCJkbb9xESyzUef0WSx8bsK0/SpITqbJpdwKeOyDWLZ+rimjua8IbTFMAde865pIQ==} peerDependencies: - effect: ^4.0.0-beta.92 + effect: ^4.0.0-beta.102 vitest: ^3.0.0 || ^4.0.0 + '@electric-sql/pglite-socket@0.0.20': + resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.3.15 + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true peerDependencies: '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-tools@0.2.20': + resolution: {integrity: sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==} + peerDependencies: + '@electric-sql/pglite': 0.3.15 + '@electric-sql/pglite-tools@0.3.3': resolution: {integrity: sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==} peerDependencies: '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite@0.3.15': + resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} + '@electric-sql/pglite@0.4.3': resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -2012,6 +2036,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2216,9 +2246,21 @@ packages: cpu: [x64] os: [win32] + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@mongodb-js/saslprep@1.4.13': + resolution: {integrity: sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==} + + '@mrleebo/prisma-ast@0.13.1': + resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} + engines: {node: '>=16'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -2399,9 +2441,6 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxc-project/types@0.130.0': - resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -2715,6 +2754,9 @@ packages: '@prisma/debug@7.9.0': resolution: {integrity: sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==} + '@prisma/dev@0.20.0': + resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} + '@prisma/dev@0.24.14': resolution: {integrity: sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==} @@ -3022,12 +3064,6 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc - '@rolldown/binding-android-arm64@1.0.1': - resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3040,12 +3076,6 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.1': - resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@rolldown/binding-darwin-arm64@1.1.5': resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3058,12 +3088,6 @@ packages: cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.1': - resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3076,12 +3100,6 @@ packages: cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.1': - resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@rolldown/binding-freebsd-x64@1.1.5': resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3094,12 +3112,6 @@ packages: cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3112,12 +3124,6 @@ packages: cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.1': - resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3130,12 +3136,6 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.1': - resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3148,12 +3148,6 @@ packages: cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.0.1': - resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3166,12 +3160,6 @@ packages: cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.0.1': - resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3184,12 +3172,6 @@ packages: cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.1': - resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3202,12 +3184,6 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.1': - resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3220,12 +3196,6 @@ packages: cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.1': - resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.1.5': resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3238,11 +3208,6 @@ packages: cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.1': - resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.1.5': resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3253,12 +3218,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.1': - resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.1.5': resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3271,12 +3230,6 @@ packages: cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.1': - resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3292,6 +3245,15 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -3508,6 +3470,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3518,6 +3486,11 @@ packages: resolution: {integrity: sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==} engines: {node: '>=14'} + '@vercel/nft@1.10.2': + resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==} + engines: {node: '>=20'} + hasBin: true + '@visx/curve@4.0.1-alpha.0': resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} @@ -3695,6 +3668,15 @@ packages: '@yuku-toolchain/types@0.5.43': resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx-walk@2.0.0: resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} @@ -3716,22 +3698,29 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alchemy@2.0.0-beta.59: - resolution: {integrity: sha512-Y1a2NWSK6/4UJT9Xv32oTu/vgk9KvKqXgnJ6SOu0U8RthFQSrVwqE5hN9Yp+3rRJTiaG23CjB4OeyAXFlD8nKA==} + alchemy@2.0.0-beta.67: + resolution: {integrity: sha512-kFEKEXtRdf781lRzGyYinPRVgrMKJFs5MNQTxF2Y5RIxyA2qCsK0NOOBBoOzYmGOln8e3CIYF/oEASFQjffXJA==} hasBin: true peerDependencies: - '@effect/platform-bun': '>=4.0.0-beta.84|| >=4.0.0' - '@effect/platform-node': '>=4.0.0-beta.84|| >=4.0.0' - '@effect/sql-pg': '>=4.0.0-beta.84|| >=4.0.0' - drizzle-kit: '>=1.0.0-rc.1' - drizzle-orm: '>=1.0.0-rc.1' - effect: '>=4.0.0-beta.84|| >=4.0.0' + '@aws/durable-execution-sdk-js': ^2.1.0 + '@effect/platform-bun': '>=4.0.0-beta.100 || >=4.0.0' + '@effect/platform-node': '>=4.0.0-beta.100 || >=4.0.0' + '@effect/sql-pg': '>=4.0.0-beta.100 || >=4.0.0' + drizzle-kit: 1.0.0-rc.4 + drizzle-orm: 1.0.0-rc.4 + effect: '>=4.0.0-beta.100 || >=4.0.0' vite: ^8.0.7 ws: ^8.20.0 peerDependenciesMeta: + '@aws/durable-execution-sdk-js': + optional: true '@effect/platform-bun': optional: true '@effect/platform-node': @@ -3783,6 +3772,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + auto-bind@5.0.1: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3794,6 +3786,10 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.43: resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} engines: {node: '>=6.0.0'} @@ -3875,13 +3871,24 @@ packages: better-result@2.9.2: resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} + engines: {node: '>=16.20.1'} + bun-types@1.3.14: resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} @@ -3924,6 +3931,9 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -4007,6 +4017,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -4155,11 +4169,8 @@ packages: effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} - effect@4.0.0-beta.93: - resolution: {integrity: sha512-wNS5MKFa3C42uBfIDik2oJ78lhpoYz2hN4oBR0229BeeDCIrkg/FiOvoiPGdCVlWa7MEKxEL5I0f8AILVHSD9A==} - - effect@4.0.0-beta.97: - resolution: {integrity: sha512-pK03HpQVxGZOWdwDAy/iwvV8u3KYcUf2mOWyWqaut2zau8V2u6ejWP7b4BELjyUIiZWW1fl/s/VJpgZUcTjThg==} + effect@4.0.0-beta.100: + resolution: {integrity: sha512-K4ed+BS3HyE+NoAZ8pJss2DpuK41mb856IO7ZlPfnwBXbq8oTtAAurrsVnwe2hzPamIuaZp/Pq4/xp9qORYv8Q==} elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} @@ -4210,6 +4221,9 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4275,6 +4289,9 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -4324,6 +4341,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -4351,6 +4372,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.11.4: + resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + engines: {node: '>=16.9.0'} + hono@4.12.31: resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} @@ -4371,6 +4396,13 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -4601,6 +4633,10 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -4613,6 +4649,9 @@ packages: resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} engines: {node: '>=22.13.0'} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -4623,6 +4662,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -4645,6 +4688,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -4681,6 +4727,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -4692,6 +4742,36 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mongodb-connection-string-url@3.0.2: + resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} + + mongodb@6.21.0: + resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.3.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4754,14 +4834,32 @@ packages: sass: optional: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + nodemailer@9.0.3: resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} engines: {node: '>=6.0.0'} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -4817,6 +4915,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4965,6 +5067,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -5036,6 +5142,9 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -5047,6 +5156,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -5100,11 +5213,6 @@ packages: vue-tsc: optional: true - rolldown@1.0.1: - resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5208,6 +5316,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -5244,10 +5355,6 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} - engines: {node: '>=20'} - string-width@8.2.2: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} @@ -5332,6 +5439,13 @@ packages: resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} engines: {node: '>=20'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5576,6 +5690,20 @@ packages: engines: {node: ^20.12||^22.13||>=24.0} hasBin: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5590,8 +5718,8 @@ packages: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} - workerd@1.20260617.1: - resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} + workerd@1.20260704.1: + resolution: {integrity: sha512-GDZ0jzIYDYfN7rCt/oFJv4BG3QJ+4IS2kfRvxEMY9VKIfPhzo63PH69Ir7ug8LfORCgCtmfkiQVXrqbot7pZTQ==} engines: {node: '>=16'} hasBin: true @@ -5893,7 +6021,7 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} - '@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': + '@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5904,39 +6032,43 @@ snapshots: kysely: 0.29.4 nanostores: 1.4.1 zod: 4.4.3 + optionalDependencies: + '@cloudflare/workers-types': 5.20260801.1 - '@better-auth/drizzle-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 - '@better-auth/memory-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 + optionalDependencies: + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1077.0) - '@better-auth/prisma-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': + '@better-auth/prisma-adapter@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) - '@better-auth/telemetry@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -5981,20 +6113,24 @@ snapshots: '@biomejs/cli-win32-x64@2.5.4': optional: true - '@clack/core@0.5.0': + '@chevrotain/cst-dts-gen@10.5.0': dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 - '@clack/core@1.4.3': + '@chevrotain/gast@10.5.0': dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} - '@clack/prompts@0.11.0': + '@chevrotain/utils@10.5.0': {} + + '@clack/core@1.4.3': dependencies: - '@clack/core': 0.5.0 - picocolors: 1.1.1 + fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 '@clack/prompts@1.7.0': @@ -6004,161 +6140,101 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260617.1 + workerd: 1.20260704.1 - '@cloudflare/workerd-darwin-64@1.20260617.1': + '@cloudflare/workerd-darwin-64@1.20260704.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260617.1': + '@cloudflare/workerd-darwin-arm64@1.20260704.1': optional: true - '@cloudflare/workerd-linux-64@1.20260617.1': + '@cloudflare/workerd-linux-64@1.20260704.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260617.1': + '@cloudflare/workerd-linux-arm64@1.20260704.1': optional: true - '@cloudflare/workerd-windows-64@1.20260617.1': + '@cloudflare/workerd-windows-64@1.20260704.1': optional: true - '@distilled.cloud/aws@0.27.0(effect@4.0.0-beta.93)': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/credential-providers': 3.1077.0 - '@aws-sdk/types': 3.974.1 - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - '@smithy/shared-ini-file-loader': 4.6.4 - '@smithy/types': 4.16.1 - '@smithy/util-base64': 4.5.4 - aws4fetch: 1.0.20 - effect: 4.0.0-beta.93 - fast-xml-parser: 5.9.3 + '@cloudflare/workers-types@5.20260801.1': {} - '@distilled.cloud/aws@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/aws@0.30.3(effect@4.0.0-beta.100)': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1077.0 '@aws-sdk/types': 3.974.1 - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) '@smithy/shared-ini-file-loader': 4.6.4 '@smithy/types': 4.16.1 '@smithy/util-base64': 4.5.4 aws4fetch: 1.0.20 - effect: 4.0.0-beta.97 + effect: 4.0.0-beta.100 fast-xml-parser: 5.9.3 - '@distilled.cloud/axiom@0.27.0(effect@4.0.0-beta.93)': + '@distilled.cloud/axiom@0.30.3(effect@4.0.0-beta.100)': dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 - '@distilled.cloud/axiom@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/cloudflare-rolldown-plugin@0.15.0(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 - - '@distilled.cloud/cloudflare-rolldown-plugin@0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1)': - dependencies: - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) magic-string: 0.30.21 unenv: 2.0.0-rc.24 optionalDependencies: - rolldown: 1.0.1 + rolldown: 1.1.5 vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93)': - dependencies: - '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - workerd: 1.20260617.1 - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1) - - '@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)': + '@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)': dependencies: '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 - workerd: 1.20260617.1 + '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 + workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1) - - '@distilled.cloud/cloudflare-vite-plugin@0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93)(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1)': - dependencies: - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1) - transitivePeerDependencies: - - rolldown - - workerd + '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) - '@distilled.cloud/cloudflare-vite-plugin@0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1)': + '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 + '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1) + '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93)': + '@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100)': dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 - '@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/core@0.30.3(effect@4.0.0-beta.100)': dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 + effect: 4.0.0-beta.100 - '@distilled.cloud/core@0.27.0(effect@4.0.0-beta.93)': + '@distilled.cloud/neon@0.30.3(effect@4.0.0-beta.100)': dependencies: - effect: 4.0.0-beta.93 + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 - '@distilled.cloud/core@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/planetscale@0.30.3(effect@4.0.0-beta.100)': dependencies: - effect: 4.0.0-beta.97 - - '@distilled.cloud/neon@0.27.0(effect@4.0.0-beta.93)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - - '@distilled.cloud/neon@0.27.0(effect@4.0.0-beta.97)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 - - '@distilled.cloud/planetscale@0.27.0(effect@4.0.0-beta.93)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - - '@distilled.cloud/planetscale@0.27.0(effect@4.0.0-beta.97)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 '@durable-streams/client@0.2.3': dependencies: @@ -6190,46 +6266,27 @@ snapshots: - msw - vite - '@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93)': - dependencies: - '@effect/platform-node-shared': 4.0.0-beta.99(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97)': - dependencies: - '@effect/platform-node-shared': 4.0.0-beta.99(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - optional: true - - '@effect/platform-node-shared@4.0.0-beta.99(effect@4.0.0-beta.93)': + '@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100)': dependencies: - '@types/ws': 8.18.1 - effect: 4.0.0-beta.93 - ws: 8.21.1 + '@effect/platform-node-shared': 4.0.0-beta.102(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.99(effect@4.0.0-beta.97)': + '@effect/platform-node-shared@4.0.0-beta.102(effect@4.0.0-beta.100)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.97 + effect: 4.0.0-beta.100 ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - optional: true - '@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1)': + '@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.99(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 + '@effect/platform-node-shared': 4.0.0-beta.102(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 ioredis: 5.11.1 mime: 4.1.0 undici: 8.7.0 @@ -6237,43 +6294,35 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1)': + '@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.99(effect@4.0.0-beta.97) - effect: 4.0.0-beta.97 - ioredis: 5.11.1 - mime: 4.1.0 - undici: 8.7.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - optional: true + '@cloudflare/workers-types': 5.20260801.1 + effect: 4.0.0-beta.100 - '@effect/vitest@4.0.0-beta.92(effect@4.0.0-beta.93)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': dependencies: - effect: 4.0.0-beta.93 + effect: 4.0.0-beta.100 vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) - '@effect/vitest@4.0.0-beta.92(effect@4.0.0-beta.97)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: - effect: 4.0.0-beta.97 - vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + '@electric-sql/pglite': 0.3.15 '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 + '@electric-sql/pglite-tools@0.2.20(@electric-sql/pglite@0.3.15)': + dependencies: + '@electric-sql/pglite': 0.3.15 + '@electric-sql/pglite-tools@0.3.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 - '@electric-sql/pglite@0.4.3': {} + '@electric-sql/pglite@0.3.15': {} - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true + '@electric-sql/pglite@0.4.3': {} '@emnapi/core@1.11.1': dependencies: @@ -6287,11 +6336,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 @@ -6302,11 +6346,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -6390,6 +6429,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@img/colour@1.1.0': optional: true @@ -6553,8 +6596,30 @@ snapshots: '@libsql/win32-x64-msvc@0.5.29': optional: true + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.8.5 + tar: 7.5.21 + transitivePeerDependencies: + - encoding + - supports-color + '@microsoft/fetch-event-source@2.0.1': {} + '@mongodb-js/saslprep@1.4.13': + dependencies: + sparse-bitfield: 3.0.3 + + '@mrleebo/prisma-ast@0.13.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -6573,13 +6638,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -6715,8 +6773,6 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-project/types@0.130.0': {} - '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.140.0': {} @@ -7147,6 +7203,28 @@ snapshots: '@prisma/debug@7.9.0': {} + '@prisma/dev@0.20.0(typescript@6.0.3)': + dependencies: + '@electric-sql/pglite': 0.3.15 + '@electric-sql/pglite-socket': 0.0.20(@electric-sql/pglite@0.3.15) + '@electric-sql/pglite-tools': 0.2.20(@electric-sql/pglite@0.3.15) + '@hono/node-server': 1.19.9(hono@4.11.4) + '@mrleebo/prisma-ast': 0.13.1 + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.11.4 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@6.0.3) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + '@prisma/dev@0.24.14(typescript@6.0.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -7427,121 +7505,78 @@ snapshots: dependencies: react: 19.2.8 - '@rolldown/binding-android-arm64@1.0.1': - optional: true - '@rolldown/binding-android-arm64@1.1.5': optional: true '@rolldown/binding-android-arm64@1.2.0': optional: true - '@rolldown/binding-darwin-arm64@1.0.1': - optional: true - '@rolldown/binding-darwin-arm64@1.1.5': optional: true '@rolldown/binding-darwin-arm64@1.2.0': optional: true - '@rolldown/binding-darwin-x64@1.0.1': - optional: true - '@rolldown/binding-darwin-x64@1.1.5': optional: true '@rolldown/binding-darwin-x64@1.2.0': optional: true - '@rolldown/binding-freebsd-x64@1.0.1': - optional: true - '@rolldown/binding-freebsd-x64@1.1.5': optional: true '@rolldown/binding-freebsd-x64@1.2.0': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.2.0': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.1': - optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true '@rolldown/binding-linux-arm64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.1': - optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true '@rolldown/binding-linux-arm64-musl@1.2.0': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.1': - optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true '@rolldown/binding-linux-ppc64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.1': - optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true '@rolldown/binding-linux-s390x-gnu@1.2.0': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.1': - optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true '@rolldown/binding-linux-x64-gnu@1.2.0': optional: true - '@rolldown/binding-linux-x64-musl@1.0.1': - optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': optional: true '@rolldown/binding-linux-x64-musl@1.2.0': optional: true - '@rolldown/binding-openharmony-arm64@1.0.1': - optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': optional: true '@rolldown/binding-openharmony-arm64@1.2.0': optional: true - '@rolldown/binding-wasm32-wasi@1.0.1': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 @@ -7556,18 +7591,12 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.1': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true '@rolldown/binding-win32-arm64-msvc@1.2.0': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.1': - optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true @@ -7576,6 +7605,12 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -7808,6 +7843,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@11.0.5': + dependencies: + '@types/webidl-conversions': 7.0.3 + '@types/ws@8.18.1': dependencies: '@types/node': 26.1.1 @@ -7816,6 +7857,25 @@ snapshots: '@vercel/detect-agent@1.2.3': {} + '@vercel/nft@1.10.2': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.4.0 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.5 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + '@visx/curve@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 @@ -8002,6 +8062,12 @@ snapshots: '@yuku-toolchain/types@0.5.43': {} + abbrev@3.0.1: {} + + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx-walk@2.0.0: {} acorn-jsx@5.3.2(acorn@8.16.0): @@ -8018,6 +8084,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -8025,110 +8093,69 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.93)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1): + alchemy@2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1077.0 - '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/axiom': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93) - '@distilled.cloud/cloudflare-vite-plugin': 0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1))(effect@4.0.0-beta.93)(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/neon': 0.27.0(effect@4.0.0-beta.93) - '@distilled.cloud/planetscale': 0.27.0(effect@4.0.0-beta.93) - '@effect/vitest': 4.0.0-beta.92(effect@4.0.0-beta.93)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) - '@libsql/client': 0.17.4 - '@octokit/rest': 22.0.1 - '@octokit/webhooks': 14.2.0 - '@smithy/node-config-provider': 4.5.4 - '@smithy/shared-ini-file-loader': 4.6.4 - '@smithy/types': 4.16.1 - '@types/aws-lambda': 8.10.162 - aws4fetch: 1.0.20 - capnweb: 0.6.1 - effect: 4.0.0-beta.93 - fast-glob: 3.3.3 - fast-xml-parser: 5.9.3 - ink: 6.8.0(@types/react@19.2.17)(react@19.2.7) - jszip: 3.10.1 - libsodium-wrappers: 0.8.4 - magic-string: 0.30.21 - mysql2: 3.22.5(@types/node@26.1.1) - pathe: 2.0.3 - pg: 8.22.0 - picomatch: 4.0.5 - react: 19.2.7 - rolldown: 1.0.1 - undici: 7.28.0 - yaml: 2.9.0 - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.93)(ioredis@5.11.1) - vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) - ws: 8.21.1 - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - bufferutil - - pg-native - - react-devtools-core - - utf-8-validate - - vitest - - workerd - - alchemy@2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.97)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.1): - dependencies: - '@alchemy.run/node-utils': 0.0.5 - '@aws-sdk/credential-providers': 3.1077.0 - '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/axiom': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/cloudflare': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.11.3(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/cloudflare-runtime': 0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97) - '@distilled.cloud/cloudflare-vite-plugin': 0.11.3(@distilled.cloud/cloudflare-runtime@0.11.3(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97))(@distilled.cloud/cloudflare@0.27.0(effect@4.0.0-beta.97))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.97))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1))(effect@4.0.0-beta.97)(rolldown@1.0.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260617.1) - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/neon': 0.27.0(effect@4.0.0-beta.97) - '@distilled.cloud/planetscale': 0.27.0(effect@4.0.0-beta.97) - '@effect/vitest': 4.0.0-beta.92(effect@4.0.0-beta.97)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) + '@clack/prompts': 1.7.0 + '@distilled.cloud/aws': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/axiom': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100) + '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/neon': 0.30.3(effect@4.0.0-beta.100) + '@distilled.cloud/planetscale': 0.30.3(effect@4.0.0-beta.100) + '@effect/sql-d1': 4.0.0-beta.102(effect@4.0.0-beta.100) + '@effect/vitest': 4.0.0-beta.102(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) '@libsql/client': 0.17.4 '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 + '@prisma/dev': 0.20.0(typescript@6.0.3) '@smithy/node-config-provider': 4.5.4 '@smithy/shared-ini-file-loader': 4.6.4 '@smithy/types': 4.16.1 '@types/aws-lambda': 8.10.162 + '@vercel/nft': 1.10.2 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.97 + effect: 4.0.0-beta.100 fast-glob: 3.3.3 fast-xml-parser: 5.9.3 - ink: 6.8.0(@types/react@19.2.17)(react@19.2.7) + ink: 6.8.0(@types/react@19.2.17)(react@19.2.8) jszip: 3.10.1 libsodium-wrappers: 0.8.4 - magic-string: 0.30.21 + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1077.0) mysql2: 3.22.5(@types/node@26.1.1) pathe: 2.0.3 pg: 8.22.0 picomatch: 4.0.5 - react: 19.2.7 - rolldown: 1.0.1 + react: 19.2.8 + rolldown: 1.1.5 undici: 7.28.0 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) - '@effect/platform-node': 4.0.0-beta.92(effect@4.0.0-beta.97)(ioredis@5.11.1) + '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) ws: 8.21.1 transitivePeerDependencies: + - '@mongodb-js/zstd' - '@types/node' - '@types/react' - bufferutil + - encoding + - gcp-metadata + - kerberos + - mongodb-client-encryption - pg-native - react-devtools-core + - rollup + - snappy + - socks + - supports-color + - typescript - utf-8-validate - vitest - workerd @@ -8163,25 +8190,29 @@ snapshots: assertion-error@2.0.1: {} + async-sema@3.1.1: {} + auto-bind@5.0.1: {} aws-ssl-profiles@1.1.2: {} aws4fetch@1.0.20: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.43: {} before-after-hook@4.0.0: {} - better-auth@1.6.24(mysql2@3.22.5(@types/node@26.1.1))(next@16.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))): + better-auth@1.6.24(@cloudflare/workers-types@5.20260801.1)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(mysql2@3.22.5(@types/node@26.1.1))(next@16.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))): dependencies: - '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) - '@better-auth/telemetry': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0)) + '@better-auth/prisma-adapter': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3)) + '@better-auth/telemetry': 1.6.24(@better-auth/core@1.6.24(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260801.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -8193,6 +8224,7 @@ snapshots: nanostores: 1.4.1 zod: 4.4.3 optionalDependencies: + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1077.0) mysql2: 3.22.5(@types/node@26.1.1) next: 16.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) pg: 8.22.0 @@ -8215,12 +8247,22 @@ snapshots: better-result@2.9.2: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bowser@2.14.1: {} + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 + bson@6.10.4: {} + bun-types@1.3.14: dependencies: '@types/node': 26.1.1 @@ -8261,6 +8303,15 @@ snapshots: character-entities-legacy@3.0.0: {} + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -8324,6 +8375,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} @@ -8462,20 +8515,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - effect@4.0.0-beta.93: - dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 4.9.0 - find-my-way-ts: 0.1.6 - ini: 7.0.0 - kubernetes-types: 1.30.0 - msgpackr: 2.0.4 - multipasta: 0.2.8 - toml: 4.3.0 - uuid: 14.0.1 - yaml: 2.9.0 - - effect@4.0.0-beta.97: + effect@4.0.0-beta.100: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 @@ -8544,6 +8584,8 @@ snapshots: escape-string-regexp@2.0.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -8612,6 +8654,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -8654,6 +8698,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -8688,6 +8738,8 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.11.4: {} + hono@4.12.31: {} hookable@6.1.1: {} @@ -8711,6 +8763,15 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-status-codes@2.3.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + husky@9.1.7: {} iconv-lite@0.7.2: @@ -8731,7 +8792,7 @@ snapshots: ini@7.0.0: {} - ink@6.8.0(@types/react@19.2.17)(react@19.2.7): + ink@6.8.0(@types/react@19.2.17)(react@19.2.8): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 ansi-escapes: 7.3.0 @@ -8746,13 +8807,13 @@ snapshots: indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 - react: 19.2.7 - react-reconciler: 0.33.0(react@19.2.7) + react: 19.2.8 + react-reconciler: 0.33.0(react@19.2.8) scheduler: 0.27.0 signal-exit: 3.0.7 slice-ansi: 8.0.0 stack-utils: 2.0.6 - string-width: 8.2.1 + string-width: 8.2.2 terminal-size: 4.0.1 type-fest: 5.7.0 widest-line: 6.0.0 @@ -8917,6 +8978,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lilconfig@2.1.0: {} + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -8938,6 +9001,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + lodash@4.17.21: {} + lodash@4.18.1: {} log-update@6.1.0: @@ -8950,6 +9015,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lru.min@1.1.4: {} magic-string@0.30.21: @@ -8981,6 +9048,8 @@ snapshots: mdurl@2.0.0: {} + memory-pager@1.5.0: {} + merge2@1.4.1: {} micromark-util-character@2.1.1: @@ -9011,6 +9080,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -9019,6 +9092,19 @@ snapshots: dependencies: minipass: 7.1.3 + mongodb-connection-string-url@3.0.2: + dependencies: + '@types/whatwg-url': 11.0.5 + whatwg-url: 14.2.0 + + mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0): + dependencies: + '@mongodb-js/saslprep': 1.4.13 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 + optionalDependencies: + '@aws-sdk/credential-providers': 3.1077.0 + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -9122,13 +9208,23 @@ snapshots: - babel-plugin-macros optional: true + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 optional: true + node-gyp-build@4.8.4: {} + nodemailer@9.0.3: {} + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + obug@2.1.3: {} obug@2.1.4: {} @@ -9174,6 +9270,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} peberminta@0.9.0: {} @@ -9303,6 +9404,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + pure-rand@6.1.0: {} pure-rand@8.4.2: {} @@ -9326,9 +9429,9 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-reconciler@0.33.0(react@19.2.7): + react-reconciler@0.33.0(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 scheduler: 0.27.0 react@19.2.7: {} @@ -9369,12 +9472,16 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regexp-to-ast@0.5.0: {} + regexp-tree@0.1.27: {} remeda@2.33.4: {} require-from-string@2.0.2: {} + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.12: @@ -9418,27 +9525,6 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.0.1: - dependencies: - '@oxc-project/types': 0.130.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.1 - '@rolldown/binding-darwin-arm64': 1.0.1 - '@rolldown/binding-darwin-x64': 1.0.1 - '@rolldown/binding-freebsd-x64': 1.0.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 - '@rolldown/binding-linux-arm64-gnu': 1.0.1 - '@rolldown/binding-linux-arm64-musl': 1.0.1 - '@rolldown/binding-linux-ppc64-gnu': 1.0.1 - '@rolldown/binding-linux-s390x-gnu': 1.0.1 - '@rolldown/binding-linux-x64-gnu': 1.0.1 - '@rolldown/binding-linux-x64-musl': 1.0.1 - '@rolldown/binding-openharmony-arm64': 1.0.1 - '@rolldown/binding-wasm32-wasi': 1.0.1 - '@rolldown/binding-win32-arm64-msvc': 1.0.1 - '@rolldown/binding-win32-x64-msvc': 1.0.1 - rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -9507,8 +9593,7 @@ snapshots: semver@7.8.1: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} seq-queue@0.0.5: {} @@ -9591,6 +9676,10 @@ snapshots: space-separated-tokens@2.0.2: {} + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + split2@4.2.0: {} sql-escaper@1.3.3: {} @@ -9617,11 +9706,6 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.1: - dependencies: - get-east-asian-width: 1.6.0 - strip-ansi: 7.2.0 - string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 @@ -9696,6 +9780,12 @@ snapshots: toml@4.3.0: {} + tr46@0.0.3: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -9883,6 +9973,20 @@ snapshots: watskeburt@5.0.3: {} + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -9896,13 +10000,13 @@ snapshots: dependencies: string-width: 8.2.2 - workerd@1.20260617.1: + workerd@1.20260704.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260617.1 - '@cloudflare/workerd-darwin-arm64': 1.20260617.1 - '@cloudflare/workerd-linux-64': 1.20260617.1 - '@cloudflare/workerd-linux-arm64': 1.20260617.1 - '@cloudflare/workerd-windows-64': 1.20260617.1 + '@cloudflare/workerd-darwin-64': 1.20260704.1 + '@cloudflare/workerd-darwin-arm64': 1.20260704.1 + '@cloudflare/workerd-linux-64': 1.20260704.1 + '@cloudflare/workerd-linux-arm64': 1.20260704.1 + '@cloudflare/workerd-windows-64': 1.20260704.1 wrap-ansi@10.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0dd24991..0dd6d3a6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,6 @@ packages: - examples/*/modules/* - test/** - website + +patchedDependencies: + "alchemy@2.0.0-beta.67": patches/alchemy@2.0.0-beta.67.patch diff --git a/test/integration/package.json b/test/integration/package.json index 9bd057ac..1957b065 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -14,7 +14,7 @@ "@prisma/example-store": "workspace:0.3.0", "@types/bun": "^1.3.13", "@types/node": "^26.0.1", - "alchemy": "2.0.0-beta.59", + "alchemy": "2.0.0-beta.67", "prisma": "7.9.0", "typescript": "^6.0.3" } diff --git a/website/package.json b/website/package.json index a04776c8..6d686b1d 100644 --- a/website/package.json +++ b/website/package.json @@ -16,7 +16,7 @@ "dependencies": { "@prisma/composer": "workspace:0.3.0", "@prisma/composer-prisma-cloud": "workspace:0.3.0", - "alchemy": "2.0.0-beta.59" + "alchemy": "2.0.0-beta.67" }, "devDependencies": { "@prisma/composer": "workspace:0.3.0", From 6f3673fa1b4f16d61a52c962274f11f75fa6d991 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 17:57:31 +0200 Subject: [PATCH 03/22] feat(lowering): adopt upstream alchemy/Prisma postgres resources Delete Composer's Project/Database/Connection resources; register upstream's classes in the PrismaComposer collection over a profile-store-free PrismaEnvironment (PRISMA_SERVICE_TOKEN + shared managementApiBaseUrl resolver honored identically by our SDK client). Descriptors bind directConnectionString explicitly; branch stages create with branchId and a generated physical name (upstream's reconcile reverts external attach/rename, so create-then-PATCH is not viable); production keeps explicit names. Legacy state rows migrate on read in the hosted store (type-ids + attribute shapes), proven against upstream's real diff/read/reconcile incl. a no-rotation guard; the one-time branch-stage rename + default-connection rotation is documented in docs/guides/deploying.md. Local-target providers rebind the emulators to upstream's classes. Part of alchemy-provider-adoption (TML-3154). Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/10-domains/core-model.md | 8 +- docs/guides/deploying.md | 24 ++ docs/guides/running-locally.md | 7 + .../postgres-instance-name-drift.test.ts | 104 +++-- .../0-lowering/local-target/src/compute.ts | 25 +- .../0-lowering/local-target/src/postgres.ts | 120 ++++-- .../0-lowering/local-target/src/providers.ts | 2 +- .../0-lowering/lowering/package.json | 2 +- .../lowering/src/__tests__/Database.test.ts | 179 --------- .../0-lowering/lowering/src/client.ts | 15 +- .../0-lowering/lowering/src/credentials.ts | 56 +++ .../0-lowering/lowering/src/exports/index.ts | 7 +- .../lowering/src/exports/postgres.ts | 3 - .../lowering/src/postgres/Connection.ts | 81 ---- .../lowering/src/postgres/Database.ts | 104 ----- .../lowering/src/postgres/Project.ts | 70 ---- .../0-lowering/lowering/src/providers.ts | 64 ++- .../state/__tests__/legacy-postgres.test.ts | 365 ++++++++++++++++++ .../lowering/src/state/legacy-postgres.ts | 156 ++++++++ .../0-lowering/lowering/src/state/service.ts | 9 +- .../0-lowering/lowering/tsdown.config.ts | 1 - .../src/__tests__/control-lowering.test.ts | 56 ++- .../target/src/descriptors/postgres.ts | 30 +- .../target/src/descriptors/prisma-next.ts | 26 +- pnpm-lock.yaml | 3 + 25 files changed, 936 insertions(+), 581 deletions(-) delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index cedfbf51..f30e506e 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -1038,10 +1038,10 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor postgres: Object.assign( ({ id, application }) => Effect.gen(function* () { - const db = yield* Prisma.Database(`${id}-db`, { projectId: projectIdOf(application), name: id }) - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }) - const warm = yield* Prisma.PgWarm(`${id}-warm`, { url: conn.connectionString }) // FT-5226 cold-start - return { outputs: { url: warm.url }, entities: [{ kind: "postgres-database", id: db.id }] } + const db = yield* Prisma.Database(`${id}-db`, { project: projectIdOf(application), name: id, region }) + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }) + const warm = yield* Prisma.PgWarm(`${id}-warm`, { url: conn.directConnectionString }) // FT-5226 cold-start + return { outputs: { url: warm.url }, entities: [{ kind: "postgres-database", id: db.databaseId }] } }), { kind: "resource" as const }, ), diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index eb5451fa..a6cfb62c 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -257,6 +257,30 @@ next deploy recreates everything under fresh state — either: Recreated apps get new generated URLs; anything pointing at the old ones needs updating. +## Upgrading to the upstream postgres resources + +Framework versions that manage databases through upstream alchemy's Prisma +provider adopt each environment's existing databases in place — deploy state +is migrated automatically on read, and production environments redeploy with +no changes to their databases or connections. + +**Stage (`--stage`) environments see two one-time effects on their first +deploy after the upgrade**, because a branch-attached database can no longer +carry an explicit display name at create: + +- Each existing stage database is **renamed** to a generated physical name + (`--db--`). The database itself, its data, + and its ID are untouched — only the display name in the Console changes. +- The database's **default connection credentials are rotated** during that + same reconcile. The framework's own named connection — the one your + services actually use — is NOT rotated and keeps working. Only credentials + minted outside the framework from the database's *default* connection (for + example, copied out of the Console) stop working and must be re-issued. + +Local dev state is not migrated: if `prisma-composer dev` fails at plan time +with `No provider is registered for resource type 'PrismaComposer.…'`, run it +once with `--fresh` to clear the stale local state. + ## The full picture [`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 651576aa..11b40629 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -46,6 +46,13 @@ buckets, and their data stay up, so the next `prisma-composer dev` is a warm start — same ports, same data. `--fresh` is what wipes this app's local instances and data before starting. +`--fresh` is also the fix when a framework upgrade leaves stale rows in this +app's local dev state — the symptom is a plan-time error naming an +unregistered resource type (for example +`No provider is registered for resource type 'PrismaComposer.Database'`). +Local dev state is never migrated across framework versions; wiping it is +always safe because everything local is rebuilt on the next start. + ## Logs ```sh diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts index aea6df54..d90d8f84 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/postgres-instance-name-drift.test.ts @@ -9,7 +9,7 @@ import { instanceNameFor, postgresClient, } from '@internal/dev-emulators'; -import { Connection, Database } from '@internal/lowering/postgres'; +import { Connection, Database } from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; @@ -29,6 +29,10 @@ import { LocalConnectionProvider, LocalDatabaseProvider } from '../postgres.ts'; * longer drift. This test proves it end to end, against the real daemon, * for exactly the pathological shape that used to fail: an app name AND a * database id each ending in a hyphen. + * + * The providers back upstream alchemy's `Prisma.Database` / + * `Prisma.Connection` classes, so the attribute names asserted here are the + * upstream ones (`databaseId`, `connectionId`, `directConnectionString`). */ const APP = 'pgdrifttestapp-'; @@ -54,6 +58,18 @@ function fakeContainer(appName: string): ContainerInstance { return { input: { appName, stage: undefined }, serialize: () => 'x' }; } +const reconcileInput = (id: string, news: Record) => + ({ + id, + fqn: id, + instanceId: id, + news, + olds: undefined, + output: undefined, + session: undefined as never, + bindings: [], + }) as never; + // The default, machine-global daemon (the SAME one `postgresClient()` inside // the providers under test talks to) — this test's whole point is proving // the providers agree with the REAL daemon, so it must run against the one @@ -85,26 +101,19 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseService = await Effect.runPromise( Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - fqn: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DATABASE_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const databaseAttributes: Database['Attributes'] = await Effect.runPromise( + databaseService.reconcile( + reconcileInput('db', { project: 'p', name: DATABASE_ID, region: 'us-east-1' }), + ), ); // The provider-derived id is exactly the daemon's own derivation — no // second implementation to drift from it. - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DATABASE_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-orders'); + expect(databaseAttributes.databaseId).toBe(instanceNameFor(APP, DATABASE_ID)); + expect(databaseAttributes.databaseId).toBe('pcdev-pgdrifttestapp-orders'); // Proves the trim/collapse actually happened — the pre-fix drift left a // doubled dash at the "pgdrifttestapp-" + "-" + "orders-" boundary. - expect(databaseAttributes.id.includes('--')).toBe(false); + expect(databaseAttributes.databaseId.includes('--')).toBe(false); // 2. Connection-resolve through the listing (LocalConnectionProvider's // own reconcile) — before the fix, this threw noRecordedInstanceError @@ -112,26 +121,21 @@ describe('instance-name drift (delta review finding A, #160)', () => { const connectionService = await Effect.runPromise( Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - fqn: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const connectionAttributes: Connection['Attributes'] = await Effect.runPromise( + connectionService.reconcile( + reconcileInput('conn', { database: databaseAttributes, name: 'conn' }), + ), ); - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + expect(connectionAttributes.databaseId).toBe(databaseAttributes.databaseId); + const direct = connectionAttributes.directConnectionString; + if (direct === undefined) throw new Error('expected a direct connection string'); + expect(Redacted.value(direct)).toMatch(/^postgres:\/\//); // 3. The daemon's own listing agrees on the same name too — the third // independent read of the same value. const listed = await postgresClient().listDatabases(APP); - const entry = listed.find((d) => d.instanceName === databaseAttributes.id); + const entry = listed.find((d) => d.instanceName === databaseAttributes.databaseId); expect(entry).toBeDefined(); }), 30_000, @@ -157,45 +161,33 @@ describe('instance-name drift (delta review finding A, #160)', () => { const databaseService = await Effect.runPromise( Database.Provider.pipe(Effect.provide(LocalDatabaseProvider(input))), ); - const databaseAttributes = await Effect.runPromise( - databaseService.reconcile({ - id: 'db', - fqn: 'db', - instanceId: 'db', - news: { projectId: 'p', name: DOTTED_ID, region: 'us-east-1' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const databaseAttributes: Database['Attributes'] = await Effect.runPromise( + databaseService.reconcile( + reconcileInput('db', { project: 'p', name: DOTTED_ID, region: 'us-east-1' }), + ), ); // slug() is idempotent, so the daemon's instanceNameFor(app, slug(name)) // equals the provider-recorded instanceNameFor(app, name). - expect(databaseAttributes.id).toBe(instanceNameFor(APP, DOTTED_ID)); - expect(databaseAttributes.id).toBe('pcdev-pgdrifttestapp-catalog-database'); + expect(databaseAttributes.databaseId).toBe(instanceNameFor(APP, DOTTED_ID)); + expect(databaseAttributes.databaseId).toBe('pcdev-pgdrifttestapp-catalog-database'); const connectionService = await Effect.runPromise( Connection.Provider.pipe(Effect.provide(LocalConnectionProvider(input))), ); - const connectionAttributes = await Effect.runPromise( - connectionService.reconcile({ - id: 'conn', - fqn: 'conn', - instanceId: 'conn', - news: { databaseId: databaseAttributes.id, name: 'conn' }, - olds: undefined, - output: undefined, - session: undefined as never, - bindings: [], - }), + const connectionAttributes: Connection['Attributes'] = await Effect.runPromise( + connectionService.reconcile( + reconcileInput('conn', { database: databaseAttributes.databaseId, name: 'conn' }), + ), ); - expect(connectionAttributes.id).toBe(databaseAttributes.id); - expect(Redacted.value(connectionAttributes.connectionString)).toMatch(/^postgres:\/\//); + expect(connectionAttributes.databaseId).toBe(databaseAttributes.databaseId); + const direct = connectionAttributes.directConnectionString; + if (direct === undefined) throw new Error('expected a direct connection string'); + expect(Redacted.value(direct)).toMatch(/^postgres:\/\//); const listed = await postgresClient().listDatabases(APP); - expect(listed.find((d) => d.instanceName === databaseAttributes.id)).toBeDefined(); + expect(listed.find((d) => d.instanceName === databaseAttributes.databaseId)).toBeDefined(); }), 30_000, ); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts index 6bf2bdb4..445da9de 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -17,7 +17,7 @@ import { type DeploymentAttributes, EnvironmentVariable, } from '@internal/lowering/compute'; -import { Project } from '@internal/lowering/postgres'; +import { Project } from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; @@ -257,16 +257,31 @@ export function LocalDeploymentProvider( } /** - * `Project` — identity only; present so the provider collection stays total. - * No lowering yields a `Project` resource today (mirrors the hosted - * `Project` provider, which is also never exercised — see postgres.ts). + * `Prisma.Project` — identity only; present so the provider collection stays + * total. No lowering yields a `Project` resource today (mirrors the hosted + * wiring, where the container resolves the project pre-alchemy). */ export function LocalProjectProvider( _input: LocalTargetProvidersInput, ): Layer.Layer> { const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => Effect.succeed({ id: 'local', name: news.name }), + reconcile: ({ id, news }) => + Effect.succeed({ + projectId: 'local', + projectName: news.name ?? id, + workspaceId: 'local', + createdAt: '1970-01-01T00:00:00.000Z', + defaultRegion: null, + databaseId: undefined, + defaultConnectionId: undefined, + directConnectionString: undefined, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + } satisfies Project['Attributes']), delete: () => Effect.void, }; return Provider.effect(Project, Effect.succeed(service)); diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts index 757346c2..3eb6ff33 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts @@ -1,12 +1,20 @@ /** * Local postgres-cluster providers (local-dev spec § 4, REVISED — operator - * review of #162): `Database` and `Connection` become clients of the - * `postgres-main` emulator daemon, which hosts `@prisma/dev`'s programmatic - * `startPrismaDevServer` — one named, persistent server per `Database` - * resource. The CLI shell-out is gone: no bin walk-up, no stdout URL - * parsing, no `prisma dev stop/rm` glob teardown. `PgWarm` and - * `PnMigration` are NOT here; the hosted ones run unchanged against - * whichever URL they are handed. + * review of #162): upstream alchemy's `Prisma.Database` and + * `Prisma.Connection` become clients of the `postgres-main` emulator daemon, + * which hosts `@prisma/dev`'s programmatic `startPrismaDevServer` — one + * named, persistent server per `Database` resource. The CLI shell-out is + * gone: no bin walk-up, no stdout URL parsing, no `prisma dev stop/rm` glob + * teardown. `PgWarm` and `PnMigration` are NOT here; the hosted ones run + * unchanged against whichever URL they are handed. + * + * The emitted attributes match upstream's shapes ({@link Prisma.Database} + * `{databaseId, …}`, {@link Prisma.Connection} `{connectionId, …}` with + * Redacted secrets). The daemon returns a DIRECT connection string; it maps + * to `directConnectionString` (and `databaseUrl`, since direct is all local + * dev has). Pooled/accelerate strings, host/user/password, and the parsed + * origins are left `undefined` — upstream's attribute types allow it and + * nothing local consumes them. * * Instance-name derivation is NOT duplicated here (delta review finding A, * #160): a locally re-derived slug drifted from the daemon's own @@ -23,13 +31,33 @@ import { createRequire } from 'node:module'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; import { instanceNameFor, postgresClient, slug } from '@internal/dev-emulators'; -import { Connection, Database } from '@internal/lowering/postgres'; +import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; +const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ +function projectIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['projectId'] === 'string') return value['projectId']; + return 'local'; +} + +/** Reads a database id from upstream's `database` input: a plain string or a resolved `Prisma.Database` attributes record. */ +function databaseIdOfInput(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['databaseId'] === 'string') return value['databaseId']; + return undefined; +} + function noPrismaDevError(): Error { return new Error( 'local dev needs @prisma/dev for its local Postgres emulator — add "prisma" to your app\'s devDependencies.', @@ -62,18 +90,23 @@ export function resolvePrismaDevModulePath(cwd: string): string { } /** - * `Database` → an ensured `postgres-main` server, one per resource. Stores - * the daemon's returned `url` on its own attributes. + * `Prisma.Database` → an ensured `postgres-main` server, one per resource. + * Stores the daemon's returned url as the `directConnectionString` attribute. */ export function LocalDatabaseProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); + // Hosted branch-stage deploys omit the display name (see + // descriptors/postgres.ts); local dev never has a branch, so + // `news.name` is normally present — the resource's logical id is + // only a defensive fallback. + const name = news.name ?? id; const prismaDevModulePath = resolvePrismaDevModulePath(process.cwd()); // The daemon's `` path segment must match // /^[a-z0-9][a-z0-9-]*$/ (spec § 2's API hygiene rule) — but a @@ -86,17 +119,33 @@ export function LocalDatabaseProvider( // below record and `Connection` looks up. const { url } = await postgresClient().ensureDatabase( app, - slug(news.name), + slug(name), prismaDevModulePath, ); - const attributes = { id: instanceNameFor(app, news.name), name: news.name, url }; - return attributes; + const direct = Redacted.make(url); + return { + databaseId: instanceNameFor(app, name), + databaseName: name, + projectId: projectIdOfInput(news.project), + status: 'ready', + region: news.region ?? 'us-east-1', + isDefault: false, + branchId: null, + defaultConnectionId: null, + createdAt: DEV_TIMESTAMP, + directConnectionString: direct, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + } satisfies Prisma.Database['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, }; - return Provider.effect(Database, Effect.succeed(service)); + return Provider.effect(Prisma.Database, Effect.succeed(service)); } function noRecordedInstanceError(databaseId: string): Error { @@ -106,24 +155,45 @@ function noRecordedInstanceError(databaseId: string): Error { ); } -/** `Connection` → the daemon's live listing, matched by instance name (the Database attributes' `id` IS the instance name). */ +/** `Prisma.Connection` → the daemon's live listing, matched by instance name (the Database attributes' `databaseId` IS the instance name). */ export function LocalConnectionProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { const app = appNameOf(input.container); + const databaseId = databaseIdOfInput(news.database); + if (databaseId === undefined) throw noRecordedInstanceError(String(news.database)); const databases = await postgresClient().listDatabases(app); - const found = databases.find((entry) => entry.instanceName === news.databaseId); - if (found === undefined) throw noRecordedInstanceError(news.databaseId); - return { id: found.instanceName, connectionString: Redacted.make(found.url) }; + const found = databases.find((entry) => entry.instanceName === databaseId); + if (found === undefined) throw noRecordedInstanceError(databaseId); + const direct = Redacted.make(found.url); + return { + connectionId: found.instanceName, + connectionName: news.name ?? id, + databaseId: found.instanceName, + kind: 'postgres', + createdAt: DEV_TIMESTAMP, + directConnectionString: direct, + pooledConnectionString: undefined, + accelerateConnectionString: undefined, + host: undefined, + user: undefined, + password: undefined, + // Local dev has only the direct endpoint, so it is also the + // conventional application URL. The parsed origins stay unset; + // nothing local consumes them. + databaseUrl: direct, + origin: undefined, + pooledOrigin: undefined, + } satisfies Prisma.Connection['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, }; - return Provider.effect(Connection, Effect.succeed(service)); + return Provider.effect(Prisma.Connection, Effect.succeed(service)); } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts index 9db09ab0..cf694ffa 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts @@ -9,7 +9,7 @@ import type { LocalTargetProvidersInput } from '@internal/core/config'; import { Providers } from '@internal/lowering'; import { Bucket, BucketKey } from '@internal/lowering/buckets'; import { ComputeService, Deployment, EnvironmentVariable } from '@internal/lowering/compute'; -import { Connection, Database, Project } from '@internal/lowering/postgres'; +import { Connection, Database, Project } from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Layer from 'effect/Layer'; import { LocalBucketKeyProvider, LocalBucketProvider } from './bucket.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index b124c083..5c0e38f7 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -6,7 +6,6 @@ ".": "./dist/index.mjs", "./buckets": "./dist/buckets.mjs", "./compute": "./dist/compute.mjs", - "./postgres": "./dist/postgres.mjs", "./state": "./dist/state.mjs", "./package.json": "./package.json" }, @@ -17,6 +16,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@effect/platform-node": "4.0.0-beta.100", "@internal/core": "workspace:0.3.0", "@internal/foundation": "workspace:0.3.0", "@prisma/management-api-sdk": "^1.50.0", diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts deleted file mode 100644 index 0be9b463..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { Database, DatabaseProvider } from '../postgres/Database.ts'; - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** When set, GET /v1/databases/{databaseId} resolves to this — the observed path. */ - observed?: { id: string; name: string }; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering only the Database provider's - * endpoints (GET/POST for observe-or-create, PATCH for Branch attachment), - * recording every call it receives — the container.test.ts fake-client - * idiom. `as unknown as ManagementApiClient` is acceptable here (test file - * — exempt from the no-bare-cast rule). - * - * The project-scoped create route is absent on purpose — it can't carry a - * branchId, so reaching for it throws instead of silently passing. - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string) => { - state.calls.push({ method: 'GET', path }); - if (path === '/v1/databases/{databaseId}') { - return Promise.resolve( - state.observed ? okResponse({ data: state.observed }) : notFoundResponse(), - ); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - if (path === '/v1/databases') { - return Promise.resolve( - okResponse({ data: { id: 'db-created', name: String(init.body?.['name']) } }, 201), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - if (path === '/v1/databases/{databaseId}') { - return Promise.resolve(okResponse({ data: { id: 'db-created', name: 'db' } })); - } - throw new Error(`fakeClient: unexpected PATCH ${path}`); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - Database.Provider.pipe( - Effect.provide(DatabaseProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { news: Record; output?: { id: string; name: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -describe('Database reconcile — Branch attachment', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [] }; - }); - - test('branchId set, no prior output: names the Branch in the create, and issues no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls).toEqual([ - { - method: 'POST', - path: '/v1/databases', - body: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - }, - ]); - }); - - test('isDefault set: rides the same create body', async () => { - await reconcile(state, { - news: { - projectId: 'proj-1', - name: 'db', - region: 'us-east-1', - branchId: 'br-1', - isDefault: true, - }, - output: undefined, - }); - - expect(state.calls[0]?.body).toEqual({ - projectId: 'proj-1', - name: 'db', - region: 'us-east-1', - isDefault: true, - branchId: 'br-1', - }); - }); - - // The PATCH survives here only: nothing was created, so nothing can be - // stranded, and it's what moves a drifted database back onto its Branch. - test('branchId set, prior output exists: observes, and still PATCHes (idempotent/self-healing)', async () => { - state.observed = { id: 'db-existing', name: 'db' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1', branchId: 'br-1' }, - output: { id: 'db-existing', name: 'db' }, - }); - - expect(result).toEqual({ id: 'db-existing', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls[1]).toEqual({ - method: 'PATCH', - path: '/v1/databases/{databaseId}', - body: { branchId: 'br-1' }, - }); - }); - - test('branchId unset, no prior output: creates without one and issues no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'db-created', name: 'db' }); - expect(state.calls).toEqual([ - { - method: 'POST', - path: '/v1/databases', - body: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - }, - ]); - }); - - test('branchId unset, prior output exists: observes and issues no PATCH', async () => { - state.observed = { id: 'db-existing', name: 'db' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'db', region: 'us-east-1' }, - output: { id: 'db-existing', name: 'db' }, - }); - - expect(result).toEqual({ id: 'db-existing', name: 'db' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); 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 ec3a54ef..7435b61f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -1,9 +1,10 @@ import { createManagementApiClient } from '@prisma/management-api-sdk'; +import type * 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 { PrismaCredentials } from './credentials.ts'; +import { managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -16,11 +17,19 @@ export class ManagementClient extends Context.Service => +export const layer = (): Layer.Layer< + ManagementClient, + Config.ConfigError | Error, + PrismaCredentials +> => Layer.effect( ManagementClient, Effect.gen(function* () { const { token } = yield* PrismaCredentials; - return createManagementApiClient({ token: Redacted.value(token) }); + // The same origin the upstream postgres providers use (see + // providers.ts) — one resolver, so PRISMA_API_URL can never split the + // postgres family and the compute/bucket/state clients across hosts. + const baseUrl = yield* managementApiBaseUrl(); + return createManagementApiClient({ token: Redacted.value(token), baseUrl }); }), ); 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 442ae532..75544c62 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts @@ -22,3 +22,59 @@ export const fromEnv = (): Layer.Layer => return { token }; }), ); + +const DEFAULT_BASE_URL = 'https://api.prisma.io'; + +const isLoopbackHost = (hostname: string) => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +/** Same validation as upstream alchemy's `PrismaEnvironment`: an HTTP(S) origin, HTTPS unless loopback, no credentials, no path/query/fragment. */ +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; + }, + catch: (cause) => + cause instanceof Error + ? cause + : new Error(`Invalid Prisma Management API URL: ${String(cause)}`), + }); + +/** + * 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. + */ +export const managementApiBaseUrl = (): Effect.Effect => + Config.string('PRISMA_API_URL').pipe( + Config.orElse(() => Config.string('PRISMA_MANAGEMENT_API_URL')), + Config.withDefault(DEFAULT_BASE_URL), + Effect.flatMap(normalizeBaseUrl), + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 91ada535..f4a5f198 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -1,8 +1,10 @@ /** * `@internal/lowering`'s public surface: the Prisma resource providers plus the * Management API client, container, and credential helpers. Implementation - * lives in `../providers.ts` and the modules it re-exports; the compute, - * postgres, and bucket surfaces are their own entrypoints. + * lives in `../providers.ts` and the modules it re-exports; the compute and + * bucket surfaces are their own entrypoints. The postgres family (Project, + * Database, Connection) is upstream alchemy's — consumers import it via + * `import * as Prisma from 'alchemy/Prisma'`. */ export { layer as managementClientLayer, @@ -14,4 +16,3 @@ export * from '../credentials.ts'; export * from '../providers.ts'; export * from './buckets.ts'; export * from './compute.ts'; -export * from './postgres.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts deleted file mode 100644 index 888159e5..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/postgres.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from '../postgres/Connection.ts'; -export * from '../postgres/Database.ts'; -export * from '../postgres/Project.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts deleted file mode 100644 index dc079276..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import { ManagementClient } from '../client.ts'; -import { call, callVoid, PrismaApiError } from '../http.ts'; - -export interface ConnectionProps { - /** The database this connection targets. */ - databaseId: string; - name: string; -} - -export interface ConnectionAttributes { - id: string; - /** - * The Postgres connection string. Returned only at creation and never - * echoed back, so it is captured here (Redacted) and persisted in state. - */ - connectionString: Redacted.Redacted; -} - -export type Connection = Resource< - 'PrismaComposer.Connection', - ConnectionProps, - ConnectionAttributes ->; - -/** A **connection** to a Prisma Postgres database — yields the connection string. */ -export const Connection = Resource('PrismaComposer.Connection', { - aliases: ['Prisma.Connection'], -}); - -export const ConnectionProvider = () => - Provider.effect( - Connection, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id', 'connectionString'], - list: () => Effect.succeed([] as ConnectionAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // The secret is only returned at creation; cached state is authoritative. - if (output?.id) return output; - - const created = yield* call(() => - client.POST('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId: news.databaseId } }, - body: { name: news.name }, - }), - ); - // `data.url` is the API self-link, NOT a Postgres DSN. The real - // connection strings live under endpoints.{direct,pooled}; the - // top-level `connectionString` is deprecated. Prefer the direct - // endpoint, fall back to pooled. - const endpoints = created.data.endpoints; - const dsn = endpoints?.direct?.connectionString ?? endpoints?.pooled?.connectionString; - if (dsn === undefined) { - return yield* Effect.fail( - new PrismaApiError({ - status: 0, - message: `connection ${created.data.id} returned no direct/pooled connection string`, - }), - ); - } - return { - id: created.data.id, - connectionString: Redacted.make(dsn), - }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/connections/{id}', { - params: { path: { id: output.id } }, - }), - ); - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts deleted file mode 100644 index bce46da3..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export type Region = - | 'us-east-1' - | 'us-west-1' - | 'eu-west-3' - | 'eu-central-1' - | 'ap-northeast-1' - | 'ap-southeast-1'; - -export interface DatabaseProps { - /** The project that will own this database. */ - projectId: string; - name: string; - region: Region; - isDefault?: boolean; - /** When set, the Branch this database is attached to (named-stage deploys). */ - branchId?: string; -} - -export interface DatabaseAttributes { - id: string; - name: string; -} - -export type Database = Resource<'PrismaComposer.Database', DatabaseProps, DatabaseAttributes>; - -/** A Prisma **Postgres database** inside a project. */ -export const Database = Resource('PrismaComposer.Database', { - aliases: ['Prisma.Database'], -}); - -export const DatabaseProvider = () => - Provider.effect( - Database, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as DatabaseAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ) - : undefined; - if (!observed) { - // branchId goes in the create body: a database created without one - // is born on the project's default Branch — production's, on a - // named stage. The platform still attaches in a second step of its - // own, so this narrows that window rather than closing it. - const created = yield* call(() => - client.POST('/v1/databases', { - body: { - projectId: news.projectId, - name: news.name, - region: news.region, - ...(news.isDefault !== undefined && { isDefault: news.isDefault }), - ...(news.branchId !== undefined && { branchId: news.branchId }), - }, - }), - ); - return { id: created.data.id, name: created.data.name }; - } - - const result: DatabaseAttributes = { id: observed.data.id, name: observed.data.name }; - if (news.branchId !== undefined) { - const branchId = news.branchId; - yield* call(() => - client.PATCH('/v1/databases/{databaseId}', { - params: { path: { databaseId: result.id } }, - body: { branchId }, - }), - ); - } - - return result; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const d = yield* callOptional(() => - client.GET('/v1/databases/{databaseId}', { - params: { path: { databaseId: output.id } }, - }), - ); - return d ? { id: d.data.id, name: d.data.name } : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts deleted file mode 100644 index 1f0b3b5a..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Project.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export interface ProjectProps { - /** The workspace that will own this project. */ - workspaceId: string; - /** Human-readable project name. */ - name: string; -} - -export interface ProjectAttributes { - id: string; - name: string; -} - -export type Project = Resource<'PrismaComposer.Project', ProjectProps, ProjectAttributes>; - -/** A Prisma Developer Platform **Project** — the container for databases and compute services. */ -export const Project = Resource('PrismaComposer.Project', { aliases: ['Prisma.Project'] }); - -export const ProjectProvider = () => - Provider.effect( - Project, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as ProjectAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // Observe — a project is only findable by its saved id. - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ) - : undefined; - if (observed) return { id: observed.data.id, name: observed.data.name }; - - // Ensure — create it in the target workspace. - const created = yield* call(() => - client.POST('/v1/projects', { - body: { name: news.name, workspaceId: news.workspaceId }, - }), - ); - return { id: created.data.id, name: created.data.name }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const p = yield* callOptional(() => - client.GET('/v1/projects/{id}', { - params: { path: { id: output.id } }, - }), - ); - return p ? { id: p.data.id, name: p.data.name } : undefined; - }), - }; - }), - ); 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 e975520e..3b6439d7 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -1,4 +1,7 @@ +import * as NodeHttpClient from '@effect/platform-node/NodeHttpClient'; +import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; +import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { Bucket, BucketProvider } from './buckets/Bucket.ts'; import { BucketKey, BucketKeyProvider } from './buckets/BucketKey.ts'; @@ -6,14 +9,57 @@ import * as client from './client.ts'; import { ComputeService, ComputeServiceProvider } from './compute/ComputeService.ts'; import { Deployment, DeploymentProvider } from './compute/Deployment.ts'; import { EnvironmentVariable, EnvironmentVariableProvider } from './compute/EnvironmentVariable.ts'; -import { fromEnv } from './credentials.ts'; -import { Connection, ConnectionProvider } from './postgres/Connection.ts'; -import { Database, DatabaseProvider } from './postgres/Database.ts'; -import { Project, ProjectProvider } from './postgres/Project.ts'; +import { fromEnv, managementApiBaseUrl, PrismaCredentials } from './credentials.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. + */ +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, + }; + }), + ); + +/** + * Upstream alchemy's live postgres-family providers (Project, Database, + * Connection) over upstream's management client, authenticated by + * {@link prismaEnvironment}. + * + * alchemy 2.0.0-beta.67 exports only the per-resource provider layers, so + * they are composed by hand here. TODO: switch to upstream's + * `liveProviderLayer` in the alchemy release that exports it. + */ +const upstreamPostgresProviders = () => + Layer.mergeAll( + Prisma.ProjectProvider(), + Prisma.DatabaseProvider(), + Prisma.ConnectionProvider(), + ).pipe( + Layer.provideMerge(Prisma.PrismaClientLive), + // Provide (NOT provideMerge) the node transport privately — mirrors + // upstream's Providers.ts: it must serve only the Prisma management + // client, never override the ambient HttpClient of other providers. + Layer.provide(NodeHttpClient.layerNodeHttp), + Layer.provideMerge(prismaEnvironment()), + ); + /** * The Prisma provider bundle: every resource provider, the Management API * client, and env-based credentials. Plug into a stack with @@ -23,9 +69,9 @@ export const providers = () => Layer.effect( Providers, Provider.collection([ - Project, - Database, - Connection, + Prisma.Project, + Prisma.Database, + Prisma.Connection, ComputeService, Deployment, EnvironmentVariable, @@ -35,9 +81,7 @@ export const providers = () => ).pipe( Layer.provide( Layer.mergeAll( - ProjectProvider(), - DatabaseProvider(), - ConnectionProvider(), + upstreamPostgresProviders(), ComputeServiceProvider(), DeploymentProvider(), EnvironmentVariableProvider(), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts new file mode 100644 index 00000000..2ea9aea6 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts @@ -0,0 +1,365 @@ +/** + * Legacy postgres-family state rows (written by Composer's own deleted + * `Prisma.Database` / `Prisma.Connection` resources) must round-trip through + * the hosted state store into shapes upstream alchemy's providers ACCEPT — + * meaning: the provider's `diff` plans no action (so no create and no + * replace), its `read` finds the physical resource instead of returning + * `undefined` (which would plan a create), and `reconcile` keeps the + * persisted secret without rotating anything. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { InstanceId } from 'alchemy/InstanceId'; +import * as Prisma from 'alchemy/Prisma'; +import type * as Provider from 'alchemy/Provider'; +import { Stack } from 'alchemy/Stack'; +import { Stage } from 'alchemy/Stage'; +import type { CreatedResourceState, ReplacedResourceState } from 'alchemy/State'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import postgres from 'postgres'; +import { migrateLegacyPostgresState } from '../legacy-postgres.ts'; +import { migratePrismaState } from '../schema.ts'; +import { makePrismaStateService } from '../service.ts'; +import { startTestPostgres, type TestPostgres } from './harness.ts'; + +const pg: TestPostgres | undefined = startTestPostgres(); + +if (pg === undefined) { + console.warn( + '[alchemy/state] skipping legacy-postgres migration tests: no Postgres available. ' + + 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + + '(e.g. `brew install postgresql@15`) on PATH.', + ); +} + +const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; + +const legacyDatabaseRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Database', + namespace: undefined, + fqn: 'data-db', + logicalId: 'data-db', + instanceId: 'inst-db', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1' }, + attr: { id: 'db-1', name: 'data' }, +}); + +const legacyConnectionRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Connection', + namespace: undefined, + fqn: 'data-conn', + logicalId: 'data-conn', + instanceId: 'inst-conn', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { databaseId: 'db-1', name: 'data' }, + attr: { id: 'conn-1', connectionString: Redacted.make(DIRECT_URL) }, +}); + +const apiDatabase = { + id: 'db-1', + name: 'data', + project: { id: 'proj-1' }, + status: 'ready', + region: { id: 'us-east-1' }, + isDefault: false, + branchId: null, + defaultConnectionId: 'conn-default', + createdAt: '2025-01-01T00:00:00.000Z', + source: { type: 'empty' }, + connections: [], +}; + +const apiConnection = { + id: 'conn-1', + name: 'data', + database: { id: 'db-1' }, + kind: 'postgres', + createdAt: '2025-01-01T00:00:00.000Z', +}; + +/** Only the endpoints the adoption paths under test actually hit; anything else throws loudly. */ +const stubClient = { + getDatabase: (id: string) => + id === 'db-1' ? Effect.succeed(apiDatabase) : Effect.die(`unexpected getDatabase ${id}`), + getConnection: (id: string) => + id === 'conn-1' ? Effect.succeed(apiConnection) : Effect.die(`unexpected getConnection ${id}`), + rotateConnection: (id: string) => + Effect.die(`rotateConnection(${id}) must not be called for an adopted legacy row`), +} as unknown as Prisma.PrismaManagementClient; + +// The provider layers' inferred environment leaks an `any` through +// Provider.effect's typing; the stubbed PrismaClient is the only real +// requirement and it IS provided, so the runtime environment is complete. +const databaseService = () => + Effect.runPromise( + Prisma.Database.Provider.pipe( + Effect.provide( + Prisma.DatabaseProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +const connectionService = () => + Effect.runPromise( + Prisma.Connection.Provider.pipe( + Effect.provide( + Prisma.ConnectionProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +type MigratedRow = CreatedResourceState & { + props: Record; + attr: Record; +}; + +describe('migrateLegacyPostgresState (pure mapping)', () => { + test('maps a legacy Database row to upstream field names, idempotently', () => { + const migrated = migrateLegacyPostgresState(legacyDatabaseRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Database'); + expect(migrated.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + expect(migrated.attr).toMatchObject({ + databaseId: 'db-1', + databaseName: 'data', + projectId: 'proj-1', + region: 'us-east-1', + isDefault: false, + branchId: null, + }); + expect(migrateLegacyPostgresState(migrated)).toEqual(migrated); + }); + + test('maps a legacy Connection row, carrying the Redacted secret into directConnectionString', () => { + const migrated = migrateLegacyPostgresState(legacyConnectionRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Connection'); + expect(migrated.props).toEqual({ database: 'db-1', name: 'data' }); + expect(migrated.attr).toMatchObject({ + connectionId: 'conn-1', + connectionName: 'data', + databaseId: 'db-1', + kind: 'postgres', + }); + const direct = migrated.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + expect(migrateLegacyPostgresState(migrated)).toEqual(migrated); + }); + + test('migrates the nested old-generation chain of a replaced row', () => { + const replaced: ReplacedResourceState = { + ...legacyDatabaseRow(), + status: 'replaced', + old: legacyDatabaseRow(), + deleteFirst: false, + } as ReplacedResourceState; + const migrated = migrateLegacyPostgresState(replaced) as ReplacedResourceState & { + old: MigratedRow; + }; + expect(migrated.old.resourceType).toBe('Prisma.Database'); + expect(migrated.old.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(migrated.old.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('maps the unreleased PrismaComposer.* type-ids too, and passes foreign rows through', () => { + const composerEra = { ...legacyDatabaseRow(), resourceType: 'PrismaComposer.Database' }; + expect((migrateLegacyPostgresState(composerEra) as MigratedRow).resourceType).toBe( + 'Prisma.Database', + ); + const foreign = { ...legacyDatabaseRow(), resourceType: 'Prisma.ComputeService' }; + expect(migrateLegacyPostgresState(foreign)).toEqual(foreign); + }); +}); + +describe('upstream provider acceptance of migrated rows (stubbed management client)', () => { + const migratedDb = migrateLegacyPostgresState(legacyDatabaseRow()) as MigratedRow; + const migratedConn = migrateLegacyPostgresState(legacyConnectionRow()) as MigratedRow; + + test('Database: diff plans NO action, and read finds the database (no create)', async () => { + const service = await databaseService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + news: { project: 'proj-1', name: 'data', region: 'us-east-1' }, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ databaseId: 'db-1', databaseName: 'data', projectId: 'proj-1' }); + }); + + test('Connection: diff plans NO action, read finds it, reconcile keeps the secret WITHOUT rotating', async () => { + const service = await connectionService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + + // The stub's rotateConnection dies, so this passing proves reconcile + // never touched the live credentials. + const reconciled = (await Effect.runPromise( + service.reconcile({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + )) as Record; + const direct = reconciled['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + }); +}); + +describe('branch-stage migrated rows against upstream Database provider', () => { + // A branch-stage row: the legacy descriptor passed an explicit name AND a + // branchId; the replacement descriptor omits the name when branchId is set. + const legacyBranchRow = (): CreatedResourceState => ({ + ...legacyDatabaseRow(), + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1', branchId: 'branch_1' }, + }); + + test('diff plans an UPDATE (the one-time rename + credential-recovery path), never a replace or create', async () => { + const migrated = migrateLegacyPostgresState(legacyBranchRow()) as MigratedRow; + expect(migrated.props).toEqual({ + project: 'proj-1', + name: 'data', + region: 'us-east-1', + branchId: 'branch_1', + }); + expect(migrated.attr).toMatchObject({ branchId: 'branch_1' }); + + const service = await databaseService(); + if (service.diff === undefined) throw new Error('upstream provider must expose diff'); + const diff = await Effect.runPromise( + service + .diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migrated.props, + // Branch-stage news shape from descriptors/postgres.ts: NO name. + news: { project: 'proj-1', region: 'us-east-1', branchId: 'branch_1' }, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe( + // The omitted name makes upstream derive a generated physical name, + // which reads the engine's Stack/Stage/InstanceId context. + Effect.provideService(Stack, { name: 'app' } as never), + Effect.provideService(Stage, 'stage1'), + Effect.provideService(InstanceId, 'abcd1234abcd1234abcd1234abcd1234'), + ), + ); + expect(diff).toEqual({ action: 'update' }); + }); +}); + +describe.skipIf(pg === undefined)('state service round-trip of legacy rows', () => { + if (pg === undefined) return; + + const sql = postgres(pg.url, { max: 5, onnotice: () => {} }); + const service = makePrismaStateService(sql); + const stack = 'legacy-postgres-stack'; + const stage = 'legacy-postgres-stage'; + + beforeAll(async () => { + await Effect.runPromise(migratePrismaState(sql)); + }); + + afterAll(async () => { + await Effect.runPromise(service.deleteStack({ stack })); + await sql.end({ timeout: 1 }); + pg.stop(); + }); + + test('an old-shape Database row persisted as-is is read back in the upstream shape', async () => { + await Effect.runPromise( + service.set({ stack, stage, fqn: 'data-db', value: legacyDatabaseRow() }), + ); + const row = (await Effect.runPromise( + service.get({ stack, stage, fqn: 'data-db' }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Database'); + expect(row.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(row.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('an old-shape Connection row round-trips with the Redacted secret intact', async () => { + await Effect.runPromise( + service.set({ stack, stage, fqn: 'data-conn', value: legacyConnectionRow() }), + ); + const row = (await Effect.runPromise( + service.get({ stack, stage, fqn: 'data-conn' }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Connection'); + expect(row.attr).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + const direct = row.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + // The databaseUrl mirror keeps the value usable where the conventional + // application URL is read. + expect(Redacted.value(row.attr['databaseUrl'] as Redacted.Redacted)).toBe(DIRECT_URL); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts new file mode 100644 index 00000000..014ec03d --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts @@ -0,0 +1,156 @@ +/** + * One-time, on-read rewrite of legacy postgres-family state rows into the + * shapes upstream alchemy's `Prisma.{Project,Database,Connection}` providers + * expect. + * + * Composer's own postgres resources persisted rows under the type-ids + * `Prisma.Database` / `Prisma.Connection` / `Prisma.Project` (and, for one + * unreleased window, `PrismaComposer.*`) with attribute shapes `{id, name}` + * (database/project) and `{id, connectionString}` (connection). Upstream's + * classes carry the SAME type-ids but expect `{databaseId, …}` / + * `{connectionId, …}` / `{projectId, …}` attributes. This module maps old rows + * to the upstream shape as they are read out of the hosted state store, so + * upstream's providers adopt them (their `read`/`diff` key off + * `output.databaseId` / `output.connectionId`) instead of planning a create. + * + * The legacy connection string was captured direct-preferred (pooled only as + * a fallback the API never actually took), so it maps to + * `directConnectionString` — and to `databaseUrl`, which is what the string + * was used as. Fields the old rows never carried (pooled/accelerate strings, + * host/user/password, origins) are left absent; upstream recomputes them from + * observed API state on the next reconcile that needs them. + * + * One-time operator-visible effects on BRANCH-STAGE environments (documented + * in docs/guides/deploying.md, "Upgrading to the upstream postgres + * resources"): the branch-stage descriptor passes no display name, so the + * first deploy after migration renames each stage database to a generated + * physical name and, in the same reconcile, rotates the database's DEFAULT + * connection credentials. The framework's own named Connection — the one + * services use — is not rotated. Production rows converge with no action. + * + * Scope: the HOSTED state store only. Local dev state (alchemy's local + * store) is never migrated — a stale local row under an unregistered type-id + * fails at plan time with alchemy's missing-provider error, and + * `prisma-composer dev --fresh` clears it (see docs/guides/running-locally.md). + */ + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const EPOCH = '1970-01-01T00:00:00.000Z'; + +type Family = 'Project' | 'Database' | 'Connection'; + +const FAMILY_BY_LEGACY_TYPE: Readonly> = { + 'Prisma.Project': 'Project', + 'Prisma.Database': 'Database', + 'Prisma.Connection': 'Connection', + 'PrismaComposer.Project': 'Project', + 'PrismaComposer.Database': 'Database', + 'PrismaComposer.Connection': 'Connection', +}; + +const migrateProps = (family: Family, props: unknown): unknown => { + if (!isRecord(props)) return props; + switch (family) { + case 'Project': + // Upstream ProjectProps carry no workspaceId; keep only the name. + return 'workspaceId' in props ? { name: props['name'] } : props; + case 'Database': { + if (!('projectId' in props) || 'project' in props) return props; + return { + project: props['projectId'], + name: props['name'], + region: props['region'], + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + } + case 'Connection': { + if (!('databaseId' in props) || 'database' in props) return props; + return { database: props['databaseId'], name: props['name'] }; + } + } +}; + +const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => { + if (!isRecord(attr) || typeof attr['id'] !== 'string') return attr; + const oldProps = isRecord(props) ? props : {}; + switch (family) { + case 'Project': { + if ('projectId' in attr) return attr; + return { + projectId: attr['id'], + projectName: attr['name'], + workspaceId: oldProps['workspaceId'] ?? '', + createdAt: EPOCH, + defaultRegion: null, + }; + } + case 'Database': { + if ('databaseId' in attr) return attr; + return { + databaseId: attr['id'], + databaseName: attr['name'] ?? oldProps['name'], + projectId: oldProps['projectId'], + status: 'ready', + region: oldProps['region'] ?? null, + isDefault: oldProps['isDefault'] ?? false, + branchId: oldProps['branchId'] ?? null, + defaultConnectionId: null, + createdAt: EPOCH, + }; + } + case 'Connection': { + if ('connectionId' in attr) return attr; + return { + connectionId: attr['id'], + connectionName: oldProps['name'], + databaseId: oldProps['databaseId'], + kind: 'postgres', + createdAt: EPOCH, + directConnectionString: attr['connectionString'], + databaseUrl: attr['connectionString'], + }; + } + } +}; + +const migrateResourceRow = (row: Record): Record => { + const resourceType = row['resourceType']; + if (typeof resourceType !== 'string') return row; + const family = FAMILY_BY_LEGACY_TYPE[resourceType]; + if (family === undefined) return row; + + const migrated: Record = { + ...row, + resourceType: `Prisma.${family}`, + ...('props' in row ? { props: migrateProps(family, row['props']) } : {}), + ...('attr' in row ? { attr: migrateAttr(family, row['attr'], row['props']) } : {}), + }; + + // Replacement rows nest the displaced generation under `old` (a full row); + // updating rows nest `{props, attr, bindings}`. Migrate both forms so no + // stale shape survives anywhere in the chain. + const old = row['old']; + if (isRecord(old)) { + migrated['old'] = + typeof old['resourceType'] === 'string' + ? migrateResourceRow(old) + : { + ...old, + ...('props' in old ? { props: migrateProps(family, old['props']) } : {}), + ...('attr' in old ? { attr: migrateAttr(family, old['attr'], old['props']) } : {}), + }; + } + return migrated; +}; + +/** + * Maps a revived state value from the legacy Composer postgres shape to the + * upstream shape. Non-postgres rows (and action rows) pass through untouched; + * the function is idempotent, so already-migrated rows pass through too. + */ +export const migrateLegacyPostgresState = (value: unknown): unknown => { + if (!isRecord(value) || value['kind'] === 'action') return value; + return migrateResourceRow(value); +}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts index bc271ff7..f82c3278 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts @@ -11,6 +11,7 @@ import { import * as Effect from 'effect/Effect'; import type postgres from 'postgres'; import { toStateStoreError } from './errors.ts'; +import { migrateLegacyPostgresState } from './legacy-postgres.ts'; import { retryColdStart } from './transient.ts'; const attempt = (f: () => Promise): Effect.Effect => @@ -42,15 +43,15 @@ const jsonParam = (sql: postgres.Sql, value: unknown): postgres.Parameter => const revivePersistedState = (value: unknown): PersistedState => blindCast< PersistedState, - 'reviveStateRecursive returns unknown; the row was written by set() through encodeState, so the revived shape is a PersistedState by construction' - >(reviveStateRecursive(value)); + 'reviveStateRecursive returns unknown; the row was written by set() through encodeState, so the revived shape is a PersistedState by construction — migrateLegacyPostgresState only rewrites legacy postgres-family rows to the upstream field names, preserving that shape' + >(migrateLegacyPostgresState(reviveStateRecursive(value))); /** Same reasoning as {@link revivePersistedState}, narrowed by the SQL status filter. */ const reviveReplacedResourceState = (value: unknown): ReplacedResourceState => blindCast< ReplacedResourceState, - "filtered to status = 'replaced' in SQL; the row was written by set() through encodeState, so the revived shape is a ReplacedResourceState by construction" - >(reviveStateRecursive(value)); + "filtered to status = 'replaced' in SQL; the row was written by set() through encodeState, so the revived shape is a ReplacedResourceState by construction — migrateLegacyPostgresState only rewrites legacy postgres-family rows to the upstream field names, preserving that shape" + >(migrateLegacyPostgresState(reviveStateRecursive(value))); /** * Builds alchemy's `StateService` over a caller-supplied postgres.js client, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts index c681a314..32830616 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ index: 'src/exports/index.ts', buckets: 'src/exports/buckets.ts', compute: 'src/exports/compute.ts', - postgres: 'src/exports/postgres.ts', state: 'src/exports/state.ts', }, }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 44bd6ecf..d20e6cb2 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 @@ -14,6 +14,7 @@ import { secretString } from '@internal/foundation/arktype'; // mode regardless of the (filesystem-dependent) test-file order. import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; +import * as RealAlchemyPrisma from 'alchemy/Prisma'; import { type } from 'arktype'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; @@ -74,6 +75,25 @@ mock.module('alchemy/Output', () => ({ all: (...outs: unknown[]) => outs, })); +// The postgres family (Database/Connection) is upstream alchemy's — the +// descriptors import it from 'alchemy/Prisma', so the stubs live there. The +// returned attributes use upstream's field names (`databaseId`, +// `directConnectionString`). +mock.module('alchemy/Prisma', () => ({ + ...RealAlchemyPrisma, + Database: (id: string, props: unknown) => { + recorded.db.push([id, props]); + return Effect.succeed({ databaseId: `${id}#cloud-id`, databaseName: id }); + }, + Connection: (id: string, props: unknown) => { + recorded.conn.push([id, props]); + return Effect.succeed({ + connectionId: `${id}#cloud-id`, + directConnectionString: Redacted.make(`postgres://${id}`), + }); + }, +})); + mock.module('@internal/lowering', () => ({ ...RealPrismaAlchemy, providers: () => ({ stub: 'providers' }), @@ -89,17 +109,6 @@ mock.module('@internal/lowering', () => ({ recorded.serviceKey.push([id, props]); return Effect.succeed({ value: `key-for-${id}` }); }, - Database: (id: string, props: unknown) => { - recorded.db.push([id, props]); - return Effect.succeed({ id: `${id}#cloud-id`, name: id }); - }, - Connection: (id: string, props: unknown) => { - recorded.conn.push([id, props]); - return Effect.succeed({ - id: `${id}#cloud-id`, - connectionString: Redacted.make(`postgres://${id}`), - }); - }, Bucket: (id: string, props: unknown) => { recorded.bucket.push([id, props]); return Effect.succeed({ id: `${id}#cloud-id`, name: id }); @@ -419,10 +428,16 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { // meaning, which is exactly why only the descriptor can decide. expect(result.entities).toEqual([{ kind: 'postgres-database', id: 'data-db#cloud-id' }]); expect(recorded.db).toEqual([ - ['data-db', { projectId: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], + ['data-db', { project: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], ]); expect(recorded.conn).toEqual([ - ['data-conn', { databaseId: 'data-db#cloud-id', name: 'data' }], + [ + 'data-conn', + { + database: { databaseId: 'data-db#cloud-id', databaseName: 'data-db' }, + name: 'data', + }, + ], ]); }); }); @@ -438,12 +453,13 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => { run(resourceDescriptorOf(target, 'postgres')(ctx)); + // A named stage attaches the branch at create, which upstream only + // permits WITHOUT an explicit display name — so `name` is absent here. expect(recorded.db.slice(before)).toEqual([ [ 'data2-db', { - projectId: 'shop-project#cloud-id', - name: 'data2', + project: 'shop-project#cloud-id', region: 'us-east-1', branchId: 'branch_1', }, @@ -1427,10 +1443,16 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th ); expect(recorded.db.slice(before.db)).toEqual([ - ['data-db', { projectId: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], + ['data-db', { project: 'shop-project#cloud-id', name: 'data', region: 'us-east-1' }], ]); expect(recorded.conn.slice(before.conn)).toEqual([ - ['data-conn', { databaseId: 'data-db#cloud-id', name: 'data' }], + [ + 'data-conn', + { + database: { databaseId: 'data-db#cloud-id', databaseName: 'data-db' }, + name: 'data', + }, + ], ]); const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index dc734ae2..2a514c7a 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts @@ -2,8 +2,8 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { PgWarm } from '../pg-warm-resource.ts'; @@ -25,14 +25,30 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); const branchId = cloudApplicationOf(application).branchId; + // Upstream refuses an explicit display name combined with branch + // attachment at create (the Management API creates the database before + // attaching the branch and exposes no idempotency key). On a named + // stage the attachment wins: the name is omitted so upstream creates + // under its recoverable generated physical name WITH the branchId in + // the create call, and `branchId` staying in props keeps the + // attachment reconciled on every later deploy. const db = yield* Prisma.Database(`${id}-db`, { - projectId: projectIdOf(application), - name: id, + project: projectIdOf(application), region: o().region ?? DEFAULT_REGION, - ...(branchId !== undefined ? { branchId } : {}), + ...(branchId !== undefined ? { branchId } : { name: id }), + }); + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }); + // Composer's semantics stay DIRECT: PgWarm and the migration flows + // depend on a direct connection, and upstream's `databaseUrl` is + // pooled-first — so bind `directConnectionString` explicitly. + const url = Output.map(conn.directConnectionString, (value) => { + if (value === undefined) { + throw new Error( + `prisma-cloud: connection "${id}-conn" returned no direct connection string.`, + ); + } + return Redacted.value(value); }); - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); - const url = Output.map(conn.connectionString, (value) => Redacted.value(value)); // Warm the DB so a consumer's first connect doesn't eat PPG's cold-start // (FT-5226). `warm.url` is the same url, so consumers depend on the warm. const warm = yield* PgWarm(`${id}-warm`, { url }); @@ -42,7 +58,7 @@ export function postgresDescriptor(o: () => ResolvedCloudOptions): NodeDescripto // same key means the opposite thing here as it does on compute. return { outputs: { url: warm.url }, - entities: [{ kind: 'postgres-database', id: db.id }], + entities: [{ kind: 'postgres-database', id: db.databaseId }], }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts index 8f7b36a0..a73f6d3f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/prisma-next.ts @@ -2,8 +2,8 @@ import type { NodeDescriptor } from '@internal/core/config'; import type { Lowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; import { PgWarm } from '../pg-warm-resource.ts'; @@ -30,14 +30,26 @@ export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescrip Effect.gen(function* () { validateName(id, 'resource name (from provision id)'); const branchId = cloudApplicationOf(application).branchId; + // Same create rule as descriptors/postgres.ts: an explicit name cannot + // combine with branch attachment at create, so a named stage omits the + // name and carries the branchId in props (created attached, reconciled + // attached). const db = yield* Prisma.Database(`${id}-db`, { - projectId: projectIdOf(application), - name: id, + project: projectIdOf(application), region: o().region ?? DEFAULT_REGION, - ...(branchId !== undefined ? { branchId } : {}), + ...(branchId !== undefined ? { branchId } : { name: id }), + }); + const conn = yield* Prisma.Connection(`${id}-conn`, { database: db, name: id }); + // Direct, not pooled — PgWarm and PnMigration below depend on it, and + // upstream's `databaseUrl` is pooled-first. + const url = Output.map(conn.directConnectionString, (value) => { + if (value === undefined) { + throw new Error( + `prisma-cloud: connection "${id}-conn" returned no direct connection string.`, + ); + } + return Redacted.value(value); }); - const conn = yield* Prisma.Connection(`${id}-conn`, { databaseId: db.id, name: id }); - const url = Output.map(conn.connectionString, (value) => Redacted.value(value)); if (!isPnPostgresResourceNode(node)) { // The registry routes 'prisma-next'-typed resource nodes here, so this @@ -81,7 +93,7 @@ export function prismaNextDescriptor(o: () => ResolvedCloudOptions): NodeDescrip // not a public endpoint, and only the descriptor can know that. return { outputs: { url: warm.url }, - entities: [{ kind: 'postgres-database', id: db.id }], + entities: [{ kind: 'postgres-database', id: db.databaseId }], }; }); return Object.assign(lowering, { kind: 'resource' as const }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3bc82a7..60880142 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -797,6 +797,9 @@ importers: packages/1-prisma-cloud/0-lowering/lowering: dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) '@internal/core': specifier: workspace:0.3.0 version: link:../../../0-framework/1-core/core From d3e25791faae33c1c9aeef42ead56e1c9fa8986f Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 18:15:49 +0200 Subject: [PATCH 04/22] docs(drive): draft upstream alchemy PR body Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/upstream-pr-body.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md diff --git a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md new file mode 100644 index 00000000..ae590ced --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md @@ -0,0 +1,88 @@ +# feat(prisma): Bucket + BucketKey resources, providers({dev}) override, Postgres state backend + +We're adopting the Prisma provider in Prisma Composer (the framework layer on +top of Prisma Cloud) and deleting our own resource implementations in its +favor. This PR adds the pieces Composer needs that the provider doesn't have +yet. Everything follows the provider's existing conventions; the contract +fixture, coverage counts, public-surface and source-convention suites are +updated rather than bypassed. + +## 1. `Prisma.Bucket` / `Prisma.BucketKey` + +Covers the 7 previously deferred `/v1/buckets` Object Storage routes +(`deferredRoutes` is now empty in the contract fixture; coverage 71 → 78). + +- `Bucket`: read-then-create reconcile, replace on project/name/branch change + (no PATCH route exists), identity-verified delete (persists `projectId` + + `createdAt`), real `list` for nuke. +- `BucketKey`: the secret access key is **reveal-once** — the API never + returns it after create, so persisted state is authoritative (the + `Connection` pattern). Keys are created under a deterministic + `instanceId`-derived physical name and looked up before create, so a crash + between the create call and the state write cannot mint a second, + unenumerable credential — the orphan from the lost response is found by + name and revoked before a fresh key is created. Existence is re-verified on + read/reconcile via `listBucketKeys` (secrets always from state); note + `bucketName` is the provider-side S3 bucket name, not the display name. +- Docs: `prisma/data/buckets` page + sidebar entry, mirroring connections. + +## 2. `providers({ dev })` + `liveProviders()` + +`Prisma.providers()` selects dev-vs-live internally, which means an embedder +with its own local emulation cannot compose with the live providers without +hand-rebuilding the client/auth wiring. Two additive changes, defaults +unchanged: + +- `providers({ dev })` — a supplied layer replaces the built-in dev layer in + `alchemy dev` only. `PrismaLocalProviders` is typed structurally (the union + of the twelve resource providers), so an embedder layer typechecks without + casts — the test builds one from `Provider.succeed` per resource and passes + it with no `as`. +- `liveProviders()` — the live provider layer exported for frameworks that do + their own mode selection. + +Composer uses both: upstream live providers on deploy, its own emulator +providers in dev. + +## 3. `State/PostgresState` + +A Postgres state backend: `postgresState({ client | dsn, lockKeyPrefix, id })` +over two tables, with a per-`(stack, stage)` session advisory lock +(`pg_try_advisory_lock(hashtextextended(key, 0))` on a reserved connection, +holder re-verified against `pg_locks` **from a different pool connection**), +and a TTL-amortized lease check wrapping every operation. Stage-less +`deleteStack` locks each stage before deleting. The schema migration runs +under a transaction-scoped advisory lock — concurrent +`create table if not exists` genuinely fails on Postgres (duplicate `pg_type` +errors), so first-boot races between two stacks are handled. + +Motivation: the `Compute` docstring tells users to "use a durable, locked +state backend" for safe deployment recovery, and no in-tree backend provides +locking. Uses the repo's existing `pg` dependency; deliberately **not** +re-exported from the `State` barrel (a comment explains: the barrel is +imported by engine files that get bundled for workers, and `pg` must stay off +that graph) — import via `alchemy/State/PostgresState`. + +Tests are hermetic stubs per the existing state-backend convention +(`HttpStateStore`). Two real-Postgres behaviors were verified against a live +PostgreSQL 15 during development and are documented in comments rather than +CI-tested: the 64-bit advisory-lock key reconstruction from +`pg_locks.classid/objid` (intentional bigint shift wraparound), and the +concurrent-DDL failure that motivates the migration lock. + +## 4. `Resource.ts`: `Aliases` typing + +`ResourceClass.Aliases` is `readonly string[] | undefined`, but +`ResourceClassLike.Aliases?: readonly string[]` — under a consumer tsconfig +with `exactOptionalPropertyTypes: true`, every `Provider.effect(cls, …)` call +fails to typecheck (Composer currently carries this as a pnpm patch). One +line: `Aliases?: readonly string[] | undefined`. + +## Verification + +- `bun run format:check` clean; `bun tsc -b` (monorepo) clean. +- `bun alchemy-test --fast test/Prisma test/State`: 391 passed / 0 failed. +- Core engine suites (exercising the `Resource.ts` change): 549 passed / 0 + failed. +- `generate-api-reference`: no new category; Prisma gains the two bucket + pages. `docs:check` builds clean. From 2f1b1406d6fbd895bd4d056b94bb44b23baa4b3f Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 18:44:40 +0200 Subject: [PATCH 05/22] =?UTF-8?q?docs(drive):=20rewrite=20upstream=20PR=20?= =?UTF-8?q?body=20=E2=80=94=20grounding=20example,=20narrative,=20alternat?= =?UTF-8?q?ives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/upstream-pr-body.md | 204 +++++++++++------- 1 file changed, 130 insertions(+), 74 deletions(-) diff --git a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md index ae590ced..b261d4fd 100644 --- a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md +++ b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md @@ -1,82 +1,117 @@ -# feat(prisma): Bucket + BucketKey resources, providers({dev}) override, Postgres state backend - -We're adopting the Prisma provider in Prisma Composer (the framework layer on -top of Prisma Cloud) and deleting our own resource implementations in its -favor. This PR adds the pieces Composer needs that the provider doesn't have -yet. Everything follows the provider's existing conventions; the contract -fixture, coverage counts, public-surface and source-convention suites are -updated rather than bypassed. - -## 1. `Prisma.Bucket` / `Prisma.BucketKey` - -Covers the 7 previously deferred `/v1/buckets` Object Storage routes -(`deferredRoutes` is now empty in the contract fixture; coverage 71 → 78). - -- `Bucket`: read-then-create reconcile, replace on project/name/branch change - (no PATCH route exists), identity-verified delete (persists `projectId` + - `createdAt`), real `list` for nuke. -- `BucketKey`: the secret access key is **reveal-once** — the API never - returns it after create, so persisted state is authoritative (the - `Connection` pattern). Keys are created under a deterministic - `instanceId`-derived physical name and looked up before create, so a crash - between the create call and the state write cannot mint a second, - unenumerable credential — the orphan from the lost response is found by - name and revoked before a fresh key is created. Existence is re-verified on - read/reconcile via `listBucketKeys` (secrets always from state); note - `bucketName` is the provider-side S3 bucket name, not the display name. -- Docs: `prisma/data/buckets` page + sidebar entry, mirroring connections. - -## 2. `providers({ dev })` + `liveProviders()` - -`Prisma.providers()` selects dev-vs-live internally, which means an embedder -with its own local emulation cannot compose with the live providers without -hand-rebuilding the client/auth wiring. Two additive changes, defaults -unchanged: - -- `providers({ dev })` — a supplied layer replaces the built-in dev layer in - `alchemy dev` only. `PrismaLocalProviders` is typed structurally (the union - of the twelve resource providers), so an embedder layer typechecks without - casts — the test builds one from `Provider.succeed` per resource and passes - it with no `as`. -- `liveProviders()` — the live provider layer exported for frameworks that do - their own mode selection. - -Composer uses both: upstream live providers on deploy, its own emulator -providers in dev. - -## 3. `State/PostgresState` - -A Postgres state backend: `postgresState({ client | dsn, lockKeyPrefix, id })` -over two tables, with a per-`(stack, stage)` session advisory lock -(`pg_try_advisory_lock(hashtextextended(key, 0))` on a reserved connection, -holder re-verified against `pg_locks` **from a different pool connection**), -and a TTL-amortized lease check wrapping every operation. Stage-less -`deleteStack` locks each stage before deleting. The schema migration runs -under a transaction-scoped advisory lock — concurrent -`create table if not exists` genuinely fails on Postgres (duplicate `pg_type` -errors), so first-boot races between two stacks are handled. - -Motivation: the `Compute` docstring tells users to "use a durable, locked -state backend" for safe deployment recovery, and no in-tree backend provides -locking. Uses the repo's existing `pg` dependency; deliberately **not** -re-exported from the `State` barrel (a comment explains: the barrel is +This PR completes three gaps in the Prisma provider that show up the moment a +real framework embeds it. After it, this works: + +```ts +import * as Alchemy from "alchemy"; +import * as Prisma from "alchemy/Prisma"; +import { postgresState } from "alchemy/State/PostgresState"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "Media", + { + // Bring your own local emulation in `alchemy dev`; live stays live. + providers: Prisma.providers({ dev: myEmulatorProviders() }), + // Durable, *locked* state — what Compute's own docs ask for. + state: postgresState({ dsn: process.env.STATE_DSN! }), + }, + Effect.gen(function* () { + const project = yield* Prisma.Project("app", {}); + + // Object storage: the last deferred Management API surface. + const bucket = yield* Prisma.Bucket("media", { project }); + const key = yield* Prisma.BucketKey("media-rw", { + bucket, + role: "read_write", + }); + + return { endpoint: key.endpoint, bucket: key.bucketName }; + }), +); +``` + +The three ship together because they're the set a production embedder needs at +once — we (Prisma Composer, the framework layer over Prisma Cloud) are deleting +our own resource implementations in favor of this provider, and these were the +three things we couldn't do with it. Each is an independent commit-sized +concern; happy to split into separate PRs if you'd rather review them that way. + +## Object storage: `Prisma.Bucket` / `Prisma.BucketKey` + +The 7 `/v1/buckets` routes were the provider's only deferred Management API +surface. This adds them to the client/operations layer and puts two resources +on top, shaped like their siblings (`Database`/`Connection` conventions +throughout: `Refs` string-or-resource references, replace-on-identity-change +diff, identity-verified delete, real `list` for nuke). + +The interesting part is the key secret. The API returns `secretAccessKey` +exactly once, at create — it can never be re-read. So persisted state is +authoritative for the secret (the `Connection` pattern), which raises the +crash-window question: what if the process dies after `POST …/keys` succeeds +but before state is written? A naive retry would mint a second, working, +never-expiring credential that no state row references and `list` can't +surface. To close that, keys are created under a deterministic +`instanceId`-derived physical name and looked up by name before create; a hit +on retry means "create succeeded, response lost" — that orphan's secret is +gone for good, so it is revoked and one fresh key is minted. Existence is +re-verified on read/reconcile (secrets always from state), so a key revoked in +the Console reads as gone instead of haunting the stack. + +Docs: `prisma/data/buckets` page + sidebar entry, mirroring connections. + +## An embedder seam for dev mode: `providers({ dev })` + `liveProviders()` + +`Prisma.providers()` picks dev-vs-live internally. That's the right default, +but it's closed: an embedder with richer local emulation (we run a compute +emulator that supervises the real artifact, a local S3, and a persistent dev +Postgres) can't swap the dev half without rebuilding the client/auth wiring by +hand. Two additive changes, defaults untouched: + +- `providers({ dev })` — the supplied layer replaces the built-in dev + providers during `alchemy dev` only. `PrismaLocalProviders` is a structural + type (the union of the twelve resource providers), and the test proves an + embedder layer built from `Provider.succeed` typechecks with **no casts** — + that's the seam's contract. +- `liveProviders()` — the live layer exported for frameworks doing their own + mode selection. + +## A locked state backend: `State/PostgresState` + +`Compute`'s recovery docs tell users to "use a durable, locked state backend" +— and no in-tree backend has locking. This adds one on the dependency the repo +already carries (`pg`): + +- per-`(stack, stage)` **session advisory lock** + (`pg_try_advisory_lock(hashtextextended(key, 0))`) held on a reserved + connection, with the holder re-verified against `pg_locks` from a + *different* pool connection — so a silently dropped lock connection is + detected, not trusted; +- a TTL-amortized lease check wrapping every operation; stage-less + `deleteStack` locks each stage before touching it; +- schema migration under a transaction-scoped advisory lock, because + concurrent `create table if not exists` genuinely fails on Postgres + (duplicate `pg_type` errors — reproduced on PG 15) and first-boot races + between two stacks are exactly the case a state store must survive. + +It is deliberately **not** re-exported from the `State` barrel: the barrel is imported by engine files that get bundled for workers, and `pg` must stay off -that graph) — import via `alchemy/State/PostgresState`. +that graph. Deep import: `alchemy/State/PostgresState` (a comment in the +barrel says why). -Tests are hermetic stubs per the existing state-backend convention -(`HttpStateStore`). Two real-Postgres behaviors were verified against a live -PostgreSQL 15 during development and are documented in comments rather than -CI-tested: the 64-bit advisory-lock key reconstruction from -`pg_locks.classid/objid` (intentional bigint shift wraparound), and the -concurrent-DDL failure that motivates the migration lock. +Tests are hermetic stubs per the existing backend convention +(`HttpStateStore`). Two real-Postgres behaviors were verified against live +PostgreSQL 15 during development and documented in comments rather than +CI-tested: the 64-bit lock-key reconstruction from `pg_locks.classid/objid` +(intentional bigint wraparound), and the concurrent-DDL failure motivating the +migration lock. -## 4. `Resource.ts`: `Aliases` typing +## One-line core fix: `Aliases` typing `ResourceClass.Aliases` is `readonly string[] | undefined`, but -`ResourceClassLike.Aliases?: readonly string[]` — under a consumer tsconfig +`ResourceClassLike.Aliases?: readonly string[]`. Under a consumer tsconfig with `exactOptionalPropertyTypes: true`, every `Provider.effect(cls, …)` call -fails to typecheck (Composer currently carries this as a pnpm patch). One -line: `Aliases?: readonly string[] | undefined`. +fails to typecheck (we currently carry a pnpm patch for this). The fix widens +the optional to `| undefined`. ## Verification @@ -85,4 +120,25 @@ line: `Aliases?: readonly string[] | undefined`. - Core engine suites (exercising the `Resource.ts` change): 549 passed / 0 failed. - `generate-api-reference`: no new category; Prisma gains the two bucket - pages. `docs:check` builds clean. + pages; `docs:check` builds clean. Contract fixture updated + (`deferredRoutes` now empty; route coverage 71 → 78). + +## Alternatives considered + +- **Three separate PRs.** Kept together because they're one consumer's + complete need and the review context overlaps (BucketKey's crash-window + design references Connection's; the dev seam is what makes the state + backend's locking story testable end-to-end for us). Say the word and we'll + split. +- **`ReturnType` for `PrismaLocalProviders`.** It + bakes the built-in stubs' literal `stables` tuples into the public type — + no real embedder layer can satisfy it without `as never`. Structural union + instead. +- **Exporting `postgresState` from the `State` barrel.** Poisons worker + bundles with `pg`. Deep import + explanatory comment instead. +- **Letting embedders rebuild the live wiring themselves** (no + `liveProviders()` export). Works today but couples every embedder to the + private composition of client/auth/upload layers — each upstream refactor + breaks them silently. +- **A migration-free schema bootstrap** (plain `create table if not exists`). + Fails under concurrency on real Postgres; see above. From 97e33fe8e6198ed66394ae5e5ebd5be9b6f280cd Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 18:49:08 +0200 Subject: [PATCH 06/22] =?UTF-8?q?docs(drive):=20unwrap=20PR=20body=20?= =?UTF-8?q?=E2=80=94=20GitHub=20renders=20single=20newlines=20as=20breaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/upstream-pr-body.md | 116 ++++-------------- 1 file changed, 25 insertions(+), 91 deletions(-) diff --git a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md index b261d4fd..cd839102 100644 --- a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md +++ b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md @@ -1,5 +1,4 @@ -This PR completes three gaps in the Prisma provider that show up the moment a -real framework embeds it. After it, this works: +This PR completes three gaps in the Prisma provider that show up the moment a real framework embeds it. After it, this works: ```ts import * as Alchemy from "alchemy"; @@ -30,115 +29,50 @@ export default Alchemy.Stack( ); ``` -The three ship together because they're the set a production embedder needs at -once — we (Prisma Composer, the framework layer over Prisma Cloud) are deleting -our own resource implementations in favor of this provider, and these were the -three things we couldn't do with it. Each is an independent commit-sized -concern; happy to split into separate PRs if you'd rather review them that way. +The three ship together because they're the set a production embedder needs at once — we (Prisma Composer, the framework layer over Prisma Cloud) are deleting our own resource implementations in favor of this provider, and these were the three things we couldn't do with it. Each is an independent commit-sized concern; happy to split into separate PRs if you'd rather review them that way. ## Object storage: `Prisma.Bucket` / `Prisma.BucketKey` -The 7 `/v1/buckets` routes were the provider's only deferred Management API -surface. This adds them to the client/operations layer and puts two resources -on top, shaped like their siblings (`Database`/`Connection` conventions -throughout: `Refs` string-or-resource references, replace-on-identity-change -diff, identity-verified delete, real `list` for nuke). - -The interesting part is the key secret. The API returns `secretAccessKey` -exactly once, at create — it can never be re-read. So persisted state is -authoritative for the secret (the `Connection` pattern), which raises the -crash-window question: what if the process dies after `POST …/keys` succeeds -but before state is written? A naive retry would mint a second, working, -never-expiring credential that no state row references and `list` can't -surface. To close that, keys are created under a deterministic -`instanceId`-derived physical name and looked up by name before create; a hit -on retry means "create succeeded, response lost" — that orphan's secret is -gone for good, so it is revoked and one fresh key is minted. Existence is -re-verified on read/reconcile (secrets always from state), so a key revoked in -the Console reads as gone instead of haunting the stack. +The 7 `/v1/buckets` routes were the provider's only deferred Management API surface. This adds them to the client/operations layer and puts two resources on top, shaped like their siblings (`Database`/`Connection` conventions throughout: `Refs` string-or-resource references, replace-on-identity-change diff, identity-verified delete, real `list` for nuke). + +The interesting part is the key secret. The API returns `secretAccessKey` exactly once, at create — it can never be re-read. So persisted state is authoritative for the secret (the `Connection` pattern), which raises the crash-window question: what if the process dies after `POST …/keys` succeeds but before state is written? A naive retry would mint a second, working, never-expiring credential that no state row references and `list` can't surface. To close that, keys are created under a deterministic `instanceId`-derived physical name and looked up by name before create; a hit on retry means "create succeeded, response lost" — that orphan's secret is gone for good, so it is revoked and one fresh key is minted. Existence is re-verified on read/reconcile (secrets always from state), so a key revoked in the Console reads as gone instead of haunting the stack. Docs: `prisma/data/buckets` page + sidebar entry, mirroring connections. ## An embedder seam for dev mode: `providers({ dev })` + `liveProviders()` -`Prisma.providers()` picks dev-vs-live internally. That's the right default, -but it's closed: an embedder with richer local emulation (we run a compute -emulator that supervises the real artifact, a local S3, and a persistent dev -Postgres) can't swap the dev half without rebuilding the client/auth wiring by -hand. Two additive changes, defaults untouched: +`Prisma.providers()` picks dev-vs-live internally. That's the right default, but it's closed: an embedder with richer local emulation (we run a compute emulator that supervises the real artifact, a local S3, and a persistent dev Postgres) can't swap the dev half without rebuilding the client/auth wiring by hand. Two additive changes, defaults untouched: -- `providers({ dev })` — the supplied layer replaces the built-in dev - providers during `alchemy dev` only. `PrismaLocalProviders` is a structural - type (the union of the twelve resource providers), and the test proves an - embedder layer built from `Provider.succeed` typechecks with **no casts** — - that's the seam's contract. -- `liveProviders()` — the live layer exported for frameworks doing their own - mode selection. +- `providers({ dev })` — the supplied layer replaces the built-in dev providers during `alchemy dev` only. `PrismaLocalProviders` is a structural type (the union of the twelve resource providers), and the test proves an embedder layer built from `Provider.succeed` typechecks with **no casts** — that's the seam's contract. +- `liveProviders()` — the live layer exported for frameworks doing their own mode selection. ## A locked state backend: `State/PostgresState` -`Compute`'s recovery docs tell users to "use a durable, locked state backend" -— and no in-tree backend has locking. This adds one on the dependency the repo -already carries (`pg`): - -- per-`(stack, stage)` **session advisory lock** - (`pg_try_advisory_lock(hashtextextended(key, 0))`) held on a reserved - connection, with the holder re-verified against `pg_locks` from a - *different* pool connection — so a silently dropped lock connection is - detected, not trusted; -- a TTL-amortized lease check wrapping every operation; stage-less - `deleteStack` locks each stage before touching it; -- schema migration under a transaction-scoped advisory lock, because - concurrent `create table if not exists` genuinely fails on Postgres - (duplicate `pg_type` errors — reproduced on PG 15) and first-boot races - between two stacks are exactly the case a state store must survive. - -It is deliberately **not** re-exported from the `State` barrel: the barrel is -imported by engine files that get bundled for workers, and `pg` must stay off -that graph. Deep import: `alchemy/State/PostgresState` (a comment in the -barrel says why). - -Tests are hermetic stubs per the existing backend convention -(`HttpStateStore`). Two real-Postgres behaviors were verified against live -PostgreSQL 15 during development and documented in comments rather than -CI-tested: the 64-bit lock-key reconstruction from `pg_locks.classid/objid` -(intentional bigint wraparound), and the concurrent-DDL failure motivating the -migration lock. +`Compute`'s recovery docs tell users to "use a durable, locked state backend" — and no in-tree backend has locking. This adds one on the dependency the repo already carries (`pg`): + +- per-`(stack, stage)` **session advisory lock** (`pg_try_advisory_lock(hashtextextended(key, 0))`) held on a reserved connection, with the holder re-verified against `pg_locks` from a *different* pool connection — so a silently dropped lock connection is detected, not trusted; +- a TTL-amortized lease check wrapping every operation; stage-less `deleteStack` locks each stage before touching it; +- schema migration under a transaction-scoped advisory lock, because concurrent `create table if not exists` genuinely fails on Postgres (duplicate `pg_type` errors — reproduced on PG 15) and first-boot races between two stacks are exactly the case a state store must survive. + +It is deliberately **not** re-exported from the `State` barrel: the barrel is imported by engine files that get bundled for workers, and `pg` must stay off that graph. Deep import: `alchemy/State/PostgresState` (a comment in the barrel says why). + +Tests are hermetic stubs per the existing backend convention (`HttpStateStore`). Two real-Postgres behaviors were verified against live PostgreSQL 15 during development and documented in comments rather than CI-tested: the 64-bit lock-key reconstruction from `pg_locks.classid/objid` (intentional bigint wraparound), and the concurrent-DDL failure motivating the migration lock. ## One-line core fix: `Aliases` typing -`ResourceClass.Aliases` is `readonly string[] | undefined`, but -`ResourceClassLike.Aliases?: readonly string[]`. Under a consumer tsconfig -with `exactOptionalPropertyTypes: true`, every `Provider.effect(cls, …)` call -fails to typecheck (we currently carry a pnpm patch for this). The fix widens -the optional to `| undefined`. +`ResourceClass.Aliases` is `readonly string[] | undefined`, but `ResourceClassLike.Aliases?: readonly string[]`. Under a consumer tsconfig with `exactOptionalPropertyTypes: true`, every `Provider.effect(cls, …)` call fails to typecheck (we currently carry a pnpm patch for this). The fix widens the optional to `| undefined`. ## Verification - `bun run format:check` clean; `bun tsc -b` (monorepo) clean. - `bun alchemy-test --fast test/Prisma test/State`: 391 passed / 0 failed. -- Core engine suites (exercising the `Resource.ts` change): 549 passed / 0 - failed. -- `generate-api-reference`: no new category; Prisma gains the two bucket - pages; `docs:check` builds clean. Contract fixture updated - (`deferredRoutes` now empty; route coverage 71 → 78). +- Core engine suites (exercising the `Resource.ts` change): 549 passed / 0 failed. +- `generate-api-reference`: no new category; Prisma gains the two bucket pages; `docs:check` builds clean. Contract fixture updated (`deferredRoutes` now empty; route coverage 71 → 78). ## Alternatives considered -- **Three separate PRs.** Kept together because they're one consumer's - complete need and the review context overlaps (BucketKey's crash-window - design references Connection's; the dev seam is what makes the state - backend's locking story testable end-to-end for us). Say the word and we'll - split. -- **`ReturnType` for `PrismaLocalProviders`.** It - bakes the built-in stubs' literal `stables` tuples into the public type — - no real embedder layer can satisfy it without `as never`. Structural union - instead. -- **Exporting `postgresState` from the `State` barrel.** Poisons worker - bundles with `pg`. Deep import + explanatory comment instead. -- **Letting embedders rebuild the live wiring themselves** (no - `liveProviders()` export). Works today but couples every embedder to the - private composition of client/auth/upload layers — each upstream refactor - breaks them silently. -- **A migration-free schema bootstrap** (plain `create table if not exists`). - Fails under concurrency on real Postgres; see above. +- **Three separate PRs.** Kept together because they're one consumer's complete need and the review context overlaps (BucketKey's crash-window design references Connection's; the dev seam is what makes the state backend's locking story testable end-to-end for us). Say the word and we'll split. +- **`ReturnType` for `PrismaLocalProviders`.** It bakes the built-in stubs' literal `stables` tuples into the public type — no real embedder layer can satisfy it without `as never`. Structural union instead. +- **Exporting `postgresState` from the `State` barrel.** Poisons worker bundles with `pg`. Deep import + explanatory comment instead. +- **Letting embedders rebuild the live wiring themselves** (no `liveProviders()` export). Works today but couples every embedder to the private composition of client/auth/upload layers — each upstream refactor breaks them silently. +- **A migration-free schema bootstrap** (plain `create table if not exists`). Fails under concurrency on real Postgres; see above. From e78945348102348e727e6a7f0034dcd612b2fec2 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 18:51:12 +0200 Subject: [PATCH 07/22] docs(drive): add embedder DX sample to upstream PR body Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/upstream-pr-body.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md index cd839102..bdf9ba36 100644 --- a/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md +++ b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md @@ -46,6 +46,50 @@ Docs: `prisma/data/buckets` page + sidebar entry, mirroring connections. - `providers({ dev })` — the supplied layer replaces the built-in dev providers during `alchemy dev` only. `PrismaLocalProviders` is a structural type (the union of the twelve resource providers), and the test proves an embedder layer built from `Provider.succeed` typechecks with **no casts** — that's the seam's contract. - `liveProviders()` — the live layer exported for frameworks doing their own mode selection. +What implementing the dev side actually looks like — a provider per resource you emulate, plain `Provider.succeed`, no casts: + +```ts +const devDatabase = Provider.succeed(Prisma.Database, { + stables: ["databaseId"], + list: () => Effect.succeed([]), + diff: Effect.fn(function* () { + return { action: "update" } as const; + }), + read: Effect.fn(function* ({ output }) { + return output; + }), + reconcile: Effect.fn(function* ({ id, news }) { + // Start (or adopt) a local Postgres for this database and + // hand back the same attribute shape the live provider emits. + const server = yield* myDevPostgres.ensure(id); + return { + databaseId: server.instanceName, + databaseName: resolveId(news.project), + directConnectionString: Redacted.make(server.url), + databaseUrl: Redacted.make(server.url), + status: "ready", + // pooled/accelerate/origin fields are optional — omit what + // your emulator doesn't have. + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* myDevPostgres.destroy(output.databaseId); + }), +}); + +const myEmulatorProviders = (): Prisma.PrismaLocalProviders => + Layer.mergeAll(devDatabase, devConnection, devCompute /* … */); + +// Deploy is untouched; `alchemy dev` runs on your emulators: +Prisma.providers({ dev: myEmulatorProviders() }); +``` + +And a framework that owns mode selection entirely skips `providers()` and composes the exported live layer with its own local one: + +```ts +const providers = isDev ? myEmulatorProviders() : Prisma.liveProviders(); +``` + ## A locked state backend: `State/PostgresState` `Compute`'s recovery docs tell users to "use a durable, locked state backend" — and no in-tree backend has locking. This adds one on the dependency the repo already carries (`pg`): From 7d61d45daa98b167553e036f36f8406adcc1a3b1 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 20:25:42 +0200 Subject: [PATCH 08/22] feat(lowering): adopt upstream alchemy/Prisma compute resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ComputeService/Deployment/EnvironmentVariable with upstream App/Deployment/EnvironmentVariable (not composite Compute: the COMPOSER_*_ORIGIN self-edge needs the App created before env rows, and Compute owns env rows in a map our per-key state cannot migrate into). The env->deployment ordering edge rides the app prop as an Output (deployment-edge.ts) — riding artifactPath would leave the diff unresolved for brand-new env rows and silently skip code deploys; proven by tests driving alchemy's real Output machinery. Legacy compute rows migrate on read incl. poison-row neutralization; the platform DATABASE_URL is no longer overwritten (system-managed; authoring-side ban remains). Local target rebinds the emulators to upstream classes. Known regressions recorded in design notes: env value changes no longer redeploy (upstream ask filed) and App delete retry budget is ~3.75s (upstream ask filed). Part of alchemy-provider-adoption (TML-3155). Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 161 +++- docs/design/03-domain-model/glossary.md | 25 +- docs/design/03-domain-model/layering.md | 6 +- .../05-prisma-cloud/alchemy-lowering.md | 134 ++-- docs/design/05-prisma-cloud/pdp-data-model.md | 15 +- docs/design/10-domains/config-params.md | 2 +- docs/design/10-domains/core-model.md | 84 +- docs/design/10-domains/local-dev.md | 5 +- docs/guides/deploying.md | 28 +- gotchas.md | 8 +- .../src/__tests__/compute-scoped-env.test.ts | 18 +- .../0-lowering/local-target/src/compute.ts | 186 ++++- .../0-lowering/local-target/src/providers.ts | 16 +- .../src/__tests__/ComputeService.test.ts | 251 ------ .../src/__tests__/EnvironmentVariable.test.ts | 168 ---- .../lowering/src/compute/ComputeService.ts | 132 --- .../lowering/src/compute/Deployment.ts | 169 ---- .../src/compute/EnvironmentVariable.ts | 132 --- .../compute/__tests__/deployment-edge.test.ts | 202 +++++ .../lowering/src/compute/deployment-edge.ts | 46 ++ .../lowering/src/exports/compute.ts | 4 +- .../0-lowering/lowering/src/exports/index.ts | 6 +- .../0-lowering/lowering/src/providers.ts | 50 +- .../state/__tests__/legacy-postgres.test.ts | 365 --------- .../state/__tests__/legacy-resources.test.ts | 759 ++++++++++++++++++ .../lowering/src/state/legacy-postgres.ts | 156 ---- .../lowering/src/state/legacy-resources.ts | 331 ++++++++ .../0-lowering/lowering/src/state/service.ts | 10 +- .../src/__tests__/control-lowering.test.ts | 243 +++--- .../target/src/control/extension.ts | 44 +- .../target/src/descriptors/compute.ts | 76 +- .../target/src/descriptors/shared.ts | 6 +- .../1-extensions/target/src/param.ts | 15 +- .../1-extensions/target/src/preflight.ts | 5 +- .../1-extensions/target/src/secret.ts | 15 +- .../1-extensions/target/src/serializer.ts | 12 +- scripts/ci-cleanup-utils.ts | 9 +- .../integration/test/local-dev.integration.ts | 14 +- 38 files changed, 2136 insertions(+), 1772 deletions(-) delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md index 8f2675bb..437dee2b 100644 --- a/.drive/projects/alchemy-provider-adoption/design-notes.md +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -36,10 +36,167 @@ alchemy's profile store. - **Keep our six resources**: rejected — the spike showed upstream is strictly more hardened on deploy lifecycle and we'd keep paying API drift. +## Decision: the compute family adopts App + Deployment + EnvironmentVariable, not Compute + +Decided in slice 2, with the descriptor rewiring in front of us. Composer binds +upstream's three low-level resources; `Prisma.Compute` is not used at all. + +**What decided it — a dependency cycle Compute cannot express.** Every Compute +service gets a `COMPOSER_
_ORIGIN` environment row whose value is that +same service's own platform-assigned endpoint domain (ADR-0039; the value +function is `selfOriginValue` in `control/extension.ts`). `Prisma.Compute` is +one resource that owns the app, its environment rows, and its deployment +together, so that row would be an input of the very resource that produces the +domain — a self-edge. Alchemy's planner fails such a cycle unless the resource +implements `precreate` to signal an attribute early, and no Prisma provider +implements `precreate`. Splitting the app out is what makes the wiring legal: +`Prisma.App` is created in `provision` and hands out `appEndpointDomain` before +any environment row is written, and `Prisma.Deployment` is created afterwards +in `deploy`. The same split is what lets one service's row carry another +service's origin without ordering the two deployments against each other. + +**Three more reasons, none of them decisive alone.** + +- *Environment ownership.* Compute manages the rows itself, keyed by an + `environmentVariableIds` map it stores in its own attributes, and refuses any + row in scope that is not in that map. Migrating Composer's existing + per-key `EnvironmentVariable` state rows into one Compute resource's map has + no honest mapping; keeping them as resources does. +- *ADR-0005.* Compute carries build, framework detection, entrypoint inference, + and effect-native bundling. `artifactPath` bypasses all of it, but the + bypass is a prop value, not a structural guarantee. `Prisma.Deployment` has + no build path at all to fall through to. +- *The local emulators.* Compute is a `Platform` (runtime context, bindings, + dev process spawning). The three low-level classes are plain resources, which + the emulator providers bind to exactly as they bound Composer's own three. + +**What we give up by not taking Compute:** preview/stable health checks, +automatic rollback, and — the one that matters — environment values folded into +the fingerprint that decides whether a new deployment is needed. See below. + +## The environment→deployment edge after the swap (PRO-211) + +Upstream's `Prisma.Deployment` has no `environment` prop, so the edge rides +`app`: the descriptor builds that prop as an expression over the app id AND +every environment row's id, resolving to the app id itself +(`compute/deployment-edge.ts`). Alchemy derives its dependency graph from the +resource references a prop's value is built from, so every variable write is +scheduled before the deployment is created. That is the ordering PRO-211 needs, +and the ordering is what `docs/design/05-prisma-cloud/alchemy-lowering.md` +records as the edge's job. + +**`app` is the only prop that can carry it**, and this is not a style +preference. Upstream's diff reads `{portMapping, skipCodeUpload, artifactPath, +artifactContentType}` as one block and returns "no opinion" the moment any of +them is unresolved (`Deployment.ts:361-367`). A brand-new variable has no +persisted state, so the planner resolves its reference to a bare resource +expression (`Plan.ts:369-371`) — meaning a deploy that adds a variable would +leave that whole block unresolved, the artifact comparison would never run, the +engine would fall back to a plain update, and reconcile would keep the running +deployment *while recording the new artifact's fingerprint as deployed*. The +code change would be dropped, and every later deploy would agree it had already +shipped. `app` sits outside that block and its own check treats an unresolved +app as unchanged (`Deployment.ts:376-378`, `concreteIdsChanged`). The first +implementation of this slice used `artifactPath` and had exactly that defect; +`compute/__tests__/deployment-edge.test.ts` fails if it ever comes back, because +it drives the real Output machinery and upstream's real diff rather than +eager-collapse stubs. + +What does NOT survive the swap is a side effect the old provider had: because +Composer's deleted `Deployment` created a brand-new deployment on every +reconcile, a changed environment *value* shipped a new deployment as well. With +upstream, an unchanged artifact plans an update, and its reconcile re-uses the +existing deployment — so a value-only change reaches the platform's variable +row but not the running deployment until the next artifact change. + +We chose not to reproduce it, because every available mechanism is worse than +the gap: + +- Upstream recreates a deployment only when its artifact fingerprint moves. + Feeding the environment into that fingerprint means hashing the desired + values into Alchemy state — which the lowering doc rejects outright ("a hash + of a secret is itself a leak"), and which upstream itself only does inside + `Compute`, where the fingerprint is stored `Redacted`. +- Making the fingerprint move without hashing values (a per-generation artifact + hard link, or a generation counter in the deployment's logical id) works only + through upstream's *path-string* comparison, which upstream could drop at any + time — the fix would disappear silently. + +The honest fix is upstream's: either a `Prisma.Deployment` prop that takes +inputs the deployment should be recreated for, or `Compute`'s env-in-fingerprint +made available to the low-level resource. Slice 3 is the place to offer it. + +## The poison DATABASE_URL rows are gone + +`application.provision` used to overwrite the platform's seeded `DATABASE_URL` +and `DATABASE_URL_POOLED` with `"-"` so nothing could rely on the platform +default. The platform marks both system-managed, and upstream's +`EnvironmentVariable` refuses to manage a system-managed variable, so those +writes are removed rather than reshaped (they would fail the deploy). What +still holds the line is the ban at the authoring end: `param.ts` and +`secret.ts` reject both names, so no Composer-written row can carry one, and +`configKey` puts every Composer row in the `COMPOSER_` namespace. Existing +poison state rows are neutralised on read (see `state/legacy-resources.ts`) +so their deletion never touches the platform's own variable. + +Residual: an app that reads `process.env.DATABASE_URL` on Prisma Cloud now gets +the platform's real default rather than a value that fails loudly. + +## What the swap costs us, precisely + +Two behaviours got worse, and neither is mitigated on our side. + +**App delete retry budget: 5 minutes → about 4 seconds.** Composer's deleted +`ComputeService` provider retried the platform's "did not reach a delete-safe +state" 409 on an exponential schedule capped at 5 minutes. Upstream's +`destroyApp` (`ComputeLifecycle.ts:276-310`) retries any conflict 5 times with +250ms · 2^attempt between them — 3.75 seconds of waiting in total — and it does +NOT drain the app's deployments first; it deletes the App and relies on the +platform's cascade. Alchemy does delete a *tracked* `Prisma.Deployment` before +the App that owns it, because the resource graph orders them, but any untracked +deployment still winding down can still 409 the App delete past that budget. A +destroy of a stage that was serving traffic seconds earlier is the case to +watch. + +**Environment-value change no longer redeploys.** Covered above. + +## Upstream asks (slice 3) + +- **A `Prisma.Deployment` prop for "recreate when these inputs change."** + Without one, the environment cannot be part of what identifies a deployment, + and the change-propagation gap above stays. `Compute` already folds `env` + into its fingerprint and stores that fingerprint `Redacted`; the low-level + resource needs the same seam. +- **Raise or make configurable the App delete-retry budget** (or drain the + app's deployments before deleting it). +- **Export `PrismaUploadClient` / open the `alchemy/Prisma/Internal/*` subpath.** + Its package export is explicitly `null`, so the scoped upload client cannot be + composed privately by an outside stack; the only alternative is overriding the + ambient `HttpClient`, which is a much blunter instrument. + +## Why no environment-derived fingerprint exists yet (the search, recorded) + +Everything an `EnvironmentVariable` exposes was checked for "moves when the +value moves": + +- `updatedAt` moves on EVERY deploy, not on every change: upstream's diff + returns an update whenever the desired value is resolved, to heal + out-of-band drift (`EnvironmentVariable.ts:290-296`), and reconcile then + PATCHes unconditionally (`:378-386`). Folding it into a deployment prop would + restore Composer's OLD behaviour of shipping a new deployment on every single + deploy — not value-change detection. +- `valueKid` identifies the encryption key, not the value; it carries no change + semantics. +- The plaintext is write-only and never read back, so nothing observable + distinguishes "same value re-applied" from "new value". + +The durable statement: **the only attribute that moves at all fires on every +deploy.** Any real fix must come from the deployment side. + ## Open questions -Tracked in spec.md (Compute vs App+Deployment; state-migration mechanics; -first released beta). +Tracked in spec.md (state-migration mechanics; first released beta). The +Compute-vs-App+Deployment question is settled above. ## References diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 988a2d80..1cbffef0 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -375,17 +375,16 @@ is in `layering.md`; this is the term-by-term catalogue. `→` **Topology / implicit root Module**. - **Resource\** — a managed entity with a string type tag, desired-input **Props**, and cloud-returned **Attributes**. Declared, then - `yield*`-ed. Ours: `Prisma.Project`, `Database`, `Connection`, - `ComputeService`, `Deployment`, `EnvironmentVariable`. - `→` a **Service** lowers to `ComputeService` + `Deployment` (+ - `EnvironmentVariable`); a first-class **Resource** (Postgres) lowers to - `Project` + `Database` + `Connection`. + `yield*`-ed. Composer binds upstream alchemy's: `Prisma.Project`, `Database`, + `Connection`, `App`, `Deployment`, `EnvironmentVariable`. + `→` a **Service** lowers to `App` + `Deployment` (+ `EnvironmentVariable`); a + first-class **Resource** (Postgres) lowers to `Database` + `Connection`. - **Props** — the desired configuration passed at declare time; diffed against - the last deploy to detect change. (We put the artifact's `artifactHash` in - Props so a rebuild registers as a change.) `→` a node's **Inputs** + + the last deploy to detect change. (A rebuild registers as a change because the + artifact is content-addressed: new bytes, new `artifactPath`.) `→` a node's **Inputs** + **Configuration**. -- **Attributes / Output\** — values the cloud returns (`deployedUrl`, - `versionId`, ids); lazy references that flow into other Resources' Props. +- **Attributes / Output\** — values the cloud returns (`appEndpointDomain`, + `deploymentId`, ids); lazy references that flow into other Resources' Props. Resource-to-resource wiring is Output → Props. `→` a node's **Outputs**; a **connection** (Output→Input) lowers to Output→Props, plus an `EnvironmentVariable` when the consumer reads it at runtime (what `AUTH_URL` @@ -417,9 +416,11 @@ These two Alchemy concepts exist but our stack does not use them — and that ga is where the framework's own binding layer gets built. - **Platform** — Alchemy's Resource-that-carries-runtime-code (Cloudflare - Worker, AWS Lambda, Container). We model Prisma Compute as **ordinary - Resources** (`ComputeService` + `Deployment` + artifact) instead, because - Compute isn't an Alchemy-native platform. + Worker, AWS Lambda, Container). Alchemy's `Prisma.Compute` is one, but + Composer lowers to the **ordinary Resources** (`App` + `Deployment` + + artifact) instead — see the compute-family decision in the adoption notes: + a service's own origin is an input to its own environment, which one + composite resource cannot express. - **Binding** (`bind()`) — Alchemy's "the binding *is* the client" for a Platform: one call emits permissions + env and hands back a typed SDK client. We do **not** use it. The framework's binding/DI (capability `Tag` + `Layer` + diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index 209210a3..4e0a2d43 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -23,8 +23,8 @@ resource graph, which deploys to the cloud. wires, and provisions. Nouns: Resource, Platform, Binding, Layer, Provider, Stack, Config. The framework adopts Alchemy's *definition language*; the apply *engine* is an open question (see below). -- **Hosting plane (Prisma Cloud)** — what actually runs. Nouns: ComputeService / - ComputeVersion, Database (1:1 within an Environment), Stream, endpoint. Prisma +- **Hosting plane (Prisma Cloud)** — what actually runs. Nouns: App / + Deployment, Database (1:1 within an Environment), Stream, endpoint. Prisma Cloud is *one* target; another target's pack maps the same authoring nouns to its own hosting primitives. The framework's deploy report calls a thing on this plane a **Deployment entity** (`DeployedEntity`): its kind, platform id, @@ -35,7 +35,7 @@ resource graph, which deploys to the cloud. | Authoring (Prisma Composer) | Provisioning (Alchemy/Effect) | Hosting (Prisma Cloud) | | --- | --- | --- | | **Module** (bounded context) | a subgraph: Resources/Platforms + a Layer exposing its ports | **no single object** — spans Compute services + a DB schema slice + streams + endpoints | -| **Service** (your code; entrypoint + ingress) | Platform (compute Resource running the bundle) | ComputeService → ComputeVersion (tar.gz bundle + manifest + endpoint) | +| **Service** (your code; entrypoint + ingress) | Platform (compute Resource running the bundle) | App → Deployment (tar.gz bundle + manifest + endpoint) | | **Resource** (managed lifecycle, state-first) | Alchemy Resource + Provider (`reconcile`/`delete`/…); Postgres via the Prisma Postgres provider | a Database (1:1 in an Environment), bucket, cache, or provisioned third-party | | **Input/Output — communication** (request/response, stream) | Binding (RPC/HTTP client; stream pub/sub) | endpoint URL + injected client; stream | | **Data Input** (method TCP/HTTP + contract) | data binding to a Postgres Resource | connection injected, scoped by contract | diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index b4f6efdc..8291cab9 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -1,10 +1,16 @@ -# Alchemy ↔ PDP — the resources we define and how they map +# Alchemy ↔ PDP — the resources we bind and how they map -The Alchemy resource types `packages/prisma-alchemy` defines over the +The Alchemy resource types Composer lowers to over the [PDP data model](pdp-data-model.md), the mapping in both directions, and the lowering graphs — including the correction that makes deploy ordering a property of the dependency graph rather than luck. +The postgres family (`Project`, `Database`, `Connection`) and the compute +family (`App`, `Deployment`, `EnvironmentVariable`) are **upstream alchemy's** +(`alchemy/Prisma`), not ours: Composer registers their providers and binds their +props. What `@internal/lowering` still owns is the artifact packager, the +buckets, `ServiceKey`, and the hosted state store. + ## Placement: one Project per application, one Branch per stage A PDP Project is a **shared config namespace** (every App on a branch snapshots @@ -36,23 +42,24 @@ Alchemy only diffs and provisions the resources *inside* a (Project, Branch), never the container itself (see [§ Stages and container resolution](#stages-and-container-resolution)). -## `DATABASE_URL` is forbidden — and actively poisoned +## `DATABASE_URL` is forbidden — and left to the platform The platform writes `DATABASE_URL` / `DATABASE_URL_POOLED` templates pointing at a project's default database — a convenience for hand-provisioned single services, and precisely the kind of **implicit ambient config the framework -exists to eliminate**. The framework never reads it, never depends on it, and -makes reliance on it impossible: when the framework provisions a Project, it -**writes user-level -`DATABASE_URL` and `DATABASE_URL_POOLED` variables with a poison value** (`"-"` — -a garbage value any direct reader fails to connect with; the API rejects an empty -string, `"String must contain at least 1 character"`, verified at the R4 deploy -proof). User-set values -permanently override the platform templates (`wireDefaultDatabaseUrl` leaves -them untouched), so nothing deployed by the framework can ever quietly work -off the default again. Every database URL a service consumes is an explicit, -per-service -variable the pack's `serialize` writes under its own named key. +exists to eliminate**. The framework never reads it and never depends on it: +every database URL a service consumes is an explicit, per-service variable the +pack's `serialize` writes under its own named key, inside the `COMPOSER_` +namespace. + +The framework used to go further and overwrite both platform variables with a +garbage value, so that a direct reader failed loudly. It no longer does. The +platform marks those variables system-managed, and alchemy's +`EnvironmentVariable` refuses to manage a system-managed variable at all — an +overwrite would fail the deploy rather than protect anyone. What remains is the +ban at the authoring end: `param.ts` and `secret.ts` reject both names, so no +Composer-written row can carry one. A service that reads `process.env.DATABASE_URL` +behind the framework's back now reads the platform's own value. ## The resource inventory @@ -61,12 +68,12 @@ it manages whatever a provider package registers). | Our resource | PDP entity it manages | Props (in) | Outputs (out) | Notes | | --- | --- | --- | --- | --- | -| `Project` | Project | workspaceId, name | id | **one per Prisma Composer application**; the poison `DATABASE_URL` variables are written at provision (see above) | -| `Database` | Database | projectId, name | id, connection info | one per Module-provisioned postgres resource; never the project default; created project-scoped, then attached to a named stage's Branch by a follow-up `PATCH` (the create body doesn't accept `branchId`) | -| `Connection` | database connection info | databaseId | url | direct/pooled endpoints; the url is written as the service's own named variable via the pack's `serialize` | -| `ComputeService` | App | projectId, name, region, branchId? | id | `branchId` in the create body targets a named stage's Branch directly; omitted, PDP attaches it to the Project's default (production) Branch | -| `EnvironmentVariable` | ConfigVariable | projectId, class, key, value, branchId? | id | production-class with no `branchId` on the default stage; preview-class with `branchId` on a named stage | -| `Deployment` | Deployment (ComputeVersion) + Promotion | computeServiceId, artifactPath, artifactHash, port, **environment** (the env-var records the version boots with — see the graphs below) | versionId, deployedUrl | provider reconcile: create version → upload tar.gz → start → poll until running → promote; `deployedUrl` read **post-promote** (create-time domain is a placeholder — PRO-200) | +| `Prisma.Project` | Project | name | projectId | **one per Prisma Composer application**; resolved by the CLI before Alchemy runs, so no lowering yields one | +| `Prisma.Database` | Database | project, name?, region, branchId? | databaseId, connection strings | one per Module-provisioned postgres resource; never the project default; a branch-attached database is created with `branchId` and no display name (upstream refuses the combination — see [deploying.md](../../guides/deploying.md)) | +| `Prisma.Connection` | database connection info | database, name | connectionId, directConnectionString | Composer binds the DIRECT string explicitly; upstream's `databaseUrl` is pooled-first | +| `Prisma.App` | App | project, displayName, regionId, branchId? | appId, appEndpointDomain | `branchId` targets a named stage's Branch; omitted, upstream attaches the App to the project's default (production) Branch. `appEndpointDomain` is available at provision — that is what a service's own origin is read from | +| `Prisma.EnvironmentVariable` | ConfigVariable | project, class, key, value (Redacted), branchId? | environmentVariableId | production-class with no `branchId` on the default stage; preview-class with `branchId` on a named stage. Values are write-only, so upstream re-applies the desired one on every deploy | +| `Prisma.Deployment` | Deployment (ComputeVersion) + Promotion | app, artifactPath, artifactContentType, portMapping, start, promote | deploymentId, appEndpointDomain | provider reconcile: create → upload tar.gz → start → poll until running → promote; `appEndpointDomain` read **post-promote** (create-time domain is a placeholder — PRO-200). It is replaced, not updated, when its artifact fingerprint moves | What we deliberately do **not** model yet, and where it will bite: **Promotion** as a standalone resource (the Deployment provider @@ -113,7 +120,8 @@ logic is untouched. resolved and its lifecycle managed by the CLI's container-resolution client, outside the Alchemy graph entirely (see [§ Stages and container resolution](#stages-and-container-resolution)). - `serviceEndpointDomain` surfaces only as `Deployment.deployedUrl`. + `serviceEndpointDomain` surfaces as `App.appEndpointDomain` (before the first + deploy) and `Deployment.appEndpointDomain` (post-promote). ## The lowering graphs @@ -137,54 +145,62 @@ flowchart LR ```mermaid flowchart TB subgraph P [Project: storefront-auth] - POISON["EnvironmentVariable(DATABASE_URL = poison)"] DBa[(Database auth-db)] --> Ca[Connection] -- url --> EVa["EnvironmentVariable(AUTH_DB_URL)"] DBs[(Database storefront-db)] --> Cs[Connection] -- url --> EVs["EnvironmentVariable(STOREFRONT_DB_URL)"] - Sa[ComputeService auth] --> Da[Deployment_a] - Ss[ComputeService storefront] --> Ds[Deployment_s] - EVa -- record ref --> Da - EVs -- record ref --> Ds - Da -- deployedUrl --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] - EVu -- record ref --> Ds + Sa[App auth] --> Da[Deployment_a] + Ss[App storefront] --> Ds[Deployment_s] + EVa -- id ref --> Da + EVs -- id ref --> Ds + Da -- appEndpointDomain --> EVu["EnvironmentVariable(STOREFRONT_AUTH_URL)"] + EVu -- id ref --> Ds end ``` How the pieces map: -- **The application** lowers to one `Project`, provisioned first, with the - poison `DATABASE_URL` variables written immediately (nothing downstream can - depend on the default). -- **Each service** lowers to a `ComputeService → Deployment` chain plus its own +- **The application** lowers to one `Project`, resolved by the CLI before + Alchemy runs. It provisions no variables of its own. +- **Each service** lowers to an `App → Deployment` chain plus its own `Database → Connection`, whose url is written as that service's **explicitly named** variable — the same `serialize` path as any other config value. -- **The connection** lowers to two edges: the producer's `deployedUrl` flows - into a named `EnvironmentVariable`, and that variable's **record reference - flows into the consumer's `Deployment`** via its `environment` prop. -- Every `EnvironmentVariable` a Deployment boots with appears in its - `environment` prop — database URLs and connection URLs alike — so the version +- **The connection** lowers to two edges: the producer's endpoint domain flows + into a named `EnvironmentVariable`, and that variable's **id flows into the + consumer's `Deployment`** through its `artifactPath` prop. +- Every `EnvironmentVariable` a Deployment boots with is threaded into its + `artifactPath` — database URLs and connection URLs alike — so the deployment depends on its config being written first. -- The Deployment's `port` prop rides the same seam: `serialize` resolves the - service's `port` param from the typed Config and surfaces it in its outputs, - and `deploy` routes the platform to it — so the routed port and the `PORT` - the app binds trace to one value and cannot drift. - -The `environment` prop is essential and mirrors PDP's own dataflow — the -version-create call literally contains the materialized env map, so the -environment is genuinely an input to a version (see the +- The Deployment's `portMapping.http` rides the same seam: `serialize` resolves + the service's `port` param from the typed Config and surfaces it in its + outputs, and `deploy` routes the platform to it — so the routed port and the + `PORT` the app binds trace to one value and cannot drift. + +That ordering edge is essential and mirrors PDP's own dataflow — the +deployment-create call literally contains the materialized env map, so the +environment is genuinely an input to a deployment (see the [config lifecycle](pdp-data-model.md#the-config-lifecycle--what-is-resolved-when)). -The edge's job today is **ordering**: the variable write completes before -version-create, so the first version boots with a complete environment. Without -it the two race — the failure documented as PRO-211 in `gotchas.md`. - -**Change propagation is a deferred follow-up, not yet wired.** The env-var -resource exposes only `{ id, key }`, so a *value* change (a rotated URL) does not -diff the consumer `Deployment`, and no new version is created. The intended fix is -provenance-based — the consumer depends on the **source node's** version, never on -the value or a hash of it (a hash of a secret is itself a leak, and persisting the -value would put a credential in Alchemy state). It is narrow in practice: promoted -service endpoints are stable across producer redeploys, so a wire's value rarely -moves, and true secrets are platform-sourced and rotate through the platform, not -this edge (see the [config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)). +The edge's job is **ordering**: the variable write completes before +deployment-create, so the first deployment boots with a complete environment. +Without it the two race — the failure documented as PRO-211 in `gotchas.md`. + +**Why the edge rides `artifactPath`.** Upstream's `Prisma.Deployment` has no +`environment` prop (Composer's deleted one did). Alchemy derives its dependency +graph from the resource references a prop's *value* is built from, so the +descriptor builds `artifactPath` as an Output over every variable's id that +resolves to the path verbatim: the graph gains the edges, and upstream's +"replace when the artifact changed" diff sees exactly the path it would +otherwise have been handed. + +**Change propagation is a deferred follow-up, not wired.** A *value* change (a +rotated URL) reaches the platform's variable row but not the running +deployment: upstream recreates a deployment only when its artifact fingerprint +moves, and that fingerprint cannot include the values without persisting a hash +of them in state (a hash of a secret is itself a leak, and persisting the value +would put a credential in Alchemy state). The intended fix is upstream's — a +`Deployment` prop that names the inputs a deployment must be recreated for. It +is narrow in practice: promoted service endpoints are stable across producer +redeploys, so a wire's value rarely moves, and true secrets are platform-sourced +and rotate through the platform, not this edge (see the +[config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)). The framework's core constructs these edges when lowering a connection (the `serialize` env-var records thread into `deploy` through the service SPI); no pack diff --git a/docs/design/05-prisma-cloud/pdp-data-model.md b/docs/design/05-prisma-cloud/pdp-data-model.md index f518e5ae..b2d90473 100644 --- a/docs/design/05-prisma-cloud/pdp-data-model.md +++ b/docs/design/05-prisma-cloud/pdp-data-model.md @@ -83,13 +83,16 @@ Consequences Prisma Composer designs around: restart-on-config-change and no live re-resolution; a late-written variable never reaches an existing version. Propagating a changed value (e.g. a producer's new URL) into a consumer therefore means creating a new consumer - version — which the Alchemy graph does via a property diff (see - [alchemy-lowering.md](alchemy-lowering.md)). -3. **`DATABASE_URL` is not a separate mechanism.** It is a module-written + version — which Composer does NOT do today: a deployment is recreated when + its artifact changes, not when a variable's value does (see the + change-propagation note in + [alchemy-lowering.md](alchemy-lowering.md#the-lowering-graphs)). +3. **`DATABASE_URL` is not a separate mechanism.** It is a platform-written template flowing through the same materialization as user variables — a - convenience for hand-provisioned single services. Prisma Composer - forbids its use and poisons it at project provision (see - [alchemy-lowering.md](alchemy-lowering.md#database_url-is-forbidden--and-actively-poisoned)); + convenience for hand-provisioned single services. The platform owns it + (system-managed), and Prisma Composer refuses to bind or manage the name + (see + [alchemy-lowering.md](alchemy-lowering.md#database_url-is-forbidden--and-left-to-the-platform)); every database URL a service consumes is an explicit, service-named variable. 4. **Branch + class is the platform's environments model** (production templates vs preview templates + per-branch overrides) — the substrate diff --git a/docs/design/10-domains/config-params.md b/docs/design/10-domains/config-params.md index 46563581..bb7426bd 100644 --- a/docs/design/10-domains/config-params.md +++ b/docs/design/10-domains/config-params.md @@ -79,7 +79,7 @@ provision(web, { params: { appOrigin: envParam('APP_ORIGIN') } }); Both bindings suit an origin the operator genuinely knows — a custom domain they provisioned. A service's own *platform-assigned* origin is not a param at -all: the target resolves it and app code reads `ComputeService.origin()` +all: the target resolves it and app code reads the service's `origin()` (ADR-0039). Resolution order per param: binding, else `default`, else absent (only legal diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index f30e506e..2f56ec30 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -141,7 +141,7 @@ SPI and never see the graph, never sequence anything, never call another tool. | Path | Where it executes | Core does (the actor) | Pack / adapter tools used | | --- | --- | --- | --- | -| **provision** | deploy machine, via Alchemy | provision the application once (Project + poison vars), then walk the DAG realizing each service's host | `ExtensionDescriptor.application.provision`, then `ServiceLowering.provision` → identity (App) | +| **provision** | deploy machine, via Alchemy | provision the application once (the Project reference), then walk the DAG realizing each service's host | `ExtensionDescriptor.application.provision`, then `ServiceLowering.provision` → identity (App) | | **deploy** | deploy machine, via Alchemy | build each service's typed `Config`, have the pack encode it *first*, assemble via the build adapter, then ship the build | `ServiceLowering.serialize`, the **build adapter's `assemble`**, then `package` + `deploy` | | **run** | inside the bundle, in the VM | provide `hydrate` (typed `Config` → each dependency's binding); the node's `run` resolves + stashes config and boots the entry, the node's `load` hydrates on demand | the node's `run` / `load`, each connection's `hydrate` | @@ -150,7 +150,7 @@ running": provision creates identity-bearing infrastructure that changes only wh the topology changes; deploy ships a specific build (keyed by artifact hash) and changes on every push. The seam between them is the only window where connection config can land — an environment variable needs the consumer's projectId (exists -after provision) and is read at version start, never after (PRO-211: so it must +after provision) and is read at deployment create, never after (PRO-211: so it must exist before deploy). Core sequences `provision → serialize → package → deploy` for every service, which **eliminates the fresh-deploy config race by construction**, for every target pack ever written. One producer-side asymmetry: a @@ -504,8 +504,7 @@ type NodeDescriptor = | { readonly kind: "build"; assemble(input: AssembleInput): Promise } // The application's shared infrastructure: on Prisma Cloud, the one Project -// (the config namespace and lifecycle boundary) plus the poison DATABASE_URL -// variables. Its product (e.g. { projectId }) reaches every later SPI call of +// (the config namespace and lifecycle boundary). Its product (e.g. { projectId }) reaches every later SPI call of // the SAME extension via LowerContext.application. Core declares it `unknown` // and never reads it — the extension narrows with its own guard (ADR-0033). interface ApplicationDescriptor { @@ -549,8 +548,8 @@ interface ServiceLowering

{ package(ctx: LowerContext, input: PackageInput): Effect.Effect // deploy: ship the packaged artifact into the provisioned thing and run it - // (version → upload → start → promote). Consumes `serialized`'s env records - // via the Deployment's environment prop (the edge). Returns the node's + // (create → upload → start → promote). Builds the deployment's props out of + // `serialized`'s env records, which is the ordering edge. Returns the node's // outputs — what dependent nodes' connection params resolve against — plus // the entities it became on the deployment target, for the deploy report. deploy(ctx: LowerContext, provisioned: P, artifact: Artifact, @@ -611,7 +610,7 @@ type Outputs = Readonly> // `url` is present only when the descriptor declares the address publicly // reachable — a connection string is never a `url` (no core-level rule is safe: // `url` on compute is an endpoint, on postgres it would be a DSN). A descriptor -// constructing one holds `deployment.deployedUrl` — an Output, not a T, +// constructing one holds `deployment.appEndpointDomain` — an Output, not a T, // because the stack effect runs before Alchemy applies — so construction sites // traffic in `Input` (LoweredResult.entities above); apply // resolves them before any reader sees them. @@ -693,8 +692,8 @@ In the mixed case the hand-written stack supplies providers itself, yields a the nodes it composes. **Core's deploy-path sequencing** — the control flow no extension can misorder. -First, each extension's `application.provision` runs once (the Project reference, -with the poison `DATABASE_URL` variables). Then walk the graph in topological +First, each extension's `application.provision` runs once (the Project +reference). Then walk the graph in topological order (the module body's provision order; the dependency DAG Load validated). Each module-provisioned **resource** lowers exactly once via its extension's `nodes[type]` `{ kind: "resource" }` entry (e.g. one Database + Connection — its @@ -723,18 +722,19 @@ resource descriptions — Alchemy executes them in dependency order and runs unordered resources concurrently; declaration order is never consulted. So core realizes the sequence as **dependency edges**: most arise naturally from value flow (the env var consumes the project id and the producer's URL), and the one that -doesn't — deploy-after-serialize — exists because the `Deployment` resource -declares the environment records it boots with as a prop, which is PDP's own -dataflow restored (the version-create call literally contains the materialized env -map). See the lowering graphs in +doesn't — deploy-after-serialize — exists because the service descriptor builds +the `Deployment`'s props out of the environment records it boots with, which is +PDP's own dataflow restored (the deployment-create call literally contains the +materialized env map). See the lowering graphs in [`../05-prisma-cloud/alchemy-lowering.md`](../05-prisma-cloud/alchemy-lowering.md). This is what makes the fresh-deploy config race (PRO-211) structurally impossible on every target — the edge's **ordering** job. Its second job, **propagating** a -wire whose value genuinely changes, is not yet wired: the env-var resource exposes -only `{ id, key }`, so a changed value doesn't diff the consumer's `Deployment`. -The fix is provenance-based (the consumer depends on the *source node's* version, -never on the value or a hash of it) and is a deferred follow-up — narrow in -practice, since promoted service endpoints are stable across producer redeploys. +wire whose value genuinely changes, is not wired: a deployment is recreated only +when its artifact changes, and an env-var resource exposes no attribute a value +change moves. The fix belongs on the deployment resource (inputs it must be +recreated for), never on a hash of the values, and is a deferred follow-up — +narrow in practice, since promoted service endpoints are stable across producer +redeploys. Secrets are platform-sourced and rotate through the platform, not this edge (see the [config/secret glossary](../03-domain-model/glossary.md#configuration--config-and-secrets)). @@ -954,8 +954,8 @@ export const compute = (def: { // writer drifts. Keys are UPPER_SNAKE(address ▸ owner ▸ name): the address prefix // makes them unique per service within the shared project namespace (auth's db.url // ↔ AUTH_DB_URL); an empty address yields the address-free stash keys run() writes -// and load() reads (DB_URL). The platform's DATABASE_URL is never among them — it -// is forbidden and poisoned at project provision (see alchemy-lowering.md). +// and load() reads (DB_URL). The platform's DATABASE_URL is never among them — the +// name is rejected at authoring time (see alchemy-lowering.md). export const configKey = (address: string, d: ConfigDeclaration): string => /* UPPER_SNAKE(address ▸ owner ▸ name) */ // Boot readers/writers — process.env is touched ONLY here in the pack. @@ -1004,18 +1004,12 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor preflight: (input) => runPreflight(input), // Runs ONCE per lowering, before any node — REFERENCES the CLI-ensured Project - // (it no longer creates one) and writes the poison DATABASE_URL variables so - // nothing can rely on the platform default. Its product reaches this - // extension's own nodes via ctx.application. + // (it neither creates one nor provisions anything of its own). Its product + // reaches this extension's own nodes via ctx.application. application: { provision: () => - Effect.gen(function* () { + Effect.sync(() => { const projectId = o.projectId // set by the CLI in the deploy env; required - for (const key of ["DATABASE_URL", "DATABASE_URL_POOLED"]) { - yield* Prisma.EnvironmentVariable(`${key}-poison`, { - projectId, key, value: "-", class: "production", // "-": the API rejects "" (verified at the deploy proof) - }) - } return { projectId } satisfies CloudApplication }), }, @@ -1058,10 +1052,10 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // real string (from the CLI env, not a resource attribute). provision: ({ id, application }) => Effect.gen(function* () { - const svc = yield* Prisma.ComputeService(`${id}-svc`, { - projectId: projectIdOf(application), name: id, region: o.region ?? "us-east-1", + const svc = yield* Prisma.App(`${id}-svc`, { + project: projectIdOf(application), displayName: id, regionId: o.region ?? "us-east-1", }) - return { serviceId: svc.id, projectId: projectIdOf(application) } // : ComputeProvisioned + return { serviceId: svc.appId, projectId: projectIdOf(application) } // : ComputeProvisioned }), // Encode the typed Config into the runtime environment — one env var per @@ -1076,8 +1070,8 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor const value = d.owner === "service" ? config.service[d.name] : config.inputs[d.owner.input]?.[d.name] if (value === undefined) continue records.push(yield* Prisma.EnvironmentVariable(`${configKey(address, d)}-var`, { - projectId: provisioned.projectId, key: configKey(address, d), - value: encode(d.owner, value), class: "production", + project: provisioned.projectId, key: configKey(address, d), + value: Redacted.make(encode(d.owner, value)), class: "production", })) } const port = typeof config.service.port === "number" ? config.service.port : 3000 @@ -1091,21 +1085,23 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor package: ({ id }, { assembled, address }) => Effect.try(() => Prisma.packageComputeArtifact({ id, bundleDir: assembled.dir, appEntry: assembled.entry, address })), - // version → upload → start → promote. The environment prop references - // serialize's records, so the version depends on them (the edge that kills - // PRO-211). Returns a LoweredResult: `url` IS published here — a Compute - // service's deployed URL is a public endpoint, and this descriptor is the - // only party that knows it. Both fields are still Output refs until apply. + // create → upload → start → promote. The app id is read through + // serialize's env-var records, so the deployment depends on them — the + // ordering edge that kills PRO-211. Returns a LoweredResult: `url` IS + // published here — a Compute service's deployed URL is a public endpoint, + // and this descriptor is the only party that knows it. Both fields are + // still Output refs until apply. deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { const deployment = yield* Prisma.Deployment(`${id}-deploy`, { - computeServiceId: provisioned.serviceId, // Input accepts the Output ref — no cast - artifactPath: artifact.path, artifactHash: artifact.sha256, - environment: serialized.environment, port: serialized.port, + app: appAfterEnvironment(provisioned.serviceId, serialized.environment), + artifactPath: artifact.path, + artifactContentType: "application/gzip", + portMapping: { http: serialized.port }, start: true, promote: true, }) return { - outputs: { url: deployment.deployedUrl, projectId: provisioned.projectId }, - entities: [{ kind: "compute-service", id: provisioned.serviceId, url: deployment.deployedUrl }], + outputs: { url: deployment.appEndpointDomain, projectId: provisioned.projectId }, + entities: [{ kind: "compute-service", id: provisioned.serviceId, url: deployment.appEndpointDomain }], } }), }, diff --git a/docs/design/10-domains/local-dev.md b/docs/design/10-domains/local-dev.md index e6cbad65..a84adab2 100644 --- a/docs/design/10-domains/local-dev.md +++ b/docs/design/10-domains/local-dev.md @@ -132,7 +132,8 @@ into a deployment at version-create; locally, the `Deployment` provider performs the same join from the `EnvironmentVariable` records the lowering emitted — against props defined in this repo, once, not an emulation of a foreign API. One pinned deviation: the local join is scoped to the -service's own rows (plus the unprefixed poison rows), because the platform +service's own rows (plus any row outside the `COMPOSER_` namespace, which is +a platform-owned name by definition), because the platform diffs a deployment only on its own referenced rows while an app-wide local snapshot diffs on bytes — which restart-amplified dependents on every first-after-cold converge. No sanctioned reader consumes sibling rows, so @@ -167,7 +168,7 @@ semantics): | `Project` | a local identity record; no platform | | `Database` | a database on the local Postgres server (ORM `prisma dev`) | | `Connection` | the local connection URL | -| `ComputeService` | registers the service with the Compute emulator, which allocates its stable port; `endpointDomain = http://localhost:` — which makes origin (ADR-0039) work unchanged | +| `App` | registers the service with the Compute emulator, which allocates its stable port; `appEndpointDomain = http://localhost:` — which makes origin (ADR-0039) work unchanged | | `Deployment` | unpacks the artifact once per hash, materializes the env, and puts the deployment at the Compute emulator, which (re)starts the child | | `EnvironmentVariable` | a key→value row in the dev state store | | `Bucket` | a directory under `.prisma-composer/dev/buckets//`, served by the bucket emulator | diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index a6cfb62c..f07b516f 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -257,12 +257,28 @@ next deploy recreates everything under fresh state — either: Recreated apps get new generated URLs; anything pointing at the old ones needs updating. -## Upgrading to the upstream postgres resources - -Framework versions that manage databases through upstream alchemy's Prisma -provider adopt each environment's existing databases in place — deploy state -is migrated automatically on read, and production environments redeploy with -no changes to their databases or connections. +## Upgrading to the upstream Prisma resources + +Framework versions that manage databases, apps, deployments, and environment +variables through upstream alchemy's Prisma provider adopt each environment's +existing resources in place — deploy state is migrated automatically on read, +and production environments redeploy with no changes to their databases or +connections. + +**Every service ships one fresh deployment on the first deploy after the +upgrade**, with the artifact it is already running: the new provider identifies +a deployment by a fingerprint the old state rows cannot reproduce, so it ships +and promotes once, then goes quiet. Subsequent deploys are unchanged: a service +whose code has not changed is not redeployed. + +**`DATABASE_URL` and `DATABASE_URL_POOLED` are no longer overwritten.** The +framework used to replace the platform's seeded values with a placeholder so +that nothing could depend on them by accident. Those variables are owned by +Prisma Cloud, and the upstream provider refuses to manage a platform-owned +variable, so the framework leaves them alone. Nothing you write can carry those +names — `envSecret`/`envParam` still reject them — but an application that +reads `process.env.DATABASE_URL` directly now sees the platform's own value. +Read your database URL from the connection your service declares. **Stage (`--stage`) environments see two one-time effects on their first deploy after the upgrade**, because a branch-attached database can no longer diff --git a/gotchas.md b/gotchas.md index 5984231e..909d2d6c 100644 --- a/gotchas.md +++ b/gotchas.md @@ -202,7 +202,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **Cause (corrected after reading pdp-control-plane source).** Env vars are `ConfigVariable` rows **materialized into a version at version-create time** (`materializeBranchEnvVars` resolves the branch's map and hands it to Foundry with the version) and frozen there — version start does not re-resolve, and updating a variable touches only the row, never an existing version. So the race is the env-var POST vs the consumer's **version-create** call, issued by one apply with no dependency edge between them. Consequences: (1) a version created before the row exists never sees it, regardless of VM recycles; (2) config changes take effect only via a new version — there is no restart-on-config-change. _The original filing (and this entry's first version) claimed boot-time application and recycle-healing; the source model contradicts that. Our one observed recycle-heal is treated as a platform bug, not behavior to rely on._ -**Workaround.** Give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). In Prisma Composer this is the Connection primitive's corrected lowering: `Deployment` declares its expected environment records as a prop, which both orders the write first and redeploys the consumer when a value changes. Manual stacks: create the variable, then ship a new version. +**Workaround.** Give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). In Prisma Composer the consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). That is the **ordering** half only: a deployment is recreated when its artifact changes, so a changed variable *value* reaches the row but not the running version until the next artifact change — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. **Reproduction.** @@ -213,7 +213,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **References.** - Upstream: [PRO-211](https://linear.app/prisma-company/issue/PRO-211/compute-fresh-deploys-race-env-var-creation-against-first-version) -- Race + edge analysis: [`packages/app-cloud/src/target.ts`](packages/app-cloud/src/target.ts) (the corrected ordering comment — the `deploy`/`serialize` edge) +- Race + edge analysis: [`packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts`](packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts) (why the edge rides `app`, and what it does not do) - Related: [`dogfood-report.md`](dogfood-report.md) --- @@ -299,7 +299,9 @@ process.on("unhandledRejection", (e) => console.error(e)); **References.** - Upstream: [PRO-215](https://linear.app/prisma-company/issue/PRO-215/management-api-project-scoped-compute-service-create-collides-with) -- Fix: [`packages/alchemy/src/compute/ComputeService.ts`](packages/alchemy/src/compute/ComputeService.ts), [`packages/alchemy/src/postgres/Database.ts`](packages/alchemy/src/postgres/Database.ts) +- Fix: both resources are alchemy's `Prisma.App` / `Prisma.Database` now, which + create on the target Branch directly (`node_modules/alchemy/src/Prisma/App.ts`, + `.../Database.ts`) --- diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts index df7140e5..8975ea35 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts @@ -8,8 +8,10 @@ import { scopedEnvRows } from '../compute.ts'; * looks "changed" on the very next converge, purely from a sibling's row * landing afterward). `scopedEnvRows` is the fix — every service's * materialized env keeps only what it owns (`COMPOSER__*`) - * plus every row OUTSIDE the `COMPOSER_` namespace (the poison rows, which - * are deliberately app-wide). + * plus every row OUTSIDE the `COMPOSER_` namespace. Composer writes no + * unprefixed row itself — an unprefixed name is a platform-owned one — but the + * store is a plain file an operator can add to, and such a row is app-wide by + * nature, so the scoping keeps it. */ describe('scopedEnvRows()', () => { test("keeps only the service's own COMPOSER_ rows plus every non-COMPOSER_ row", () => { @@ -18,15 +20,13 @@ describe('scopedEnvRows()', () => { COMPOSER_WEB_ORIGIN: 'http://localhost:3000', COMPOSER_ORDERS_SERVICE_PORT: '3001', COMPOSER_ORDERS_SERVICE_CATALOG_URL: 'http://localhost:3002', - DATABASE_URL: '-', - DATABASE_URL_POOLED: '-', + SHARED_FEATURE_FLAG: 'on', }; expect(scopedEnvRows(all, 'web')).toEqual({ COMPOSER_WEB_PORT: '3000', COMPOSER_WEB_ORIGIN: 'http://localhost:3000', - DATABASE_URL: '-', - DATABASE_URL_POOLED: '-', + SHARED_FEATURE_FLAG: 'on', }); }); @@ -44,13 +44,13 @@ describe('scopedEnvRows()', () => { }); }); - test('a service with no rows of its own still gets every poison/app-wide row', () => { + test('a service with no rows of its own still gets every app-wide row', () => { const all = { COMPOSER_OTHER_PORT: '4000', - DATABASE_URL: '-', + SHARED_FEATURE_FLAG: 'on', }; - expect(scopedEnvRows(all, 'web')).toEqual({ DATABASE_URL: '-' }); + expect(scopedEnvRows(all, 'web')).toEqual({ SHARED_FEATURE_FLAG: 'on' }); }); test('an empty env store scopes to an empty object', () => { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts index 445da9de..0a0e7015 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -1,30 +1,87 @@ /** - * Local compute-cluster providers (local-dev spec § 4): `ComputeService` and - * `Deployment` become clients of the machine-scoped Compute emulator; - * `EnvironmentVariable` becomes a row in the dev env store; `Project` is a - * total-but-unused identity stand-in (no lowering yields one today). Every - * factory takes `LocalTargetProvidersInput` — the app name is - * `input.container`'s `input.appName` (see `app-name.ts`), `devDir` is + * Local compute-cluster providers (local-dev spec § 4): upstream alchemy's + * `Prisma.App` and `Prisma.Deployment` become clients of the machine-scoped + * Compute emulator; `Prisma.EnvironmentVariable` becomes a row in the dev env + * store; `Prisma.Project` is a total-but-unused identity stand-in (no lowering + * yields one today). Every factory takes `LocalTargetProvidersInput` — the app + * name is `input.container`'s `input.appName` (see `app-name.ts`), `devDir` is * `input.devDir`; nothing here reads `process.cwd()` or the environment. + * + * The emitted attributes match upstream's shapes. Fields the emulator has no + * answer for are left `null`/`undefined` where upstream's types allow it and + * nothing local reads them; the two that ARE read — the App's + * `appEndpointDomain` and the Deployment's — carry the emulator's local URL, + * which is what the deployed shapes carry too. */ +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import type { LocalTargetProvidersInput } from '@internal/core/config'; import { computeClient } from '@internal/dev-emulators'; -import { - ComputeService, - Deployment, - type DeploymentAttributes, - EnvironmentVariable, -} from '@internal/lowering/compute'; -import { Project } from 'alchemy/Prisma'; +import { App, Deployment, EnvironmentVariable, Project } from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; import { extractComputeArtifact } from './artifact-extract.ts'; import { envStore, secretsStore } from './dev-store.ts'; +const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ +function projectIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['projectId'] === 'string') return value['projectId']; + return 'local'; +} + +/** Reads an app id from upstream's `app` input: a plain string or a resolved `Prisma.App` attributes record. */ +function appIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['appId'] === 'string') return value['appId']; + throw new Error(`local Deployment received an app reference it cannot read: ${String(value)}`); +} + +/** + * The artifact's own sha256, streamed from its bytes. Upstream's + * `Prisma.Deployment` carries no artifact-hash prop (it fingerprints the file + * inside the provider), so the emulator hashes the file it is handed — the + * same digest `packageComputeArtifact` derived the path from, which is what + * names the unpacked artifact directory and identifies the deployment. + * + * Memoized on the file's identity (path, size, mtime): a converge re-runs + * every provider, artifacts run to hundreds of megabytes, and the watch loop + * converges on every save. + */ +const artifactHashes = new Map(); + +function artifactSha256(artifactPath: string): string { + const stat = fs.statSync(artifactPath); + const identity = `${artifactPath}:${String(stat.size)}:${String(stat.mtimeMs)}`; + const memoized = artifactHashes.get(identity); + if (memoized !== undefined) return memoized; + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(artifactPath, 'r'); + try { + const buffer = Buffer.allocUnsafe(1024 * 1024); + let read = fs.readSync(fd, buffer, 0, buffer.length, null); + while (read > 0) { + hash.update(buffer.subarray(0, read)); + read = fs.readSync(fd, buffer, 0, buffer.length, null); + } + } finally { + fs.closeSync(fd); + } + const digest = hash.digest('hex'); + artifactHashes.set(identity, digest); + return digest; +} + /** * The env-var key the app's own boot-side `deserialize()` reads for its * `port` service param — `COMPOSER_

_PORT`. Mirrors @@ -50,17 +107,17 @@ const COMPOSER_NAMESPACE_PREFIX = 'COMPOSER_'; /** * Scopes `env.json` to what THIS service is allowed to see: rows it owns * (`COMPOSER__*`) plus every row OUTSIDE the `COMPOSER_` - * namespace entirely — the poison `DATABASE_URL(_POOLED)` rows are - * deliberately unprefixed and app-wide (local-dev spec § 4's pinned parity - * note). The hosted platform materializes the app-wide row set into every + * namespace entirely — an unprefixed row is a platform-owned name, app-wide + * by nature (local-dev spec § 4's pinned parity note). The hosted platform + * materializes the app-wide row set into every * deployment but DIFFS a deployment only on its own referenced rows; an * app-wide LOCAL materialization restart-amplifies instead — an * early-deployed service's snapshot is incomplete on the first converge, * "completes" on the second, and diffs as changed. Scoping the content here * aligns local restart behavior with the platform's diff scope. The dropped * sibling rows have no sanctioned reader: `run()`/`load()` consume only - * own-address rows, and ambient sibling reads are exactly what the poison - * rows exist to punish. + * own-address rows, and an ambient sibling read is exactly the mistake the + * COMPOSER_ namespace exists to make impossible. */ export function scopedEnvRows( allRows: Readonly>, @@ -155,31 +212,43 @@ async function materializeEnv( } /** - * `ComputeService` → the Compute emulator: reserves (or returns) the - * service's stable port. `delete` is a no-op — instance removal belongs to - * `teardown` (`DELETE /apps/`), not per-resource Alchemy deletes. + * `Prisma.App` → the Compute emulator: reserves (or returns) the service's + * stable port. The app id here IS the service's own address, which + * `Deployment` slugs back into the emulator's id. `delete` is a no-op — + * instance removal belongs to `teardown` (`DELETE /apps/`), not + * per-resource Alchemy deletes. */ -export function LocalComputeServiceProvider( +export function LocalAppProvider( input: LocalTargetProvidersInput, -): Layer.Layer> { - const service: Provider.ProviderService = { +): Layer.Layer> { + const service: Provider.ProviderService = { list: () => Effect.succeed([]), - reconcile: ({ news }) => + reconcile: ({ id, news }) => Effect.tryPromise({ try: async () => { - const app = appNameOf(input.container); - const { url } = await computeClient().ensureService(app, slugServiceId(news.name)); - return { id: news.name, name: news.name, endpointDomain: url }; + const appName = appNameOf(input.container); + const name = news.displayName ?? id; + const { url } = await computeClient().ensureService(appName, slugServiceId(name)); + return { + appId: name, + name, + projectId: projectIdOfInput(news.project), + regionId: news.regionId ?? 'us-east-1', + branchId: news.branchId ?? null, + latestDeploymentId: null, + appEndpointDomain: url, + createdAt: DEV_TIMESTAMP, + } satisfies App['Attributes']; }, catch: (cause) => cause, }), delete: () => Effect.void, read: ({ output }) => Effect.succeed(output), }; - return Provider.effect(ComputeService, Effect.succeed(service)); + return Provider.effect(App, Effect.succeed(service)); } -/** `EnvironmentVariable` → a key/value row in `/env.json`. Parity with deploy: the poison `DATABASE_URL` rows land here like any other. */ +/** `Prisma.EnvironmentVariable` → a key/value row in `/env.json`. Upstream's value is `Redacted`; env.json holds the plain string the child process is given. */ export function LocalEnvironmentVariableProvider( input: LocalTargetProvidersInput, ): Layer.Layer> { @@ -190,9 +259,20 @@ export function LocalEnvironmentVariableProvider( try: async () => { await envStore(input.devDir).update((current) => ({ ...current, - [news.key]: news.value, + [news.key]: Redacted.value(news.value), })); - return { id: news.key, key: news.key }; + return { + environmentVariableId: news.key, + projectId: projectIdOfInput(news.project), + branchId: news.branchId ?? null, + class: news.class, + key: news.key, + value: news.value, + valueKid: '', + isManagedBySystem: false, + createdAt: DEV_TIMESTAMP, + updatedAt: DEV_TIMESTAMP, + } satisfies EnvironmentVariable['Attributes']; }, catch: (cause) => cause, }), @@ -212,10 +292,13 @@ export function LocalEnvironmentVariableProvider( } /** - * `Deployment` → unpacks the artifact once per hash, fetches the emulator's - * assigned port, materializes the child's full env (env store + secrets + - * the port override + `PATH`/`HOME`), and puts the deployment — the emulator - * (re)starts the child only when the hash or env actually changed. + * `Prisma.Deployment` → unpacks the artifact once per hash, fetches the + * emulator's assigned port, materializes the child's full env (env store + + * secrets + the port override + `PATH`/`HOME`), and puts the deployment — the + * emulator (re)starts the child only when the hash or env actually changed. + * `portMapping` is ignored on purpose: the emulator owns port allocation, and + * the child learns its port from `COMPOSER_
_PORT` in the env this + * provider materializes. */ export function LocalDeploymentProvider( input: LocalTargetProvidersInput, @@ -224,12 +307,16 @@ export function LocalDeploymentProvider( list: () => Effect.succeed([]), reconcile: ({ news }) => Effect.tryPromise({ - try: async (): Promise => { + try: async (): Promise => { const app = appNameOf(input.container); - const id = news.computeServiceId; - const emulatorId = slugServiceId(id); + const appId = appIdOfInput(news.app); + const emulatorId = slugServiceId(appId); + if (news.artifactPath === undefined) { + throw new Error('local Deployment requires an artifactPath — nothing to run.'); + } + const artifactHash = artifactSha256(news.artifactPath); - const artifactDir = path.join(input.devDir, 'artifacts', news.artifactHash); + const artifactDir = path.join(input.devDir, 'artifacts', artifactHash); if (!fs.existsSync(artifactDir)) { extractComputeArtifact(news.artifactPath, artifactDir); } @@ -240,12 +327,25 @@ export function LocalDeploymentProvider( await computeClient().putDeployment(app, emulatorId, { address, artifactDir, - artifactHash: news.artifactHash, + artifactHash, env, port, }); - return { deploymentId: news.artifactHash, deployedUrl: `http://localhost:${port}` }; + const url = `http://localhost:${port}`; + return { + deploymentId: artifactHash, + appId, + // The emulator has no Foundry: the artifact digest is the only + // identity a local deployment has, and it is what upstream's + // recovery-by-version lookup would be given. + foundryVersionId: artifactHash, + status: 'running', + previewDomain: null, + artifactHash, + appEndpointDomain: url, + createdAt: DEV_TIMESTAMP, + } satisfies Deployment['Attributes']; }, catch: (cause) => cause, }), @@ -271,7 +371,7 @@ export function LocalProjectProvider( projectId: 'local', projectName: news.name ?? id, workspaceId: 'local', - createdAt: '1970-01-01T00:00:00.000Z', + createdAt: DEV_TIMESTAMP, defaultRegion: null, databaseId: undefined, defaultConnectionId: undefined, diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts index cf694ffa..777d76bd 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/providers.ts @@ -8,13 +8,19 @@ import type { LocalTargetProvidersInput } from '@internal/core/config'; import { Providers } from '@internal/lowering'; import { Bucket, BucketKey } from '@internal/lowering/buckets'; -import { ComputeService, Deployment, EnvironmentVariable } from '@internal/lowering/compute'; -import { Connection, Database, Project } from 'alchemy/Prisma'; +import { + App, + Connection, + Database, + Deployment, + EnvironmentVariable, + Project, +} from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Layer from 'effect/Layer'; import { LocalBucketKeyProvider, LocalBucketProvider } from './bucket.ts'; import { - LocalComputeServiceProvider, + LocalAppProvider, LocalDeploymentProvider, LocalEnvironmentVariableProvider, LocalProjectProvider, @@ -28,7 +34,7 @@ export const localTargetProviders = (input: LocalTargetProvidersInput): Layer.La Project, Database, Connection, - ComputeService, + App, Deployment, EnvironmentVariable, Bucket, @@ -40,7 +46,7 @@ export const localTargetProviders = (input: LocalTargetProvidersInput): Layer.La LocalProjectProvider(input), LocalDatabaseProvider(input), LocalConnectionProvider(input), - LocalComputeServiceProvider(input), + LocalAppProvider(input), LocalDeploymentProvider(input), LocalEnvironmentVariableProvider(input), LocalBucketProvider(input), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts deleted file mode 100644 index 05038c40..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { - ComputeService, - ComputeServiceProvider, - deleteSafeRetrySchedule, - isDeleteNotSafeYet, -} from '../compute/ComputeService.ts'; -import { PrismaApiError } from '../http.ts'; - -const deleteNotSafeError = new PrismaApiError({ - status: 409, - message: JSON.stringify({ - error: { - code: 'client-error', - message: 'The deployment did not reach a delete-safe state after stop', - hint: 'The resource already exists or is in a conflicting state.', - }, - }), -}); - -describe('isDeleteNotSafeYet', () => { - test('classifies the delete-safe-after-stop error as retryable', () => { - expect(isDeleteNotSafeYet(deleteNotSafeError)).toBe(true); - }); - - test('does not classify an unrelated API error as retryable', () => { - const unauthorized = new PrismaApiError({ status: 401, message: '{"error":"unauthorized"}' }); - const notFound = new PrismaApiError({ status: 404, message: '{"error":"not found"}' }); - const serverError = new PrismaApiError({ status: 500, message: '{"error":"internal error"}' }); - - expect(isDeleteNotSafeYet(unauthorized)).toBe(false); - expect(isDeleteNotSafeYet(notFound)).toBe(false); - expect(isDeleteNotSafeYet(serverError)).toBe(false); - }); -}); - -describe('delete retry wiring (Effect.retry({ schedule, while }))', () => { - // Exercises the same `{ schedule, while: isDeleteNotSafeYet }` composition - // ComputeService's delete uses, swapping in a millisecond-scale schedule so - // the test doesn't wait on the real 2s-to-5min production backoff. - const fastSchedule = Schedule.spaced('1 millis'); - - test('retries a delete-not-safe-yet failure until it succeeds', async () => { - let attempts = 0; - const flaky = Effect.gen(function* () { - attempts++; - if (attempts < 3) return yield* Effect.fail(deleteNotSafeError); - return 'deleted'; - }); - - const result = await Effect.runPromise( - flaky.pipe(Effect.retry({ schedule: fastSchedule, while: isDeleteNotSafeYet })), - ); - - expect(result).toBe('deleted'); - expect(attempts).toBe(3); - }); - - test('does not retry a different error — it fails on the first attempt', async () => { - let attempts = 0; - const alwaysUnauthorized = Effect.gen(function* () { - attempts++; - return yield* Effect.fail( - new PrismaApiError({ status: 401, message: '{"error":"unauthorized"}' }), - ); - }); - - const outcome = await Effect.runPromiseExit( - alwaysUnauthorized.pipe(Effect.retry({ schedule: fastSchedule, while: isDeleteNotSafeYet })), - ); - - expect(outcome._tag).toBe('Failure'); - expect(attempts).toBe(1); - }); - - test('gives up once the delete-safe error persists past the overall timeout', async () => { - // A near-zero overall cap makes the "generous timeout" boundary itself - // fast to test: it should retry a couple of times and then still fail. - let attempts = 0; - const alwaysNotSafe = Effect.gen(function* () { - attempts++; - return yield* Effect.fail(deleteNotSafeError); - }); - - const shortCappedSchedule = Schedule.spaced('1 millis').pipe( - Schedule.upTo({ duration: '20 millis' }), - ); - - const outcome = await Effect.runPromiseExit( - alwaysNotSafe.pipe( - Effect.retry({ schedule: shortCappedSchedule, while: isDeleteNotSafeYet }), - ), - ); - - expect(outcome._tag).toBe('Failure'); - expect(attempts).toBeGreaterThan(1); - }); -}); - -describe('deleteSafeRetrySchedule', () => { - test('is a Schedule value wired into the delete provider', () => { - expect(Schedule.isSchedule(deleteSafeRetrySchedule)).toBe(true); - }); -}); - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** When set, GET /v1/apps/{appId} resolves to this — the observed path. */ - observed?: { id: string; name: string; appEndpointDomain?: string }; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering the ComputeService provider's - * endpoints (GET/POST for observe-or-create; PATCH is stubbed but should - * never be hit — reconcile no longer PATCHes), recording every call it - * receives — the container.test.ts fake-client idiom. `as unknown as - * ManagementApiClient` is acceptable here (test file — exempt from the - * no-bare-cast rule). - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string) => { - state.calls.push({ method: 'GET', path }); - if (path === '/v1/apps/{appId}') { - return Promise.resolve( - state.observed ? okResponse({ data: state.observed }) : notFoundResponse(), - ); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - if (path === '/v1/apps') { - return Promise.resolve( - okResponse({ data: { id: 'cs-created', name: String(init.body?.['displayName']) } }, 201), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - if (path === '/v1/apps/{appId}') { - return Promise.resolve(okResponse({ data: { id: 'cs-created', name: 'compute' } })); - } - throw new Error(`fakeClient: unexpected PATCH ${path}`); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - ComputeService.Provider.pipe( - Effect.provide(ComputeServiceProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { news: Record; output?: { id: string; name: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -describe('ComputeService reconcile — Branch via the create body', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [] }; - }); - - test('branchId set, no prior output: creates on the Branch, no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute', branchId: 'br-1' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'cs-created', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST']); - expect(state.calls[0]?.body).toEqual({ - displayName: 'compute', - projectId: 'proj-1', - branchId: 'br-1', - }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId set, prior output exists: observes only, no POST, no PATCH', async () => { - state.observed = { id: 'cs-existing', name: 'compute' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute', branchId: 'br-1' }, - output: { id: 'cs-existing', name: 'compute' }, - }); - - expect(result).toEqual({ id: 'cs-existing', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId unset, no prior output: creates without a branchId key, no PATCH', async () => { - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'cs-created', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['POST']); - expect(state.calls[0]?.body).toEqual({ displayName: 'compute', projectId: 'proj-1' }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); - - test('branchId unset, prior output exists: observes only, no POST, no PATCH', async () => { - state.observed = { id: 'cs-existing', name: 'compute' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', name: 'compute' }, - output: { id: 'cs-existing', name: 'compute' }, - }); - - expect(result).toEqual({ id: 'cs-existing', name: 'compute' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts deleted file mode 100644 index 32815268..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Cause from 'effect/Cause'; -import * as Effect from 'effect/Effect'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import { - EnvironmentVariable, - EnvironmentVariableProvider, -} from '../compute/EnvironmentVariable.ts'; - -interface RecordedCall { - method: 'GET' | 'POST' | 'PATCH'; - path: string; - body?: unknown; -} - -interface FakeState { - calls: RecordedCall[]; - /** Rows the own-row GET /{envVarId} resolves (keyed by id); absent → 404. */ - byId: Record; - /** What the list GET (project, class, key) returns as its `data` array. */ - listMatch: { id: string }[]; -} - -const okResponse = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -const notFoundResponse = () => ({ - data: undefined, - error: undefined, - response: new Response(null, { status: 404 }), -}); - -/** - * A stubbed `ManagementApiClient` covering the EnvironmentVariable provider's - * endpoints, recording every call — the ComputeService.test.ts idiom. `as - * unknown as ManagementApiClient` is acceptable here (test file — exempt from - * the no-bare-cast rule). - */ -const fakeClient = (state: FakeState): ManagementApiClient => { - const GET = (path: string, init: { params?: { path?: { envVarId?: string } } } = {}) => { - state.calls.push({ method: 'GET', path }); - if (path === '/v1/environment-variables/{envVarId}') { - const id = init.params?.path?.envVarId ?? ''; - const row = state.byId[id]; - return Promise.resolve(row ? okResponse(row) : notFoundResponse()); - } - if (path === '/v1/environment-variables') { - return Promise.resolve(okResponse({ data: state.listMatch })); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }; - - const POST = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'POST', path, body: init.body }); - return Promise.resolve( - okResponse({ data: { id: 'ev-created', key: String(init.body?.['key']) } }, 201), - ); - }; - - const PATCH = (path: string, init: { body?: Record } = {}) => { - state.calls.push({ method: 'PATCH', path, body: init.body }); - return Promise.resolve(okResponse({ ok: true })); - }; - - return { GET, POST, PATCH } as unknown as ManagementApiClient; -}; - -const getService = (state: FakeState) => - Effect.runPromise( - EnvironmentVariable.Provider.pipe( - Effect.provide(EnvironmentVariableProvider()), - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -const reconcile = async ( - state: FakeState, - input: { - news: Record; - output?: { id: string; key: string } | undefined; - }, -) => { - const svc = await getService(state); - return Effect.runPromise(svc.reconcile(input as unknown as Parameters[0])); -}; - -const reconcileExit = async ( - state: FakeState, - input: { news: Record; output?: { id: string; key: string } | undefined }, -) => { - const svc = await getService(state); - return Effect.runPromiseExit( - svc.reconcile(input as unknown as Parameters[0]), - ); -}; - -describe('EnvironmentVariable reconcile — restricted adoption (ADR-0029)', () => { - let state: FakeState; - - beforeEach(() => { - state = { calls: [], byId: {}, listMatch: [] }; - }); - - test('own prior row (output.id still exists): PATCHes it, no adoption GET-list', async () => { - state.byId['ev-mine'] = { id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: { id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }, - }); - - expect(result).toEqual({ id: 'ev-mine', key: 'COMPOSER_INGEST_STRIPEKEY' }); - // GET the own row, then PATCH it — never the (project,class,key) adoption list. - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls.filter((c) => c.path === '/v1/environment-variables')).toHaveLength(0); - }); - - test('a poison key with a pre-existing platform row is adopted and PATCHed', async () => { - state.listMatch = [{ id: 'ev-poison' }]; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'DATABASE_URL', value: '-' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'ev-poison', key: 'DATABASE_URL' }); - expect(state.calls.map((c) => c.method)).toEqual(['GET', 'PATCH']); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); - - test('a COMPOSER_ key with a pre-existing row it has no state for fails loudly, never overwrites', async () => { - state.listMatch = [{ id: 'ev-foreign' }]; - - const exit = await reconcileExit(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: undefined, - }); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - expect(Cause.pretty(exit.cause)).toContain('reserved COMPOSER_ key'); - } - // It observed the collision, then refused — no PATCH, no POST. - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - expect(state.calls.filter((c) => c.method === 'POST')).toHaveLength(0); - }); - - test('a COMPOSER_ key with no pre-existing row creates it', async () => { - state.listMatch = []; - - const result = await reconcile(state, { - news: { projectId: 'proj-1', key: 'COMPOSER_INGEST_STRIPEKEY', value: 'STRIPE_SECRET_KEY' }, - output: undefined, - }); - - expect(result).toEqual({ id: 'ev-created', key: 'COMPOSER_INGEST_STRIPEKEY' }); - const post = state.calls.find((c) => c.method === 'POST'); - expect(post?.body).toMatchObject({ - projectId: 'proj-1', - key: 'COMPOSER_INGEST_STRIPEKEY', - value: 'STRIPE_SECRET_KEY', - }); - expect(state.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts deleted file mode 100644 index 64914b70..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid, type PrismaApiError } from '../http.ts'; - -/** - * Stopping a deployment before the app that owns it can be - * deleted is asynchronous on the platform's side: DELETE can 409 with this - * message while the deployment is still winding down. Retrying blindly on - * every API error would mask real failures (bad auth, a genuinely conflicting - * state, etc.), so this only matches the platform's specific "not delete-safe - * yet" wording — everything else fails immediately, as before. - */ -export const isDeleteNotSafeYet = (error: PrismaApiError): boolean => - error.message.includes('did not reach a delete-safe state'); - -/** - * Backs off exponentially from 2s, capped at 5 minutes total — long enough - * for the platform to finish stopping the deployment, short enough to still - * fail loudly (rather than hang forever) if it never does. - */ -export const deleteSafeRetrySchedule = Schedule.exponential('2 seconds', 2).pipe( - Schedule.upTo({ duration: '5 minutes' }), -); - -/** Every region Prisma Compute serves — the runtime source of truth; `ComputeRegion` is derived from it so the two can never drift. */ -export const COMPUTE_REGIONS = [ - 'us-east-1', - 'us-west-1', - 'eu-west-3', - 'eu-central-1', - 'ap-northeast-1', - 'ap-southeast-1', -] as const; - -export type ComputeRegion = (typeof COMPUTE_REGIONS)[number]; - -export interface ComputeServiceProps { - /** The project that will own this compute service. */ - projectId: string; - name: string; - region?: ComputeRegion; - /** When set, the Branch this compute service is attached to (named-stage deploys). */ - branchId?: string; -} - -export interface ComputeServiceAttributes { - id: string; - name: string; - endpointDomain?: string; -} - -export type ComputeService = Resource< - 'PrismaComposer.ComputeService', - ComputeServiceProps, - ComputeServiceAttributes ->; - -/** A Prisma **Compute service** — the stable app identity behind a project. */ -export const ComputeService = Resource('PrismaComposer.ComputeService', { - aliases: ['Prisma.ComputeService'], -}); - -export const ComputeServiceProvider = () => - Provider.effect( - ComputeService, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as ComputeServiceAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - // Observe — an app is only findable by its saved id. - const observed = output?.id - ? yield* callOptional(() => - client.GET('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ) - : undefined; - if (observed) { - return { - id: observed.data.id, - name: observed.data.name, - endpointDomain: observed.data.appEndpointDomain, - }; - } - - // Create on the target Branch via the create body — NOT a later PATCH. - // App names are unique per Branch, so a create without a branchId - // lands on the default Branch and collides with the same-named - // production app there (a live-deploy find). - const created = yield* call(() => - client.POST('/v1/apps', { - body: { - displayName: news.name, - projectId: news.projectId, - ...(news.region && { regionId: news.region }), - ...(news.branchId !== undefined && { branchId: news.branchId }), - }, - }), - ); - return { - id: created.data.id, - name: created.data.name, - endpointDomain: created.data.appEndpointDomain, - }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ).pipe(Effect.retry({ schedule: deleteSafeRetrySchedule, while: isDeleteNotSafeYet })); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const s = yield* callOptional(() => - client.GET('/v1/apps/{appId}', { - params: { path: { appId: output.id } }, - }), - ); - return s - ? { id: s.data.id, name: s.data.name, endpointDomain: s.data.appEndpointDomain } - : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts deleted file mode 100644 index 6d22dac4..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts +++ /dev/null @@ -1,169 +0,0 @@ -import * as fs from 'node:fs'; -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, PrismaApiError } from '../http.ts'; -import type { EnvironmentVariable } from './EnvironmentVariable.ts'; - -export interface DeploymentProps { - /** The app this deployment targets. */ - computeServiceId: string; - /** Path to a PREBUILT artifact (tar.gz) to upload. */ - artifactPath: string; - /** - * sha256 of the artifact. Part of the props so a new build (new hash) - * registers as a change and forces a fresh deployment; a byte-identical - * `artifactPath` alone would diff as a no-op. - */ - artifactHash: string; - /** - * HTTP port the app listens on. Compute routes external HTTP to it - * (`portMapping.http`); without it the endpoint has no route and 404s. - */ - port?: number; - /** - * The env-var records this deployment boots with. The provider never reads - * this — PDP materializes the branch's ConfigVariables into the deployment - * itself at deployment-create. Its only job is the Alchemy dependency edge: - * order this Deployment after those writes, and force a new deployment when - * any upstream value changes (the environment edge that kills PRO-211 — - * see docs/design/05-prisma-cloud/alchemy-lowering.md). - */ - environment?: readonly EnvironmentVariable[]; -} - -export interface DeploymentAttributes { - deploymentId: string; - deployedUrl?: string; -} - -export type Deployment = Resource< - 'PrismaComposer.Deployment', - DeploymentProps, - DeploymentAttributes ->; - -/** - * A **deployment** of a Prisma app — creates a deployment, uploads - * its artifact, starts the VM, waits for it to run, then promotes it to the - * app's stable endpoint. - */ -export const Deployment = Resource('PrismaComposer.Deployment', { - aliases: ['Prisma.Deployment'], -}); - -export const DeploymentProvider = () => - Provider.effect( - Deployment, - Effect.gen(function* () { - const client = yield* ManagementClient; - - // `start` is asynchronous — the VM is not running when it returns. Poll - // the deployment until its status is `running` before promoting, or the - // promote call fails with 409 "not running". - const waitForRunning = (deploymentId: string) => - call(() => - client.GET('/v1/deployments/{deploymentId}', { - params: { path: { deploymentId } }, - }), - ).pipe( - Effect.flatMap((v) => - v.data.status === 'running' - ? Effect.void - : Effect.fail( - new PrismaApiError({ - status: 409, - message: `deployment ${deploymentId} is ${v.data.status}, not running`, - }), - ), - ), - Effect.retry(Schedule.spaced('2 seconds').pipe(Schedule.upTo({ duration: '2 minutes' }))), - ); - - return { - stables: [], - list: () => Effect.succeed([] as DeploymentAttributes[]), - reconcile: Effect.fn(function* ({ news }) { - // Every reconcile ships a new deployment: create → upload → start → - // wait-until-running → promote. There is no observe short-circuit — - // a props change (a new artifactHash) is what brought us here, so - // returning the previous deployment would strand the new build. - const created = yield* call(() => - client.POST('/v1/apps/{appId}/deployments', { - params: { path: { appId: news.computeServiceId } }, - body: news.port !== undefined ? { portMapping: { http: news.port } } : {}, - }), - ); - const deploymentId = created.data.id; - - if (created.data.uploadUrl) { - const uploadUrl = created.data.uploadUrl; - const artifact = yield* Effect.try({ - try: () => fs.readFileSync(news.artifactPath), - catch: (cause) => - new PrismaApiError({ - status: 0, - message: `failed to read artifact ${news.artifactPath}: ${String(cause)}`, - }), - }); - yield* Effect.tryPromise({ - try: async () => { - const res = await fetch(uploadUrl, { method: 'PUT', body: artifact }); - if (!res.ok) { - throw new PrismaApiError({ - status: res.status, - message: `artifact upload failed: ${res.status} ${res.statusText}`, - }); - } - }, - catch: (cause) => - cause instanceof PrismaApiError - ? cause - : new PrismaApiError({ status: 0, message: String(cause) }), - }); - } - - yield* call(() => - client.POST('/v1/deployments/{deploymentId}/start', { - params: { path: { deploymentId } }, - }), - ); - - yield* waitForRunning(deploymentId); - - // The serving domain only resolves to the running deployment's - // region once promoted; the app's create-time `appEndpointDomain` - // is a placeholder. Promote returns the live one. - const promoted = yield* call(() => - client.POST('/v1/apps/{appId}/promote', { - params: { path: { appId: news.computeServiceId } }, - body: { deploymentId }, - }), - ); - - const deployedUrl = promoted.data.appEndpointDomain; - return { deploymentId, ...(deployedUrl !== undefined && { deployedUrl }) }; - }), - delete: Effect.fn(function* () { - // A promoted deployment is retained as the app's deploy history; - // deleting the ComputeService itself tears down its deployments. - }), - read: Effect.fn(function* ({ output }) { - if (!output?.deploymentId) return undefined; - const v = yield* callOptional(() => - client.GET('/v1/deployments/{deploymentId}', { - params: { path: { deploymentId: output.deploymentId } }, - }), - ); - return v - ? { - deploymentId: v.data.id, - ...(v.data.previewDomain && { deployedUrl: v.data.previewDomain }), - } - : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts deleted file mode 100644 index 67857dac..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Resource } from 'alchemy'; -import * as Provider from 'alchemy/Provider'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../client.ts'; -import { call, callOptional, callVoid } from '../http.ts'; - -export type EnvironmentClass = 'production' | 'preview'; - -export interface EnvironmentVariableProps { - /** The project this variable belongs to. */ - projectId: string; - /** Variable name, e.g. `AUTH_URL`. */ - key: string; - /** Variable value. Stored encrypted; not readable back. */ - value: string; - /** Which environment the value applies to. Defaults to `production`. */ - class?: EnvironmentClass; - /** Set only for a preview-branch override. */ - branchId?: string; -} - -export interface EnvironmentVariableAttributes { - id: string; - key: string; -} - -export type EnvironmentVariable = Resource< - 'PrismaComposer.EnvironmentVariable', - EnvironmentVariableProps, - EnvironmentVariableAttributes ->; - -/** - * A project-scoped **environment variable** that Compute injects into the - * project's services from their attached branch (e.g. wiring one module's URL into - * another). - */ -export const EnvironmentVariable = Resource( - 'PrismaComposer.EnvironmentVariable', - { aliases: ['Prisma.EnvironmentVariable'] }, -); - -export const EnvironmentVariableProvider = () => - Provider.effect( - EnvironmentVariable, - Effect.gen(function* () { - const client = yield* ManagementClient; - - return { - stables: ['id'], - list: () => Effect.succeed([] as EnvironmentVariableAttributes[]), - reconcile: Effect.fn(function* ({ news, output }) { - const cls = news.class ?? 'production'; - // Value is write-only, so we PATCH, never diff. Adopt our own prior - // row (output.id), or a pre-existing poison-key row (DATABASE_URL(_POOLED), - // platform-seeded). Any other untracked match is a COMPOSER_ collision we - // refuse to overwrite (see the throw below). - let id = output?.id; - if (id !== undefined) { - const priorId = id; - const mine = yield* callOptional(() => - client.GET('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: priorId } }, - }), - ); - if (!mine) id = undefined; - } - if (id === undefined) { - const match = yield* call(() => - client.GET('/v1/environment-variables', { - params: { - query: { projectId: news.projectId, class: cls, key: news.key } as never, - }, - }), - ); - const matchId = (match as { data?: Array<{ id: string }> }).data?.[0]?.id; - if (matchId !== undefined) { - const isPoison = news.key === 'DATABASE_URL' || news.key === 'DATABASE_URL_POOLED'; - if (!isPoison) { - throw new Error( - `EnvironmentVariable "${news.key}" (project "${news.projectId}", class "${cls}") ` + - 'exists but is untracked in this deploy state — refusing to overwrite a reserved ' + - "COMPOSER_ key. Restore this deploy's hosted state, or remove the variable to let " + - 'this deploy recreate it.', - ); - } - id = matchId; - } - } - if (id !== undefined) { - const targetId = id; - yield* call(() => - client.PATCH('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: targetId } }, - body: { value: news.value }, - }), - ); - return { id, key: news.key }; - } - - const created = yield* call(() => - client.POST('/v1/environment-variables', { - body: { - projectId: news.projectId, - class: cls, - key: news.key, - value: news.value, - ...(news.branchId ? { branchId: news.branchId } : {}), - }, - }), - ); - return { id: created.data.id, key: created.data.key }; - }), - delete: Effect.fn(function* ({ output }) { - yield* callVoid(() => - client.DELETE('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: output.id } }, - }), - ); - }), - read: Effect.fn(function* ({ output }) { - if (!output?.id) return undefined; - const v = yield* callOptional(() => - client.GET('/v1/environment-variables/{envVarId}', { - params: { path: { envVarId: output.id } }, - }), - ); - return v ? { id: v.data.id, key: v.data.key } : undefined; - }), - }; - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts new file mode 100644 index 00000000..79169c9f --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts @@ -0,0 +1,202 @@ +/** + * The environment→deployment ordering edge, against the REAL Output machinery + * and upstream's REAL `Prisma.Deployment` provider — no stubbed `Output.all` / + * `Output.flatMap`, because the failure this guards against lives in the + * unresolved half of Alchemy's planning, which an eager-collapse stub cannot + * represent. + * + * Two properties, both about the deploy that ADDS a variable and changes the + * code in the same run: + * + * 1. Every variable write, and the app itself, is upstream of the + * deployment — checked with the same walker the planner builds its graph + * with, not by reading the prop values by hand. + * 2. The artifact comparison still runs while the brand-new variable is + * unresolved. Upstream reads `{portMapping, skipCodeUpload, artifactPath, + * artifactContentType}` as one block and gives no opinion the moment any + * of them is unresolved, which the engine turns into a plain update: the + * running deployment would be kept while the new artifact's fingerprint + * was recorded as deployed, dropping the code change for good, and every + * later deploy would agree it had already shipped. Carrying the edge on + * `app` is what keeps that block resolved. + */ + +import { afterAll, 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 * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; +import type * as Provider from 'alchemy/Provider'; +import { Stack } from 'alchemy/Stack'; +import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import { sha256, sha256Object } from 'alchemy/Util/sha256'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import { appAfterEnvironment } from '../deployment-edge.ts'; + +/** A stack the resource constructors register into; nothing ever applies it. */ +const stack = { name: 'shop', stage: 'prod', resources: {}, bindings: {}, actions: {} }; + +const registered = (effect: Effect.Effect): A => + Effect.runSync( + effect.pipe(Effect.provideService(Stack, stack as never)) as Effect.Effect, + ); + +const app = registered( + Prisma.App('auth-svc', { project: 'proj-1', displayName: 'auth', regionId: 'us-east-1' }), +); + +/** Two variables: one this deploy already had, one it is adding. */ +const persistedVariable = registered( + Prisma.EnvironmentVariable('COMPOSER_AUTH_PORT-var', { + project: 'proj-1', + class: 'production', + key: 'COMPOSER_AUTH_PORT', + value: Redacted.make('3000'), + }), +); + +const newVariable = registered( + Prisma.EnvironmentVariable('COMPOSER_AUTH_DB_URL-var', { + project: 'proj-1', + class: 'production', + key: 'COMPOSER_AUTH_DB_URL', + value: Redacted.make('postgres://db'), + }), +); + +const environment = [persistedVariable, newVariable]; + +const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deployment-edge-')); +const artifactPath = path.join(artifactDir, 'auth.tar.gz'); +fs.writeFileSync(artifactPath, 'artifact-generation-2'); + +afterAll(() => { + fs.rmSync(artifactDir, { recursive: true, force: true }); +}); + +/** The deploy hook's props, built by the same helper the descriptor uses. */ +const deploymentProps = () => ({ + app: appAfterEnvironment(app.appId, environment), + artifactPath, + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, +}); + +/** Upstream's own fingerprint for the bytes on disk — its formula, its hashes. */ +const artifactFingerprint = () => + Effect.runPromise( + Effect.gen(function* () { + const digest = yield* sha256(fs.readFileSync(artifactPath)); + return yield* sha256Object({ artifact: digest, contentType: 'application/gzip' }); + }), + ); + +const apiDeployment = { + id: 'dep-1', + type: 'deployment', + url: 'https://api.prisma.io/v1/deployments/dep-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: 'dep-1.preview.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', +}; + +/** Only the endpoints a diff may touch; a create would be a test failure. */ +const stubClient = { + getDeployment: (id: string) => + id === 'dep-1' ? Effect.succeed(apiDeployment) : Effect.die(`unexpected getDeployment ${id}`), + listAppDeployments: () => Effect.succeed([apiDeployment]), + createAppDeployment: () => Effect.die('a diff must not create a deployment'), +} as unknown as Prisma.PrismaManagementClient; + +// Same `any` leak through Provider.effect's typing the state tests document: +// the stubbed PrismaClient is the only real requirement and it IS provided. +const deploymentService = () => + Effect.runPromise( + Prisma.Deployment.Provider.pipe( + Effect.provide( + Prisma.DeploymentProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +const persistedOutput = (artifactHash: string) => ({ + deploymentId: 'dep-1', + appId: 'app-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: null, + artifactHash, + appEndpointDomain: 'auth.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', +}); + +/** + * `news` as the planner hands it over on the deploy that adds a variable: the + * combined `app` expression cannot resolve (the new variable has no state to + * resolve to), every other prop is a value. `olds` are the previous deploy's + * persisted props, where `app` had resolved to the app id. + */ +const diffAgainst = async (output: Record) => { + const service = await deploymentService(); + if (service.diff === undefined) throw new Error('provider must expose diff'); + return Effect.runPromise( + service + .diff({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: { ...deploymentProps(), app: 'app-1' }, + news: deploymentProps(), + output, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); +}; + +describe('appAfterEnvironment — the edge Alchemy actually plans on', () => { + test('every variable AND the app are upstream of the deployment', () => { + const upstream = Output.upstreamAny(deploymentProps()); + expect(Object.keys(upstream).sort()).toEqual( + ['COMPOSER_AUTH_DB_URL-var', 'COMPOSER_AUTH_PORT-var', 'auth-svc'].sort(), + ); + }); + + test('a service with no variables passes the app id straight through', () => { + expect(Object.keys(Output.upstreamAny({ app: appAfterEnvironment(app.appId, []) }))).toEqual([ + 'auth-svc', + ]); + }); + + test('the artifact props are plain values, never expressions', () => { + const props = deploymentProps(); + expect(Output.isOutput(props.artifactPath)).toBe(false); + expect(Output.isOutput(props.artifactContentType)).toBe(false); + expect(Output.isOutput(props.portMapping)).toBe(false); + // The unresolved half is confined to `app`, which is the point. + expect(Output.isOutput(props.app)).toBe(true); + }); +}); + +describe('upstream Deployment.diff while the new variable is still unresolved', () => { + test('a changed artifact plans a REPLACE — the code change is not dropped', async () => { + const diff = await diffAgainst(persistedOutput('the-previous-generations-fingerprint')); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('an unchanged artifact plans no replacement', async () => { + const diff = await diffAgainst(persistedOutput(await artifactFingerprint())); + // An update, not a replace: with start/promote asserted on every deploy, + // upstream re-asserts the lifecycle of the deployment it already has. + expect(diff).toEqual({ action: 'update' }); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts new file mode 100644 index 00000000..edef20a3 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -0,0 +1,46 @@ +/** + * The dependency edge that orders a deployment AFTER the environment rows it + * boots with. + * + * The platform materializes a branch's environment variables INTO a deployment + * when the deployment is created, and never re-reads them (gotchas.md, + * PRO-211): a deployment created before its rows exist boots without them, for + * as long as it lives. So the write must be scheduled first, and Alchemy + * schedules on one thing only — the resource references a prop's VALUE is + * built from. + * + * Upstream's `Prisma.Deployment` has no prop for the environment, so the edge + * rides `app`: the app id is threaded through every variable's id, and the + * value the platform receives is the app id itself. + * + * `app` is the only prop this can ride. Upstream's diff reads + * `{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one + * block and gives up — returning "no opinion", which the engine turns into a + * plain update — as soon as ANY of them is unresolved. A brand-new variable + * has no persisted state, so its reference resolves to a bare resource + * expression rather than a value; threading it through one of those four props + * would leave the whole block unresolved on exactly the deploys that add a + * variable, skipping the artifact comparison. The deployment would then be + * reused while the new artifact's fingerprint was recorded as deployed — so + * the code change would be silently dropped, and every later deploy would + * agree it had already shipped. `app` sits outside that block, and its own + * check treats an unresolved app as "unchanged" rather than as a change. + */ + +import * as Output from 'alchemy/Output'; +import type { EnvironmentVariable } from 'alchemy/Prisma'; + +export const appAfterEnvironment = ( + app: Output.Output, + environment: readonly EnvironmentVariable[], +): Output.Output => + environment.length === 0 + ? app + : // The app id is inside the combined expression as well as returned from + // it: Alchemy's dependency walker looks only at what an expression is + // built FROM, never inside the function, so an app referenced only by + // the closure would leave the deployment with no edge to its own app. + Output.flatMap( + Output.all(app, ...environment.map((variable) => variable.environmentVariableId)), + () => app, + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 8a365d5d..919f806e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts @@ -1,5 +1,3 @@ export * from '../compute/artifact.ts'; -export * from '../compute/ComputeService.ts'; -export * from '../compute/Deployment.ts'; -export * from '../compute/EnvironmentVariable.ts'; +export * from '../compute/deployment-edge.ts'; export * from '../compute/ServiceKey.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index f4a5f198..4a8c6bb7 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -3,8 +3,10 @@ * Management API client, container, and credential helpers. Implementation * lives in `../providers.ts` and the modules it re-exports; the compute and * bucket surfaces are their own entrypoints. The postgres family (Project, - * Database, Connection) is upstream alchemy's — consumers import it via - * `import * as Prisma from 'alchemy/Prisma'`. + * Database, Connection) and the compute family (App, Deployment, + * EnvironmentVariable) are upstream alchemy's — consumers import them via + * `import * as Prisma from 'alchemy/Prisma'`. What stays here is Composer's + * own: the artifact packager, `ServiceKey`, and the bucket resources. */ export { layer as managementClientLayer, 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 3b6439d7..cafb3e39 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -6,9 +6,6 @@ import * as Layer from 'effect/Layer'; import { Bucket, BucketProvider } from './buckets/Bucket.ts'; import { BucketKey, BucketKeyProvider } from './buckets/BucketKey.ts'; import * as client from './client.ts'; -import { ComputeService, ComputeServiceProvider } from './compute/ComputeService.ts'; -import { Deployment, DeploymentProvider } from './compute/Deployment.ts'; -import { EnvironmentVariable, EnvironmentVariableProvider } from './compute/EnvironmentVariable.ts'; import { fromEnv, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; /** The collection of Prisma resource providers. */ @@ -38,19 +35,23 @@ const prismaEnvironment = () => ); /** - * Upstream alchemy's live postgres-family providers (Project, Database, - * Connection) over upstream's management client, authenticated by + * Upstream alchemy's live providers for the postgres family (Project, + * Database, Connection) and the compute family (App, Deployment, + * EnvironmentVariable), over upstream's management client, authenticated by * {@link prismaEnvironment}. * * alchemy 2.0.0-beta.67 exports only the per-resource provider layers, so * they are composed by hand here. TODO: switch to upstream's * `liveProviderLayer` in the alchemy release that exports it. */ -const upstreamPostgresProviders = () => +const upstreamPrismaProviders = () => Layer.mergeAll( Prisma.ProjectProvider(), Prisma.DatabaseProvider(), Prisma.ConnectionProvider(), + Prisma.AppProvider(), + Prisma.DeploymentProvider(), + Prisma.EnvironmentVariableProvider(), ).pipe( Layer.provideMerge(Prisma.PrismaClientLive), // Provide (NOT provideMerge) the node transport privately — mirrors @@ -64,6 +65,25 @@ const upstreamPostgresProviders = () => * The Prisma provider bundle: every resource provider, the Management API * client, and env-based credentials. Plug into a stack with * `{ providers: Prisma.providers() }`. + * + * The node transport is ALSO exposed as the bundle's ambient `HttpClient`, + * overriding the stack's fetch client. Upstream's `Deployment` PUTs the + * artifact to a presigned URL, which requires an explicit Content-Length on a + * file-backed body — what node's transport sends and fetch's chunked streaming + * does not. Upstream serves that from a Prisma-scoped service whose package + * subpath (`alchemy/Prisma/Internal/*`) is exported as `null`, so it cannot be + * composed in privately from outside; upstream documents the ambient client as + * the supported fallback, which is what this makes correct. + * + * The invariant that keeps this safe, and that new code must preserve: **no + * Composer provider may resolve the ambient `HttpClient`**. Every one of them + * carries its own client — the Management API client (openapi-fetch), the + * bucket resources through it, `PgWarm`/`PnMigration` over postgres.js — so + * this layer's override reaches only upstream's artifact upload. A provider + * that starts taking `HttpClient.HttpClient` would silently be handed the node + * transport by this line. Filed upstream: export the scoped upload client (or + * open the Internal subpath), after which this becomes a private + * `PrismaUploadClientLive` and the invariant can be retired. */ export const providers = () => Layer.effect( @@ -72,23 +92,15 @@ export const providers = () => Prisma.Project, Prisma.Database, Prisma.Connection, - ComputeService, - Deployment, - EnvironmentVariable, + Prisma.App, + Prisma.Deployment, + Prisma.EnvironmentVariable, Bucket, BucketKey, ]), ).pipe( - Layer.provide( - Layer.mergeAll( - upstreamPostgresProviders(), - ComputeServiceProvider(), - DeploymentProvider(), - EnvironmentVariableProvider(), - BucketProvider(), - BucketKeyProvider(), - ), - ), + Layer.provide(Layer.mergeAll(upstreamPrismaProviders(), BucketProvider(), BucketKeyProvider())), + Layer.provideMerge(NodeHttpClient.layerNodeHttp), Layer.provideMerge(client.layer()), Layer.provideMerge(fromEnv()), Layer.orDie, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts deleted file mode 100644 index 2ea9aea6..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-postgres.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Legacy postgres-family state rows (written by Composer's own deleted - * `Prisma.Database` / `Prisma.Connection` resources) must round-trip through - * the hosted state store into shapes upstream alchemy's providers ACCEPT — - * meaning: the provider's `diff` plans no action (so no create and no - * replace), its `read` finds the physical resource instead of returning - * `undefined` (which would plan a create), and `reconcile` keeps the - * persisted secret without rotating anything. - */ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { InstanceId } from 'alchemy/InstanceId'; -import * as Prisma from 'alchemy/Prisma'; -import type * as Provider from 'alchemy/Provider'; -import { Stack } from 'alchemy/Stack'; -import { Stage } from 'alchemy/Stage'; -import type { CreatedResourceState, ReplacedResourceState } from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import * as Redacted from 'effect/Redacted'; -import postgres from 'postgres'; -import { migrateLegacyPostgresState } from '../legacy-postgres.ts'; -import { migratePrismaState } from '../schema.ts'; -import { makePrismaStateService } from '../service.ts'; -import { startTestPostgres, type TestPostgres } from './harness.ts'; - -const pg: TestPostgres | undefined = startTestPostgres(); - -if (pg === undefined) { - console.warn( - '[alchemy/state] skipping legacy-postgres migration tests: no Postgres available. ' + - 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + - '(e.g. `brew install postgresql@15`) on PATH.', - ); -} - -const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; - -const legacyDatabaseRow = (): CreatedResourceState => ({ - resourceType: 'Prisma.Database', - namespace: undefined, - fqn: 'data-db', - logicalId: 'data-db', - instanceId: 'inst-db', - providerVersion: 1, - status: 'created', - downstream: [], - bindings: [], - props: { projectId: 'proj-1', name: 'data', region: 'us-east-1' }, - attr: { id: 'db-1', name: 'data' }, -}); - -const legacyConnectionRow = (): CreatedResourceState => ({ - resourceType: 'Prisma.Connection', - namespace: undefined, - fqn: 'data-conn', - logicalId: 'data-conn', - instanceId: 'inst-conn', - providerVersion: 1, - status: 'created', - downstream: [], - bindings: [], - props: { databaseId: 'db-1', name: 'data' }, - attr: { id: 'conn-1', connectionString: Redacted.make(DIRECT_URL) }, -}); - -const apiDatabase = { - id: 'db-1', - name: 'data', - project: { id: 'proj-1' }, - status: 'ready', - region: { id: 'us-east-1' }, - isDefault: false, - branchId: null, - defaultConnectionId: 'conn-default', - createdAt: '2025-01-01T00:00:00.000Z', - source: { type: 'empty' }, - connections: [], -}; - -const apiConnection = { - id: 'conn-1', - name: 'data', - database: { id: 'db-1' }, - kind: 'postgres', - createdAt: '2025-01-01T00:00:00.000Z', -}; - -/** Only the endpoints the adoption paths under test actually hit; anything else throws loudly. */ -const stubClient = { - getDatabase: (id: string) => - id === 'db-1' ? Effect.succeed(apiDatabase) : Effect.die(`unexpected getDatabase ${id}`), - getConnection: (id: string) => - id === 'conn-1' ? Effect.succeed(apiConnection) : Effect.die(`unexpected getConnection ${id}`), - rotateConnection: (id: string) => - Effect.die(`rotateConnection(${id}) must not be called for an adopted legacy row`), -} as unknown as Prisma.PrismaManagementClient; - -// The provider layers' inferred environment leaks an `any` through -// Provider.effect's typing; the stubbed PrismaClient is the only real -// requirement and it IS provided, so the runtime environment is complete. -const databaseService = () => - Effect.runPromise( - Prisma.Database.Provider.pipe( - Effect.provide( - Prisma.DatabaseProvider().pipe( - Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), - ), - ), - ) as Effect.Effect, never, never>, - ); - -const connectionService = () => - Effect.runPromise( - Prisma.Connection.Provider.pipe( - Effect.provide( - Prisma.ConnectionProvider().pipe( - Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), - ), - ), - ) as Effect.Effect, never, never>, - ); - -type MigratedRow = CreatedResourceState & { - props: Record; - attr: Record; -}; - -describe('migrateLegacyPostgresState (pure mapping)', () => { - test('maps a legacy Database row to upstream field names, idempotently', () => { - const migrated = migrateLegacyPostgresState(legacyDatabaseRow()) as MigratedRow; - expect(migrated.resourceType).toBe('Prisma.Database'); - expect(migrated.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); - expect(migrated.attr).toMatchObject({ - databaseId: 'db-1', - databaseName: 'data', - projectId: 'proj-1', - region: 'us-east-1', - isDefault: false, - branchId: null, - }); - expect(migrateLegacyPostgresState(migrated)).toEqual(migrated); - }); - - test('maps a legacy Connection row, carrying the Redacted secret into directConnectionString', () => { - const migrated = migrateLegacyPostgresState(legacyConnectionRow()) as MigratedRow; - expect(migrated.resourceType).toBe('Prisma.Connection'); - expect(migrated.props).toEqual({ database: 'db-1', name: 'data' }); - expect(migrated.attr).toMatchObject({ - connectionId: 'conn-1', - connectionName: 'data', - databaseId: 'db-1', - kind: 'postgres', - }); - const direct = migrated.attr['directConnectionString']; - expect(Redacted.isRedacted(direct)).toBe(true); - expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); - expect(migrateLegacyPostgresState(migrated)).toEqual(migrated); - }); - - test('migrates the nested old-generation chain of a replaced row', () => { - const replaced: ReplacedResourceState = { - ...legacyDatabaseRow(), - status: 'replaced', - old: legacyDatabaseRow(), - deleteFirst: false, - } as ReplacedResourceState; - const migrated = migrateLegacyPostgresState(replaced) as ReplacedResourceState & { - old: MigratedRow; - }; - expect(migrated.old.resourceType).toBe('Prisma.Database'); - expect(migrated.old.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); - expect(migrated.old.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); - }); - - test('maps the unreleased PrismaComposer.* type-ids too, and passes foreign rows through', () => { - const composerEra = { ...legacyDatabaseRow(), resourceType: 'PrismaComposer.Database' }; - expect((migrateLegacyPostgresState(composerEra) as MigratedRow).resourceType).toBe( - 'Prisma.Database', - ); - const foreign = { ...legacyDatabaseRow(), resourceType: 'Prisma.ComputeService' }; - expect(migrateLegacyPostgresState(foreign)).toEqual(foreign); - }); -}); - -describe('upstream provider acceptance of migrated rows (stubbed management client)', () => { - const migratedDb = migrateLegacyPostgresState(legacyDatabaseRow()) as MigratedRow; - const migratedConn = migrateLegacyPostgresState(legacyConnectionRow()) as MigratedRow; - - test('Database: diff plans NO action, and read finds the database (no create)', async () => { - const service = await databaseService(); - if (service.diff === undefined || service.read === undefined) { - throw new Error('upstream provider must expose diff and read'); - } - const diff = await Effect.runPromise( - service.diff({ - id: 'data-db', - fqn: 'data-db', - instanceId: 'inst-db', - olds: migratedDb.props, - news: { project: 'proj-1', name: 'data', region: 'us-east-1' }, - output: migratedDb.attr, - session: undefined, - bindings: [], - } as never), - ); - expect(diff).toBeUndefined(); - - const read = await Effect.runPromise( - service.read({ - id: 'data-db', - fqn: 'data-db', - instanceId: 'inst-db', - olds: migratedDb.props, - output: migratedDb.attr, - session: undefined, - bindings: [], - } as never), - ); - expect(read).toMatchObject({ databaseId: 'db-1', databaseName: 'data', projectId: 'proj-1' }); - }); - - test('Connection: diff plans NO action, read finds it, reconcile keeps the secret WITHOUT rotating', async () => { - const service = await connectionService(); - if (service.diff === undefined || service.read === undefined) { - throw new Error('upstream provider must expose diff and read'); - } - const diff = await Effect.runPromise( - service.diff({ - id: 'data-conn', - fqn: 'data-conn', - instanceId: 'inst-conn', - olds: migratedConn.props, - news: { database: 'db-1', name: 'data' }, - output: migratedConn.attr, - session: undefined, - bindings: [], - } as never), - ); - expect(diff).toBeUndefined(); - - const read = await Effect.runPromise( - service.read({ - id: 'data-conn', - fqn: 'data-conn', - instanceId: 'inst-conn', - olds: migratedConn.props, - output: migratedConn.attr, - session: undefined, - bindings: [], - } as never), - ); - expect(read).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); - - // The stub's rotateConnection dies, so this passing proves reconcile - // never touched the live credentials. - const reconciled = (await Effect.runPromise( - service.reconcile({ - id: 'data-conn', - fqn: 'data-conn', - instanceId: 'inst-conn', - olds: migratedConn.props, - news: { database: 'db-1', name: 'data' }, - output: migratedConn.attr, - session: undefined, - bindings: [], - } as never), - )) as Record; - const direct = reconciled['directConnectionString']; - expect(Redacted.isRedacted(direct)).toBe(true); - expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); - }); -}); - -describe('branch-stage migrated rows against upstream Database provider', () => { - // A branch-stage row: the legacy descriptor passed an explicit name AND a - // branchId; the replacement descriptor omits the name when branchId is set. - const legacyBranchRow = (): CreatedResourceState => ({ - ...legacyDatabaseRow(), - props: { projectId: 'proj-1', name: 'data', region: 'us-east-1', branchId: 'branch_1' }, - }); - - test('diff plans an UPDATE (the one-time rename + credential-recovery path), never a replace or create', async () => { - const migrated = migrateLegacyPostgresState(legacyBranchRow()) as MigratedRow; - expect(migrated.props).toEqual({ - project: 'proj-1', - name: 'data', - region: 'us-east-1', - branchId: 'branch_1', - }); - expect(migrated.attr).toMatchObject({ branchId: 'branch_1' }); - - const service = await databaseService(); - if (service.diff === undefined) throw new Error('upstream provider must expose diff'); - const diff = await Effect.runPromise( - service - .diff({ - id: 'data-db', - fqn: 'data-db', - instanceId: 'inst-db', - olds: migrated.props, - // Branch-stage news shape from descriptors/postgres.ts: NO name. - news: { project: 'proj-1', region: 'us-east-1', branchId: 'branch_1' }, - output: migrated.attr, - session: undefined, - bindings: [], - } as never) - .pipe( - // The omitted name makes upstream derive a generated physical name, - // which reads the engine's Stack/Stage/InstanceId context. - Effect.provideService(Stack, { name: 'app' } as never), - Effect.provideService(Stage, 'stage1'), - Effect.provideService(InstanceId, 'abcd1234abcd1234abcd1234abcd1234'), - ), - ); - expect(diff).toEqual({ action: 'update' }); - }); -}); - -describe.skipIf(pg === undefined)('state service round-trip of legacy rows', () => { - if (pg === undefined) return; - - const sql = postgres(pg.url, { max: 5, onnotice: () => {} }); - const service = makePrismaStateService(sql); - const stack = 'legacy-postgres-stack'; - const stage = 'legacy-postgres-stage'; - - beforeAll(async () => { - await Effect.runPromise(migratePrismaState(sql)); - }); - - afterAll(async () => { - await Effect.runPromise(service.deleteStack({ stack })); - await sql.end({ timeout: 1 }); - pg.stop(); - }); - - test('an old-shape Database row persisted as-is is read back in the upstream shape', async () => { - await Effect.runPromise( - service.set({ stack, stage, fqn: 'data-db', value: legacyDatabaseRow() }), - ); - const row = (await Effect.runPromise( - service.get({ stack, stage, fqn: 'data-db' }), - )) as MigratedRow; - expect(row.resourceType).toBe('Prisma.Database'); - expect(row.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); - expect(row.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); - }); - - test('an old-shape Connection row round-trips with the Redacted secret intact', async () => { - await Effect.runPromise( - service.set({ stack, stage, fqn: 'data-conn', value: legacyConnectionRow() }), - ); - const row = (await Effect.runPromise( - service.get({ stack, stage, fqn: 'data-conn' }), - )) as MigratedRow; - expect(row.resourceType).toBe('Prisma.Connection'); - expect(row.attr).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); - const direct = row.attr['directConnectionString']; - expect(Redacted.isRedacted(direct)).toBe(true); - expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); - // The databaseUrl mirror keeps the value usable where the conventional - // application URL is read. - expect(Redacted.value(row.attr['databaseUrl'] as Redacted.Redacted)).toBe(DIRECT_URL); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts new file mode 100644 index 00000000..d084a12e --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -0,0 +1,759 @@ +/** + * Legacy state rows — written by Composer's own deleted `Prisma.Database` / + * `Prisma.Connection` / `Prisma.ComputeService` / `Prisma.Deployment` / + * `Prisma.EnvironmentVariable` resources — must round-trip through the hosted + * state store into shapes upstream alchemy's providers ACCEPT. On the + * unchanged path that means: the provider's `diff` plans no action (so no + * create and no replace), its `read` finds the physical resource instead of + * returning `undefined` (which would plan a create), and `reconcile` keeps the + * persisted secret without rotating anything. Where migration cannot avoid a + * mutating plan (a production App's unrecorded branch, a deployment's + * unrecoverable artifact fingerprint), the test pins WHICH action is planned, + * so the one-time cost stays visible. + */ + +import { afterAll, beforeAll, 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 { InstanceId } from 'alchemy/InstanceId'; +import * as Prisma from 'alchemy/Prisma'; +import type * as Provider from 'alchemy/Provider'; +import { Stack } from 'alchemy/Stack'; +import { Stage } from 'alchemy/Stage'; +import type { CreatedResourceState, ReplacedResourceState } from 'alchemy/State'; +import { PlatformServices } from 'alchemy/Util/PlatformServices'; +import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import postgres from 'postgres'; +import { migrateLegacyResourceState } from '../legacy-resources.ts'; +import { migratePrismaState } from '../schema.ts'; +import { makePrismaStateService } from '../service.ts'; +import { startTestPostgres, type TestPostgres } from './harness.ts'; + +const pg: TestPostgres | undefined = startTestPostgres(); + +if (pg === undefined) { + console.warn( + '[alchemy/state] skipping legacy-state migration tests: no Postgres available. ' + + 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + + '(e.g. `brew install postgresql@15`) on PATH.', + ); +} + +const DIRECT_URL = 'postgres://user:pass@db.prisma.io:5432/postgres'; + +const legacyDatabaseRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Database', + namespace: undefined, + fqn: 'data-db', + logicalId: 'data-db', + instanceId: 'inst-db', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1' }, + attr: { id: 'db-1', name: 'data' }, +}); + +const legacyConnectionRow = (): CreatedResourceState => ({ + resourceType: 'Prisma.Connection', + namespace: undefined, + fqn: 'data-conn', + logicalId: 'data-conn', + instanceId: 'inst-conn', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { databaseId: 'db-1', name: 'data' }, + attr: { id: 'conn-1', connectionString: Redacted.make(DIRECT_URL) }, +}); + +const apiDatabase = { + id: 'db-1', + name: 'data', + project: { id: 'proj-1' }, + status: 'ready', + region: { id: 'us-east-1' }, + isDefault: false, + branchId: null, + defaultConnectionId: 'conn-default', + createdAt: '2025-01-01T00:00:00.000Z', + source: { type: 'empty' }, + connections: [], +}; + +const apiConnection = { + id: 'conn-1', + name: 'data', + database: { id: 'db-1' }, + kind: 'postgres', + createdAt: '2025-01-01T00:00:00.000Z', +}; + +/** Only the endpoints the adoption paths under test actually hit; anything else throws loudly. */ +const stubClient = { + getDatabase: (id: string) => + id === 'db-1' ? Effect.succeed(apiDatabase) : Effect.die(`unexpected getDatabase ${id}`), + getConnection: (id: string) => + id === 'conn-1' ? Effect.succeed(apiConnection) : Effect.die(`unexpected getConnection ${id}`), + rotateConnection: (id: string) => + Effect.die(`rotateConnection(${id}) must not be called for an adopted legacy row`), +} as unknown as Prisma.PrismaManagementClient; + +// The provider layers' inferred environment leaks an `any` through +// Provider.effect's typing; the stubbed PrismaClient is the only real +// requirement and it IS provided, so the runtime environment is complete. +const databaseService = () => + Effect.runPromise( + Prisma.Database.Provider.pipe( + Effect.provide( + Prisma.DatabaseProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +const connectionService = () => + Effect.runPromise( + Prisma.Connection.Provider.pipe( + Effect.provide( + Prisma.ConnectionProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, stubClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + +type MigratedRow = CreatedResourceState & { + props: Record; + attr: Record; +}; + +describe('migrateLegacyResourceState (pure mapping)', () => { + test('maps a legacy Database row to upstream field names, idempotently', () => { + const migrated = migrateLegacyResourceState(legacyDatabaseRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Database'); + expect(migrated.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + expect(migrated.attr).toMatchObject({ + databaseId: 'db-1', + databaseName: 'data', + projectId: 'proj-1', + region: 'us-east-1', + isDefault: false, + branchId: null, + }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('maps a legacy Connection row, carrying the Redacted secret into directConnectionString', () => { + const migrated = migrateLegacyResourceState(legacyConnectionRow()) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Connection'); + expect(migrated.props).toEqual({ database: 'db-1', name: 'data' }); + expect(migrated.attr).toMatchObject({ + connectionId: 'conn-1', + connectionName: 'data', + databaseId: 'db-1', + kind: 'postgres', + }); + const direct = migrated.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('migrates the nested old-generation chain of a replaced row', () => { + const replaced: ReplacedResourceState = { + ...legacyDatabaseRow(), + status: 'replaced', + old: legacyDatabaseRow(), + deleteFirst: false, + } as ReplacedResourceState; + const migrated = migrateLegacyResourceState(replaced) as ReplacedResourceState & { + old: MigratedRow; + }; + expect(migrated.old.resourceType).toBe('Prisma.Database'); + expect(migrated.old.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(migrated.old.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('maps the unreleased PrismaComposer.* type-ids too, and passes foreign rows through', () => { + const composerEra = { ...legacyDatabaseRow(), resourceType: 'PrismaComposer.Database' }; + expect((migrateLegacyResourceState(composerEra) as MigratedRow).resourceType).toBe( + 'Prisma.Database', + ); + const foreign = { ...legacyDatabaseRow(), resourceType: 'Cloudflare.Worker' }; + expect(migrateLegacyResourceState(foreign)).toEqual(foreign); + }); +}); + +describe('upstream provider acceptance of migrated rows (stubbed management client)', () => { + const migratedDb = migrateLegacyResourceState(legacyDatabaseRow()) as MigratedRow; + const migratedConn = migrateLegacyResourceState(legacyConnectionRow()) as MigratedRow; + + test('Database: diff plans NO action, and read finds the database (no create)', async () => { + const service = await databaseService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + news: { project: 'proj-1', name: 'data', region: 'us-east-1' }, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migratedDb.props, + output: migratedDb.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ databaseId: 'db-1', databaseName: 'data', projectId: 'proj-1' }); + }); + + test('Connection: diff plans NO action, read finds it, reconcile keeps the secret WITHOUT rotating', async () => { + const service = await connectionService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + + // The stub's rotateConnection dies, so this passing proves reconcile + // never touched the live credentials. + const reconciled = (await Effect.runPromise( + service.reconcile({ + id: 'data-conn', + fqn: 'data-conn', + instanceId: 'inst-conn', + olds: migratedConn.props, + news: { database: 'db-1', name: 'data' }, + output: migratedConn.attr, + session: undefined, + bindings: [], + } as never), + )) as Record; + const direct = reconciled['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + }); +}); + +describe('branch-stage migrated rows against upstream Database provider', () => { + // A branch-stage row: the legacy descriptor passed an explicit name AND a + // branchId; the replacement descriptor omits the name when branchId is set. + const legacyBranchRow = (): CreatedResourceState => ({ + ...legacyDatabaseRow(), + props: { projectId: 'proj-1', name: 'data', region: 'us-east-1', branchId: 'branch_1' }, + }); + + test('diff plans an UPDATE (the one-time rename + credential-recovery path), never a replace or create', async () => { + const migrated = migrateLegacyResourceState(legacyBranchRow()) as MigratedRow; + expect(migrated.props).toEqual({ + project: 'proj-1', + name: 'data', + region: 'us-east-1', + branchId: 'branch_1', + }); + expect(migrated.attr).toMatchObject({ branchId: 'branch_1' }); + + const service = await databaseService(); + if (service.diff === undefined) throw new Error('upstream provider must expose diff'); + const diff = await Effect.runPromise( + service + .diff({ + id: 'data-db', + fqn: 'data-db', + instanceId: 'inst-db', + olds: migrated.props, + // Branch-stage news shape from descriptors/postgres.ts: NO name. + news: { project: 'proj-1', region: 'us-east-1', branchId: 'branch_1' }, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe( + // The omitted name makes upstream derive a generated physical name, + // which reads the engine's Stack/Stage/InstanceId context. + Effect.provideService(Stack, { name: 'app' } as never), + Effect.provideService(Stage, 'stage1'), + Effect.provideService(InstanceId, 'abcd1234abcd1234abcd1234abcd1234'), + ), + ); + expect(diff).toEqual({ action: 'update' }); + }); +}); + +describe('legacy compute-family rows against upstream providers', () => { + const legacyAppRow = (branchId?: string): CreatedResourceState => ({ + resourceType: 'Prisma.ComputeService', + namespace: undefined, + fqn: 'auth-svc', + logicalId: 'auth-svc', + instanceId: 'inst-app', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { + projectId: 'proj-1', + name: 'auth', + region: 'us-east-1', + ...(branchId !== undefined ? { branchId } : {}), + }, + attr: { id: 'app-1', name: 'auth', endpointDomain: 'auth.prisma.app' }, + }); + + const legacyDeploymentRow = (artifactPath: string): CreatedResourceState => ({ + resourceType: 'Prisma.Deployment', + namespace: undefined, + fqn: 'auth-deploy', + logicalId: 'auth-deploy', + instanceId: 'inst-deploy', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { + computeServiceId: 'app-1', + artifactPath, + artifactHash: 'sha-auth', + port: 8080, + environment: [{ id: 'var-1', key: 'COMPOSER_AUTH_PORT' }], + }, + attr: { deploymentId: 'dep-1', deployedUrl: 'auth.prisma.app' }, + }); + + const legacyEnvRow = (key: string): CreatedResourceState => ({ + resourceType: 'Prisma.EnvironmentVariable', + namespace: undefined, + fqn: `${key}-var`, + logicalId: `${key}-var`, + instanceId: 'inst-var', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: { projectId: 'proj-1', key, value: 'plain-secret', class: 'production' }, + attr: { id: 'var-1', key }, + }); + + const apiApp = { + id: 'app-1', + name: 'auth', + projectId: 'proj-1', + region: { id: 'us-east-1' }, + branchId: 'branch_1', + latestDeploymentId: 'dep-1', + appEndpointDomain: 'auth.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', + }; + + const apiDeployment = { + id: 'dep-1', + type: 'deployment', + url: 'https://api.prisma.io/v1/deployments/dep-1', + foundryVersionId: 'fv-1', + status: 'running', + previewDomain: 'dep-1.preview.prisma.app', + createdAt: '2025-01-01T00:00:00.000Z', + }; + + const apiVariable = { + id: 'var-1', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'COMPOSER_AUTH_PORT', + valueKid: 'kid-1', + isManagedBySystem: false, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + + /** Only the endpoints these adoption paths hit; anything else throws loudly. */ + const computeClient = { + getApp: (id: string) => + id === 'app-1' ? Effect.succeed(apiApp) : Effect.die(`unexpected getApp ${id}`), + listBranches: () => Effect.succeed([{ id: 'branch_1', isDefault: true }]), + getDeployment: (id: string) => + id === 'dep-1' ? Effect.succeed(apiDeployment) : Effect.die(`unexpected getDeployment ${id}`), + listAppDeployments: () => Effect.succeed([apiDeployment]), + getEnvironmentVariable: (id: string) => + id === 'var-1' + ? Effect.succeed(apiVariable) + : Effect.die(`unexpected getEnvironmentVariable ${id}`), + deleteEnvironmentVariable: (id: string) => + Effect.die(`deleteEnvironmentVariable(${id}) must not be called for a platform-owned key`), + createAppDeployment: () => Effect.die('createAppDeployment must not be called by a diff'), + } as unknown as Prisma.PrismaManagementClient; + + // Same `any` leak through Provider.effect's typing as the postgres services + // above: the stubbed PrismaClient is the only real requirement and it IS + // provided, so the runtime environment is complete. + const appService = () => + Effect.runPromise( + Prisma.App.Provider.pipe( + Effect.provide( + Prisma.AppProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + const deploymentService = () => + Effect.runPromise( + Prisma.Deployment.Provider.pipe( + Effect.provide( + Prisma.DeploymentProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + const environmentVariableService = () => + Effect.runPromise( + Prisma.EnvironmentVariable.Provider.pipe( + Effect.provide( + Prisma.EnvironmentVariableProvider().pipe( + Layer.provide(Layer.succeed(Prisma.PrismaClient, computeClient)), + ), + ), + ) as Effect.Effect, never, never>, + ); + + test('maps a legacy ComputeService row onto Prisma.App, idempotently', () => { + const migrated = migrateLegacyResourceState(legacyAppRow('branch_1')) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.App'); + expect(migrated.props).toEqual({ + project: 'proj-1', + displayName: 'auth', + regionId: 'us-east-1', + branchId: 'branch_1', + }); + expect(migrated.attr).toMatchObject({ + appId: 'app-1', + name: 'auth', + projectId: 'proj-1', + regionId: 'us-east-1', + branchId: 'branch_1', + appEndpointDomain: 'auth.prisma.app', + }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('App on a branch stage: diff plans NO action, read finds the app (no create)', async () => { + const migrated = migrateLegacyResourceState(legacyAppRow('branch_1')) as MigratedRow; + const service = await appService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const diff = await Effect.runPromise( + service.diff({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toBeUndefined(); + + const read = await Effect.runPromise( + service.read({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ appId: 'app-1', projectId: 'proj-1' }); + }); + + test('App on production: diff plans an UPDATE — the one-time branch-id repair, never a replace', async () => { + // A production row recorded no branch, and the project's default branch id + // is not derivable from the row, so upstream re-reads the App once. + const migrated = migrateLegacyResourceState(legacyAppRow()) as MigratedRow; + expect(migrated.attr).toMatchObject({ branchId: null }); + const service = await appService(); + if (service.diff === undefined) throw new Error('upstream provider must expose diff'); + const diff = await Effect.runPromise( + service.diff({ + id: 'auth-svc', + fqn: 'auth-svc', + instanceId: 'inst-app', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(diff).toEqual({ action: 'update' }); + }); + + test('maps a legacy Deployment row onto upstream props/attrs, idempotently', () => { + const migrated = migrateLegacyResourceState( + legacyDeploymentRow('/tmp/auth.tar.gz'), + ) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.Deployment'); + expect(migrated.props).toEqual({ + app: 'app-1', + artifactPath: '/tmp/auth.tar.gz', + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, + }); + expect(migrated.attr).toMatchObject({ + deploymentId: 'dep-1', + appId: 'app-1', + appEndpointDomain: 'auth.prisma.app', + }); + // Absent, not invented: upstream recovers a lost deployment by Foundry + // version id, and a made-up one could claim a stranger's deployment. + expect(migrated.attr['foundryVersionId']).toBeUndefined(); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('Deployment: read finds the deployment; diff plans the one-time REPLACE (unrecoverable artifact fingerprint)', async () => { + const artifactPath = path.join(os.tmpdir(), `legacy-artifact-${process.pid}.tar.gz`); + fs.writeFileSync(artifactPath, 'artifact-bytes'); + try { + const migrated = migrateLegacyResourceState(legacyDeploymentRow(artifactPath)) as MigratedRow; + const service = await deploymentService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + + const read = await Effect.runPromise( + service + .read({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); + // Read adopts the live deployment — no create planned for it. + expect(read).toMatchObject({ deploymentId: 'dep-1', appId: 'app-1', status: 'running' }); + + const diff = await Effect.runPromise( + service + .diff({ + id: 'auth-deploy', + fqn: 'auth-deploy', + instanceId: 'inst-deploy', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never) + .pipe(Effect.provide(PlatformServices)) as Effect.Effect, + ); + // Pinned, not tolerated silently: upstream's fingerprint hashes the + // artifact digest with the content type, which a legacy row cannot + // reproduce, so the first deploy after migration ships one fresh + // deployment per service (create-before-delete, artifact unchanged). + expect(diff).toEqual({ action: 'replace' }); + } finally { + fs.rmSync(artifactPath, { force: true }); + } + }); + + test('maps a legacy EnvironmentVariable row onto upstream field names, redacting the stored value', () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('COMPOSER_AUTH_PORT')) as MigratedRow; + expect(migrated.resourceType).toBe('Prisma.EnvironmentVariable'); + expect(migrated.attr).toMatchObject({ + environmentVariableId: 'var-1', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'COMPOSER_AUTH_PORT', + isManagedBySystem: false, + }); + // The legacy row kept the value in PLAIN TEXT in state; the migrated prop + // carries it wrapped, which is what keeps it out of the next state write. + const value = (migrated.props as { value: unknown })['value']; + expect(Redacted.isRedacted(value)).toBe(true); + expect(Redacted.value(value as Redacted.Redacted)).toBe('plain-secret'); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); + }); + + test('EnvironmentVariable: read finds the variable (no create); diff plans the value re-apply', async () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('COMPOSER_AUTH_PORT')) as MigratedRow; + const service = await environmentVariableService(); + if (service.diff === undefined || service.read === undefined) { + throw new Error('upstream provider must expose diff and read'); + } + const read = await Effect.runPromise( + service.read({ + id: 'COMPOSER_AUTH_PORT-var', + fqn: 'COMPOSER_AUTH_PORT-var', + instanceId: 'inst-var', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + expect(read).toMatchObject({ environmentVariableId: 'var-1', key: 'COMPOSER_AUTH_PORT' }); + + const diff = await Effect.runPromise( + service.diff({ + id: 'COMPOSER_AUTH_PORT-var', + fqn: 'COMPOSER_AUTH_PORT-var', + instanceId: 'inst-var', + olds: migrated.props, + news: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + // Values are write-only, so upstream re-applies the desired one on every + // deploy — an update, never a replace or a create. + expect(diff).toEqual({ action: 'update' }); + }); + + test('a poison DATABASE_URL row is neutralised: delete touches nothing', async () => { + const migrated = migrateLegacyResourceState(legacyEnvRow('DATABASE_URL')) as MigratedRow; + expect(migrated.attr).toEqual({ + environmentVariableId: 'dev:legacy-poison-DATABASE_URL', + key: 'DATABASE_URL', + }); + const service = await environmentVariableService(); + // The stub's deleteEnvironmentVariable/getEnvironmentVariable die on this + // id, so completing proves the platform's own variable is never touched. + await Effect.runPromise( + service.delete({ + id: 'DATABASE_URL-var', + fqn: 'DATABASE_URL-var', + instanceId: 'inst-var', + olds: migrated.props, + output: migrated.attr, + session: undefined, + bindings: [], + } as never), + ); + }); + + test('maps the unreleased PrismaComposer.* compute type-ids too', () => { + const composerEra = { + ...legacyAppRow('branch_1'), + resourceType: 'PrismaComposer.ComputeService', + }; + expect((migrateLegacyResourceState(composerEra) as MigratedRow).resourceType).toBe( + 'Prisma.App', + ); + const composerEnv = { + ...legacyEnvRow('COMPOSER_AUTH_PORT'), + resourceType: 'PrismaComposer.EnvironmentVariable', + }; + expect((migrateLegacyResourceState(composerEnv) as MigratedRow).resourceType).toBe( + 'Prisma.EnvironmentVariable', + ); + }); +}); + +describe.skipIf(pg === undefined)('state service round-trip of legacy rows', () => { + if (pg === undefined) return; + + const sql = postgres(pg.url, { max: 5, onnotice: () => {} }); + const service = makePrismaStateService(sql); + const stack = 'legacy-state-stack'; + const stage = 'legacy-state-stage'; + + beforeAll(async () => { + await Effect.runPromise(migratePrismaState(sql)); + }); + + afterAll(async () => { + await Effect.runPromise(service.deleteStack({ stack })); + await sql.end({ timeout: 1 }); + pg.stop(); + }); + + test('an old-shape Database row persisted as-is is read back in the upstream shape', async () => { + await Effect.runPromise( + service.set({ stack, stage, fqn: 'data-db', value: legacyDatabaseRow() }), + ); + const row = (await Effect.runPromise( + service.get({ stack, stage, fqn: 'data-db' }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Database'); + expect(row.attr).toMatchObject({ databaseId: 'db-1', databaseName: 'data' }); + expect(row.props).toEqual({ project: 'proj-1', name: 'data', region: 'us-east-1' }); + }); + + test('an old-shape Connection row round-trips with the Redacted secret intact', async () => { + await Effect.runPromise( + service.set({ stack, stage, fqn: 'data-conn', value: legacyConnectionRow() }), + ); + const row = (await Effect.runPromise( + service.get({ stack, stage, fqn: 'data-conn' }), + )) as MigratedRow; + expect(row.resourceType).toBe('Prisma.Connection'); + expect(row.attr).toMatchObject({ connectionId: 'conn-1', databaseId: 'db-1' }); + const direct = row.attr['directConnectionString']; + expect(Redacted.isRedacted(direct)).toBe(true); + expect(Redacted.value(direct as Redacted.Redacted)).toBe(DIRECT_URL); + // The databaseUrl mirror keeps the value usable where the conventional + // application URL is read. + expect(Redacted.value(row.attr['databaseUrl'] as Redacted.Redacted)).toBe(DIRECT_URL); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts deleted file mode 100644 index 014ec03d..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-postgres.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * One-time, on-read rewrite of legacy postgres-family state rows into the - * shapes upstream alchemy's `Prisma.{Project,Database,Connection}` providers - * expect. - * - * Composer's own postgres resources persisted rows under the type-ids - * `Prisma.Database` / `Prisma.Connection` / `Prisma.Project` (and, for one - * unreleased window, `PrismaComposer.*`) with attribute shapes `{id, name}` - * (database/project) and `{id, connectionString}` (connection). Upstream's - * classes carry the SAME type-ids but expect `{databaseId, …}` / - * `{connectionId, …}` / `{projectId, …}` attributes. This module maps old rows - * to the upstream shape as they are read out of the hosted state store, so - * upstream's providers adopt them (their `read`/`diff` key off - * `output.databaseId` / `output.connectionId`) instead of planning a create. - * - * The legacy connection string was captured direct-preferred (pooled only as - * a fallback the API never actually took), so it maps to - * `directConnectionString` — and to `databaseUrl`, which is what the string - * was used as. Fields the old rows never carried (pooled/accelerate strings, - * host/user/password, origins) are left absent; upstream recomputes them from - * observed API state on the next reconcile that needs them. - * - * One-time operator-visible effects on BRANCH-STAGE environments (documented - * in docs/guides/deploying.md, "Upgrading to the upstream postgres - * resources"): the branch-stage descriptor passes no display name, so the - * first deploy after migration renames each stage database to a generated - * physical name and, in the same reconcile, rotates the database's DEFAULT - * connection credentials. The framework's own named Connection — the one - * services use — is not rotated. Production rows converge with no action. - * - * Scope: the HOSTED state store only. Local dev state (alchemy's local - * store) is never migrated — a stale local row under an unregistered type-id - * fails at plan time with alchemy's missing-provider error, and - * `prisma-composer dev --fresh` clears it (see docs/guides/running-locally.md). - */ - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -const EPOCH = '1970-01-01T00:00:00.000Z'; - -type Family = 'Project' | 'Database' | 'Connection'; - -const FAMILY_BY_LEGACY_TYPE: Readonly> = { - 'Prisma.Project': 'Project', - 'Prisma.Database': 'Database', - 'Prisma.Connection': 'Connection', - 'PrismaComposer.Project': 'Project', - 'PrismaComposer.Database': 'Database', - 'PrismaComposer.Connection': 'Connection', -}; - -const migrateProps = (family: Family, props: unknown): unknown => { - if (!isRecord(props)) return props; - switch (family) { - case 'Project': - // Upstream ProjectProps carry no workspaceId; keep only the name. - return 'workspaceId' in props ? { name: props['name'] } : props; - case 'Database': { - if (!('projectId' in props) || 'project' in props) return props; - return { - project: props['projectId'], - name: props['name'], - region: props['region'], - ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), - }; - } - case 'Connection': { - if (!('databaseId' in props) || 'database' in props) return props; - return { database: props['databaseId'], name: props['name'] }; - } - } -}; - -const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => { - if (!isRecord(attr) || typeof attr['id'] !== 'string') return attr; - const oldProps = isRecord(props) ? props : {}; - switch (family) { - case 'Project': { - if ('projectId' in attr) return attr; - return { - projectId: attr['id'], - projectName: attr['name'], - workspaceId: oldProps['workspaceId'] ?? '', - createdAt: EPOCH, - defaultRegion: null, - }; - } - case 'Database': { - if ('databaseId' in attr) return attr; - return { - databaseId: attr['id'], - databaseName: attr['name'] ?? oldProps['name'], - projectId: oldProps['projectId'], - status: 'ready', - region: oldProps['region'] ?? null, - isDefault: oldProps['isDefault'] ?? false, - branchId: oldProps['branchId'] ?? null, - defaultConnectionId: null, - createdAt: EPOCH, - }; - } - case 'Connection': { - if ('connectionId' in attr) return attr; - return { - connectionId: attr['id'], - connectionName: oldProps['name'], - databaseId: oldProps['databaseId'], - kind: 'postgres', - createdAt: EPOCH, - directConnectionString: attr['connectionString'], - databaseUrl: attr['connectionString'], - }; - } - } -}; - -const migrateResourceRow = (row: Record): Record => { - const resourceType = row['resourceType']; - if (typeof resourceType !== 'string') return row; - const family = FAMILY_BY_LEGACY_TYPE[resourceType]; - if (family === undefined) return row; - - const migrated: Record = { - ...row, - resourceType: `Prisma.${family}`, - ...('props' in row ? { props: migrateProps(family, row['props']) } : {}), - ...('attr' in row ? { attr: migrateAttr(family, row['attr'], row['props']) } : {}), - }; - - // Replacement rows nest the displaced generation under `old` (a full row); - // updating rows nest `{props, attr, bindings}`. Migrate both forms so no - // stale shape survives anywhere in the chain. - const old = row['old']; - if (isRecord(old)) { - migrated['old'] = - typeof old['resourceType'] === 'string' - ? migrateResourceRow(old) - : { - ...old, - ...('props' in old ? { props: migrateProps(family, old['props']) } : {}), - ...('attr' in old ? { attr: migrateAttr(family, old['attr'], old['props']) } : {}), - }; - } - return migrated; -}; - -/** - * Maps a revived state value from the legacy Composer postgres shape to the - * upstream shape. Non-postgres rows (and action rows) pass through untouched; - * the function is idempotent, so already-migrated rows pass through too. - */ -export const migrateLegacyPostgresState = (value: unknown): unknown => { - if (!isRecord(value) || value['kind'] === 'action') return value; - return migrateResourceRow(value); -}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts new file mode 100644 index 00000000..47bf48ab --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -0,0 +1,331 @@ +/** + * One-time, on-read rewrite of legacy Composer state rows into the shapes + * upstream alchemy's `Prisma.*` providers expect — the postgres family + * (`Project`, `Database`, `Connection`) and the compute family (`App`, + * `Deployment`, `EnvironmentVariable`). + * + * Composer's own resources persisted rows under the type-ids `Prisma.Database` + * / `Prisma.Connection` / `Prisma.Project` / `Prisma.ComputeService` / + * `Prisma.Deployment` / `Prisma.EnvironmentVariable` (and, for one unreleased + * window, `PrismaComposer.*`) with hand-rolled attribute shapes: `{id, name}` + * (project/database/compute service), `{id, connectionString}` (connection), + * `{deploymentId, deployedUrl}` (deployment), `{id, key}` (environment + * variable). Upstream's classes carry the same type-ids — `ComputeService` + * becomes `App` — but expect `{projectId, …}` / `{databaseId, …}` / + * `{connectionId, …}` / `{appId, …}` / `{deploymentId, appId, …}` / + * `{environmentVariableId, …}`. This module maps old rows to the upstream + * shape as they are read out of the hosted state store, so upstream's + * providers adopt them (their `read`/`diff` key off those ids) instead of + * planning a create. + * + * The legacy connection string was captured direct-preferred (pooled only as + * a fallback the API never actually took), so it maps to + * `directConnectionString` — and to `databaseUrl`, which is what the string + * was used as. Fields the old rows never carried (pooled/accelerate strings, + * host/user/password, origins) are left absent; upstream recomputes them from + * observed API state on the next reconcile that needs them. + * + * Operator-visible effects of the first deploy after migration, documented in + * docs/guides/deploying.md: + * + * · BRANCH-STAGE databases are renamed to a generated physical name and the + * database's DEFAULT connection credentials rotate once (the branch-stage + * descriptor passes no display name). The framework's own named + * Connection — the one services use — is not rotated. Production database + * rows converge with no action. + * · Every service ships ONE fresh deployment. Upstream keys a deployment's + * replacement on an artifact fingerprint that hashes the artifact digest + * together with the upload content type, which a legacy row's bare digest + * cannot reproduce, so the first plan sees a fingerprint it has never + * recorded and replaces: the same upload → start → promote a code change + * takes, with the same artifact bytes. + * · PRODUCTION apps plan an update (never a replace): the legacy row records + * no branch id and the project's default branch id is not derivable + * offline, so upstream re-reads the App and repairs the attribute in + * place. Branch-stage apps recorded their branch id and converge silently. + * · The poison `DATABASE_URL`/`DATABASE_URL_POOLED` rows are neutralised — + * see {@link poisonKeyAttr}. + * + * Scope: the HOSTED state store only. Local dev state (alchemy's local + * store) is never migrated — a stale local row under an unregistered type-id + * fails at plan time with alchemy's missing-provider error, and + * `prisma-composer dev --fresh` clears it (see docs/guides/running-locally.md). + */ + +import * as Redacted from 'effect/Redacted'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const EPOCH = '1970-01-01T00:00:00.000Z'; + +/** Where a legacy row recorded no region: the only region Composer's descriptors ever defaulted to. */ +const DEFAULT_REGION = 'us-east-1'; + +/** The content type `descriptors/compute.ts` uploads Composer's tar.gz artifact with. */ +const ARTIFACT_CONTENT_TYPE = 'application/gzip'; + +/** + * The keys the platform seeds and owns. Composer used to overwrite them with a + * garbage value so nothing could rely on the platform default; upstream + * refuses to manage a variable the platform marks `isManagedBySystem`, so + * those writes are gone (see control/extension.ts) and the rows they left + * behind are disposed of here. + */ +const POISON_KEYS: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); + +type Family = 'Project' | 'Database' | 'Connection' | 'App' | 'Deployment' | 'EnvironmentVariable'; + +const FAMILY_BY_LEGACY_TYPE: Readonly> = { + 'Prisma.Project': 'Project', + 'Prisma.Database': 'Database', + 'Prisma.Connection': 'Connection', + 'Prisma.ComputeService': 'App', + 'Prisma.Deployment': 'Deployment', + 'Prisma.EnvironmentVariable': 'EnvironmentVariable', + 'PrismaComposer.Project': 'Project', + 'PrismaComposer.Database': 'Database', + 'PrismaComposer.Connection': 'Connection', + 'PrismaComposer.ComputeService': 'App', + 'PrismaComposer.Deployment': 'Deployment', + 'PrismaComposer.EnvironmentVariable': 'EnvironmentVariable', +}; + +/** The type-id upstream registers each family under. */ +const UPSTREAM_TYPE: Readonly> = { + Project: 'Prisma.Project', + Database: 'Prisma.Database', + Connection: 'Prisma.Connection', + App: 'Prisma.App', + Deployment: 'Prisma.Deployment', + EnvironmentVariable: 'Prisma.EnvironmentVariable', +}; + +/** + * Four of the six families keep the type-id they always had, so the type-id + * alone cannot say whether a row is legacy or already upstream's. Each shape + * is told apart by a props field only the legacy one has, which is also what + * makes the whole rewrite idempotent. + */ +const isLegacyProps = (family: Family, props: Record): boolean => { + switch (family) { + case 'Project': + return 'workspaceId' in props; + case 'Database': + return 'projectId' in props && !('project' in props); + case 'Connection': + return 'databaseId' in props && !('database' in props); + case 'App': + return 'projectId' in props && !('project' in props); + case 'Deployment': + return 'computeServiceId' in props; + case 'EnvironmentVariable': + return 'projectId' in props && !('project' in props); + } +}; + +const migrateProps = (family: Family, props: unknown): unknown => { + if (!isRecord(props) || !isLegacyProps(family, props)) return props; + switch (family) { + case 'Project': + // Upstream ProjectProps carry no workspaceId; keep only the name. + return { name: props['name'] }; + case 'Database': + return { + project: props['projectId'], + name: props['name'], + region: props['region'], + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + case 'Connection': + return { database: props['databaseId'], name: props['name'] }; + case 'App': + return { + project: props['projectId'], + displayName: props['name'], + regionId: props['region'] ?? DEFAULT_REGION, + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + case 'Deployment': + // The legacy `environment` prop is dropped: upstream's Deployment has no + // such prop, and the ordering edge it carried rides `app` instead (see + // compute/deployment-edge.ts). `start`/`promote` are what the legacy + // provider always did unconditionally. + return { + app: props['computeServiceId'], + artifactPath: props['artifactPath'], + artifactContentType: ARTIFACT_CONTENT_TYPE, + ...(props['port'] !== undefined ? { portMapping: { http: props['port'] } } : {}), + start: true, + promote: true, + }; + case 'EnvironmentVariable': { + const value = props['value']; + return { + project: props['projectId'], + key: props['key'], + class: props['class'] ?? 'production', + // Legacy rows persisted the value as PLAIN TEXT. Upstream types it + // `Redacted`, which is also what keeps it out of the next state write. + value: Redacted.isRedacted(value) ? value : Redacted.make(String(value ?? '')), + ...(props['branchId'] !== undefined ? { branchId: props['branchId'] } : {}), + }; + } + } +}; + +/** + * A poison-key row named a variable the PLATFORM owns. Deleting one for real + * is not safe: the legacy adoption matched on `{projectId, class, key}` with + * no branch id, so the recorded scope may not equal the live variable's, and + * upstream's delete refuses — loudly, mid-deploy — on a scope mismatch. + * Handing it an id upstream reads as "not a cloud resource" + * (`isPrismaDevId`) makes that delete a guaranteed no-op: the state row goes + * away on the next deploy, the platform's own variable is left untouched, and + * nothing is left double-managed. + */ +const poisonKeyAttr = (key: string) => ({ + environmentVariableId: `dev:legacy-poison-${key}`, + key, +}); + +const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => { + if (!isRecord(attr)) return attr; + const oldProps = isRecord(props) ? props : {}; + switch (family) { + case 'Project': { + if (typeof attr['id'] !== 'string' || 'projectId' in attr) return attr; + return { + projectId: attr['id'], + projectName: attr['name'], + workspaceId: oldProps['workspaceId'] ?? '', + createdAt: EPOCH, + defaultRegion: null, + }; + } + case 'Database': { + if (typeof attr['id'] !== 'string' || 'databaseId' in attr) return attr; + return { + databaseId: attr['id'], + databaseName: attr['name'] ?? oldProps['name'], + projectId: oldProps['projectId'], + status: 'ready', + region: oldProps['region'] ?? null, + isDefault: oldProps['isDefault'] ?? false, + branchId: oldProps['branchId'] ?? null, + defaultConnectionId: null, + createdAt: EPOCH, + }; + } + case 'Connection': { + if (typeof attr['id'] !== 'string' || 'connectionId' in attr) return attr; + return { + connectionId: attr['id'], + connectionName: oldProps['name'], + databaseId: oldProps['databaseId'], + kind: 'postgres', + createdAt: EPOCH, + directConnectionString: attr['connectionString'], + databaseUrl: attr['connectionString'], + }; + } + case 'App': { + if (typeof attr['id'] !== 'string' || 'appId' in attr) return attr; + return { + appId: attr['id'], + name: attr['name'] ?? oldProps['name'], + projectId: oldProps['projectId'], + regionId: oldProps['region'] ?? DEFAULT_REGION, + // A production row recorded no branch: null makes upstream's diff plan + // an update, whose reconcile re-reads the App and records the real + // default-branch id. A branch stage recorded its own and converges. + branchId: oldProps['branchId'] ?? null, + latestDeploymentId: null, + // Absent only on a row written before the platform returned a domain. + // Left unset rather than faked: a service's own origin is read from + // this attribute, and an empty string would wire a broken origin + // silently where an absent one fails loudly. + ...(attr['endpointDomain'] !== undefined + ? { appEndpointDomain: attr['endpointDomain'] } + : {}), + createdAt: EPOCH, + }; + } + case 'Deployment': { + if (typeof attr['deploymentId'] !== 'string' || 'appId' in attr) return attr; + return { + deploymentId: attr['deploymentId'], + appId: oldProps['computeServiceId'], + // `foundryVersionId` is deliberately absent, not invented: upstream + // uses it to recover a deployment whose id was lost, and a made-up + // one would either match nothing or claim a stranger's deployment. + // Absent means "recover by deployment id only". + status: undefined, + previewDomain: undefined, + appEndpointDomain: attr['deployedUrl'], + createdAt: undefined, + }; + } + case 'EnvironmentVariable': { + if (typeof attr['id'] !== 'string' || 'environmentVariableId' in attr) return attr; + const key = attr['key'] ?? oldProps['key']; + if (typeof key === 'string' && POISON_KEYS.has(key)) return poisonKeyAttr(key); + return { + environmentVariableId: attr['id'], + projectId: oldProps['projectId'], + branchId: oldProps['branchId'] ?? null, + class: oldProps['class'] ?? 'production', + key, + // The API never returns plaintext, so upstream's own cold-read + // placeholder is what belongs here — the desired value arrives from + // props on every reconcile. + value: Redacted.make(''), + valueKid: '', + isManagedBySystem: false, + createdAt: EPOCH, + updatedAt: EPOCH, + }; + } + } +}; + +const migrateResourceRow = (row: Record): Record => { + const resourceType = row['resourceType']; + if (typeof resourceType !== 'string') return row; + const family = FAMILY_BY_LEGACY_TYPE[resourceType]; + if (family === undefined) return row; + + const migrated: Record = { + ...row, + resourceType: UPSTREAM_TYPE[family], + ...('props' in row ? { props: migrateProps(family, row['props']) } : {}), + ...('attr' in row ? { attr: migrateAttr(family, row['attr'], row['props']) } : {}), + }; + + // Replacement rows nest the displaced generation under `old` (a full row); + // updating rows nest `{props, attr, bindings}`. Migrate both forms so no + // stale shape survives anywhere in the chain. + const old = row['old']; + if (isRecord(old)) { + migrated['old'] = + typeof old['resourceType'] === 'string' + ? migrateResourceRow(old) + : { + ...old, + ...('props' in old ? { props: migrateProps(family, old['props']) } : {}), + ...('attr' in old ? { attr: migrateAttr(family, old['attr'], old['props']) } : {}), + }; + } + return migrated; +}; + +/** + * Maps a revived state value from a legacy Composer resource shape to the + * upstream shape. Rows of other resource types (and action rows) pass through + * untouched; the function is idempotent, so already-migrated rows pass + * through too. + */ +export const migrateLegacyResourceState = (value: unknown): unknown => { + if (!isRecord(value) || value['kind'] === 'action') return value; + return migrateResourceRow(value); +}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts index f82c3278..394e4a56 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts @@ -11,7 +11,7 @@ import { import * as Effect from 'effect/Effect'; import type postgres from 'postgres'; import { toStateStoreError } from './errors.ts'; -import { migrateLegacyPostgresState } from './legacy-postgres.ts'; +import { migrateLegacyResourceState } from './legacy-resources.ts'; import { retryColdStart } from './transient.ts'; const attempt = (f: () => Promise): Effect.Effect => @@ -43,15 +43,15 @@ const jsonParam = (sql: postgres.Sql, value: unknown): postgres.Parameter => const revivePersistedState = (value: unknown): PersistedState => blindCast< PersistedState, - 'reviveStateRecursive returns unknown; the row was written by set() through encodeState, so the revived shape is a PersistedState by construction — migrateLegacyPostgresState only rewrites legacy postgres-family rows to the upstream field names, preserving that shape' - >(migrateLegacyPostgresState(reviveStateRecursive(value))); + 'reviveStateRecursive returns unknown; the row was written by set() through encodeState, so the revived shape is a PersistedState by construction — migrateLegacyResourceState only rewrites legacy Composer resource rows to the upstream field names, preserving that shape' + >(migrateLegacyResourceState(reviveStateRecursive(value))); /** Same reasoning as {@link revivePersistedState}, narrowed by the SQL status filter. */ const reviveReplacedResourceState = (value: unknown): ReplacedResourceState => blindCast< ReplacedResourceState, - "filtered to status = 'replaced' in SQL; the row was written by set() through encodeState, so the revived shape is a ReplacedResourceState by construction — migrateLegacyPostgresState only rewrites legacy postgres-family rows to the upstream field names, preserving that shape" - >(migrateLegacyPostgresState(reviveStateRecursive(value))); + "filtered to status = 'replaced' in SQL; the row was written by set() through encodeState, so the revived shape is a ReplacedResourceState by construction — migrateLegacyResourceState only rewrites legacy Composer resource rows to the upstream field names, preserving that shape" + >(migrateLegacyResourceState(reviveStateRecursive(value))); /** * Builds alchemy's `StateService` over a caller-supplied postgres.js client, 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 d20e6cb2..0f00a1e6 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 @@ -41,6 +41,7 @@ import * as RealS3Credentials from '../s3-credentials-resource.ts'; // the resolved value the mock resource returned). const recorded: { envVar: Array<[string, unknown]>; + envVarProps: Array<[string, { value: unknown }]>; db: Array<[string, unknown]>; conn: Array<[string, unknown]>; warm: Array<[string, unknown]>; @@ -54,6 +55,7 @@ const recorded: { generated: Array<[string, unknown]>; } = { envVar: [], + envVarProps: [], db: [], conn: [], warm: [], @@ -73,14 +75,50 @@ mock.module('alchemy/Output', () => ({ // Mirrors `map` above: every "output" here is already the resolved value a // mock resource returned, so combining them is just collecting the array. all: (...outs: unknown[]) => outs, + // Same collapse for `flatMap`, whose function returns an "output" that is + // already a resolved value here. What flatMap is FOR — keeping a dependency + // edge visible to Alchemy's planner while resolving to a value — cannot be + // observed through these mocks at all; it is proven against the real Output + // machinery in @internal/lowering's deployment-edge test. + flatMap: (output: unknown, fn: (v: unknown) => unknown) => fn(output), })); -// The postgres family (Database/Connection) is upstream alchemy's — the -// descriptors import it from 'alchemy/Prisma', so the stubs live there. The -// returned attributes use upstream's field names (`databaseId`, -// `directConnectionString`). +// The postgres family (Database/Connection) and the compute family +// (App/Deployment/EnvironmentVariable) are upstream alchemy's — the descriptors +// import them from 'alchemy/Prisma', so the stubs live there. The returned +// attributes use upstream's field names (`databaseId`, `appId`, +// `appEndpointDomain`, `environmentVariableId`). mock.module('alchemy/Prisma', () => ({ ...RealAlchemyPrisma, + App: (id: string, props: unknown) => { + recorded.svc.push([id, props]); + return Effect.succeed({ + appId: `${id}#cloud-id`, + name: id, + appEndpointDomain: `https://${id}.example`, + }); + }, + Deployment: (id: string, props: unknown) => { + recorded.deploy.push([id, props]); + return Effect.succeed({ + deploymentId: 'v1', + appEndpointDomain: `https://${id}.example`, + }); + }, + EnvironmentVariable: (id: string, props: { key: string; value: unknown }) => { + // Upstream's prop is `Redacted`. Recorded UNWRAPPED so every row + // assertion below can name the value it expects in plain text; the + // wrapper itself is pinned by its own test, off `recorded.envVarProps`. + recorded.envVarProps.push([id, props]); + recorded.envVar.push([ + id, + { + ...props, + value: Redacted.isRedacted(props.value) ? Redacted.value(props.value) : props.value, + }, + ]); + return Effect.succeed({ environmentVariableId: `${id}#cloud-id`, key: props.key }); + }, Database: (id: string, props: unknown) => { recorded.db.push([id, props]); return Effect.succeed({ databaseId: `${id}#cloud-id`, databaseName: id }); @@ -97,10 +135,6 @@ mock.module('alchemy/Prisma', () => ({ mock.module('@internal/lowering', () => ({ ...RealPrismaAlchemy, providers: () => ({ stub: 'providers' }), - EnvironmentVariable: (id: string, props: { key: string }) => { - recorded.envVar.push([id, props]); - return Effect.succeed({ id: `${id}#cloud-id`, key: props.key }); - }, // A real Alchemy Resource (needs the Stack service); stubbed so // application.provision's mint runs purely. The returned "value" is // derived from `id` (which itself carries the edge id), so distinct edges @@ -124,18 +158,6 @@ mock.module('@internal/lowering', () => ({ bucketName: 'user-bucket-stub', }); }, - ComputeService: (id: string, props: unknown) => { - recorded.svc.push([id, props]); - return Effect.succeed({ - id: `${id}#cloud-id`, - name: id, - endpointDomain: `https://${id}.example`, - }); - }, - Deployment: (id: string, props: unknown) => { - recorded.deploy.push([id, props]); - return Effect.succeed({ deploymentId: 'v1', deployedUrl: `https://${id}.example` }); - }, packageComputeArtifact: (opts: { id: string }) => { recorded.pkg.push([opts]); return { path: `/tmp/${opts.id}.tar.gz`, sha256: `sha-${opts.id}` }; @@ -216,7 +238,7 @@ const run = (eff: Effect.Effect): A => type Resolved = T extends RealOutput.Output ? U : T; type Mirror = { readonly [K in keyof T]: Resolved }; /** The mock EnvironmentVariable, standing in for the real resource. */ -type MockedEnvironment = ReadonlyArray<{ id: string; key: string }>; +type MockedEnvironment = ReadonlyArray<{ environmentVariableId: string; key: string }>; type MockedProvisioned = Mirror; type MockedSerialized = Omit, 'environment'> & { @@ -315,7 +337,7 @@ describe("projectIdOf — narrowing ctx.application to this extension's own prod }); describe('prismaCloud().application.provision (once-per-lowering hook)', () => { - test('default stage: references the resolved container project (no Project minted), poisons DATABASE_URL + DATABASE_URL_POOLED with "-", class production, no branchId', () => { + test('default stage: references the resolved container project (no Project minted), writes NO environment variable', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const before = recorded.envVar.length; const container = new PrismaCloudContainer( @@ -332,30 +354,15 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: undefined }); - // "-", not "": the API rejects empty env-var values (verified at the R4 deploy proof). - expect(recorded.envVar.slice(before)).toEqual([ - [ - 'DATABASE_URL-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL', - value: '-', - class: 'production', - }, - ], - [ - 'DATABASE_URL_POOLED-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL_POOLED', - value: '-', - class: 'production', - }, - ], - ]); + // DATABASE_URL/DATABASE_URL_POOLED used to be overwritten with garbage + // here. The platform owns those variables (it marks them system-managed, + // and alchemy refuses to manage a system-managed variable), so this hook + // writes nothing at all; param.ts/secret.ts still reject both names, so no + // Composer row can carry one. + expect(recorded.envVar.slice(before)).toEqual([]); }); - test('named stage: poison env vars carry class "preview" and branchId', () => { + test('named stage: still writes no environment variable', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const before = recorded.envVar.length; const container = new PrismaCloudContainer( @@ -372,28 +379,7 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: 'branch_1' }); - expect(recorded.envVar.slice(before)).toEqual([ - [ - 'DATABASE_URL-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL', - value: '-', - class: 'preview', - branchId: 'branch_1', - }, - ], - [ - 'DATABASE_URL_POOLED-poison', - { - projectId: 'shop-project-id', - key: 'DATABASE_URL_POOLED', - value: '-', - class: 'preview', - branchId: 'branch_1', - }, - ], - ]); + expect(recorded.envVar.slice(before)).toEqual([]); }); test('fails with the container-missing error when the CLI parent never resolved one', () => { @@ -556,7 +542,10 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { endpointDomain: 'https://auth-svc.example', }); expect(recorded.svc).toEqual([ - ['auth-svc', { projectId: 'shop-project#cloud-id', name: 'auth', region: 'us-east-1' }], + [ + 'auth-svc', + { project: 'shop-project#cloud-id', displayName: 'auth', regionId: 'us-east-1' }, + ], ]); }); }); @@ -576,9 +565,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'auth2-svc', { - projectId: 'shop-project#cloud-id', - name: 'auth2', - region: 'us-east-1', + project: 'shop-project#cloud-id', + displayName: 'auth2', + regionId: 'us-east-1', branchId: 'branch_1', }, ], @@ -622,7 +611,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_DB_URL-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_DB_URL', value: 'postgres://real-db', class: 'production', @@ -634,7 +623,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_PORT-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_PORT', value: '3000', class: 'production', @@ -645,7 +634,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH_ORIGIN-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_ORIGIN', value: '"https://auth-svc.example"', class: 'production', @@ -653,9 +642,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { ], ]); expect(result.environment).toEqual([ - { id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }, - { id: 'COMPOSER_AUTH_PORT-var#cloud-id', key: 'COMPOSER_AUTH_PORT' }, - { id: 'COMPOSER_AUTH_ORIGIN-var#cloud-id', key: 'COMPOSER_AUTH_ORIGIN' }, + { + environmentVariableId: 'COMPOSER_AUTH_DB_URL-var#cloud-id', + key: 'COMPOSER_AUTH_DB_URL', + }, + { environmentVariableId: 'COMPOSER_AUTH_PORT-var#cloud-id', key: 'COMPOSER_AUTH_PORT' }, + { + environmentVariableId: 'COMPOSER_AUTH_ORIGIN-var#cloud-id', + key: 'COMPOSER_AUTH_ORIGIN', + }, ]); // serialize also surfaces the resolved listen port for deploy() — the // Deployment must route to whatever the app binds, not a constant. @@ -663,6 +658,49 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); }); + test('every env-var value reaches the platform as a Redacted value, never a bare string', async () => { + await withEnv({}, () => { + const target = prismaCloud({ workspaceId: 'ws_1' }); + const node = compute({ + name: 'test-service', + deps: { + db: postgres(), + }, + build: { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', + }, + }); + const ctx = { + address: 'auth', + node, + graph: { inputBindings: [], edges: [] }, + application: { projectId: 'shop-project#cloud-id', branchId: undefined }, + } as unknown as LowerContext; + const provisioned = { + serviceId: 'auth-svc#cloud-id', + projectId: 'shop-project#cloud-id', + endpointDomain: 'https://auth-svc.example', + }; + const before = recorded.envVarProps.length; + + run( + serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, { + service: { port: 3000 }, + inputs: { db: { url: 'postgres://real-db' } }, + }), + ); + + const written = recorded.envVarProps.slice(before); + expect(written.length).toBeGreaterThan(0); + // A bare string here would put the value in Alchemy's state file in + // plain text — the wrapper is what keeps it out. + expect(written.every(([, props]) => Redacted.isRedacted(props.value))).toBe(true); + }); + }); + test('an optional connection param with no provisioned value writes NO env-var row; a provided one still does', async () => { await withEnv({}, () => { const target = prismaCloud({ workspaceId: 'ws_1' }); @@ -710,7 +748,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const writes = recorded.envVar.slice(before).map(([, props]) => props); // The provided url still writes its row... expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_CONSUMER_AUTH_URL', value: 'http://auth.internal', class: 'production', @@ -770,7 +808,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { // One self-describing document; the secret leaf is a pointer naming // the platform var, never a value. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_INGEST_INPUT', value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', class: 'production', @@ -835,7 +873,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { // document's $generated pointer names. const writes = recorded.envVar.slice(beforeEnv).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_INGEST_SECRET_GENERATED', value: 'generated-for-COMPOSER_INGEST_INPUT:secret-generated', class: 'production', @@ -941,7 +979,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const writes = recorded.envVar.slice(before).map(([, props]) => props); // The pointer row holds the bound platform NAME, never a value. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_PORT', value: '@composer-param-pointer:PLATFORM_PORT', class: 'production', @@ -988,7 +1026,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const writes = recorded.envVar.slice(before).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_PORT', value: '4100', class: 'production', @@ -1030,7 +1068,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH3_PORT-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_PORT', value: '3000', class: 'preview', @@ -1040,7 +1078,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { [ 'COMPOSER_AUTH3_ORIGIN-var', { - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_ORIGIN', value: '"https://svc.example"', class: 'preview', @@ -1110,13 +1148,18 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(result).toEqual({ path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }); }); - test("deploy's environment prop IS serialize's returned records — the edge that kills PRO-211", () => { + test("deploy's artifactPath carries serialize's env records — the ordering edge that kills PRO-211", () => { const target = prismaCloud({ workspaceId: 'ws_1' }); const ctx = { id: 'auth' } as unknown as LowerContext; const provisioned = { serviceId: 'auth-svc#cloud-id', projectId: 'shop-project#cloud-id' }; const artifact = { path: '/tmp/auth.tar.gz', sha256: 'sha-auth' }; const serialized = { - environment: [{ id: 'COMPOSER_AUTH_DB_URL-var#cloud-id', key: 'COMPOSER_AUTH_DB_URL' }], + environment: [ + { + environmentVariableId: 'COMPOSER_AUTH_DB_URL-var#cloud-id', + key: 'COMPOSER_AUTH_DB_URL', + }, + ], // A non-default port from serialize must reach the Deployment verbatim. port: 8080, }; @@ -1125,15 +1168,21 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { serviceDescriptorOf(target, 'compute').deploy(ctx, provisioned, artifact, serialized), ); + // The mocked `Output.all`/`Output.map` collapse to "apply the function to + // the collected values", so the recorded artifactPath is the resolved + // path — what the real Output resolves to as well. What the assertion + // pins is that the path is built FROM the env rows' ids, which is the + // dependency Alchemy schedules the writes on. expect(recorded.deploy).toEqual([ [ 'auth-deploy', { - computeServiceId: 'auth-svc#cloud-id', + app: 'auth-svc#cloud-id', artifactPath: '/tmp/auth.tar.gz', - artifactHash: 'sha-auth', - environment: serialized.environment, - port: 8080, + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, }, ], ]); @@ -1457,13 +1506,13 @@ describe('sharing: one module-provisioned postgres, two compute consumers — th const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_MAIN_URL', value: 'postgres://data-conn', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_BILLING_STORE_URL', value: 'postgres://data-conn', class: 'production', @@ -1534,13 +1583,13 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_WEB_AUTH_SERVICEKEY', value: 'key-for-servicekey-web.auth', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH_RPC_ACCEPTED_KEYS', value: '["key-for-servicekey-web.auth"]', class: 'production', @@ -1618,7 +1667,7 @@ describe('ADR-0030: per-binding RPC service keys — mint (control.ts) + wire (d const writes = recorded.envVar.slice(before.envVar).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_AUTH3_RPC_ACCEPTED_KEYS', value: '[]', class: 'production', @@ -1740,7 +1789,7 @@ describe("streams' provisioned bearer key — one value per PROVIDER, stored on // validates and re-stashes it), JSON-encoded like any service-own // literal param. expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_EVENTS_STREAMS_API_KEY', value: '"key-for-streamskey-events"', class: 'production', @@ -1897,13 +1946,13 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis const writes = recorded.envVar.slice(before).map(([, props]) => props); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_MULTI_PARAM_ONE', value: '"value-one"', class: 'production', }); expect(writes).toContainEqual({ - projectId: 'shop-project#cloud-id', + project: 'shop-project#cloud-id', key: 'COMPOSER_MULTI_PARAM_TWO', value: '"value-two"', class: 'production', 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 5c560977..b71a7e71 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 @@ -11,6 +11,7 @@ import * as Prisma from '@internal/lowering'; import { prismaStateLayer } from '@internal/lowering/state'; import { RPC_PEER_KEY } from '@internal/service-rpc'; import * as Output from 'alchemy/Output'; +import * as AlchemyPrisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import { @@ -152,7 +153,7 @@ const selfOriginValue: ServiceProviderParam['valueForService'] = (provisioned, a Output.map(provisioned.endpointDomain, (v) => { if (v === undefined) { throw new Error( - `ComputeService for "${address}" reported no endpointDomain at provision — cannot resolve the service's own origin (Management API predates the PRO-200 fix?)`, + `the App for "${address}" reported no endpoint domain at provision — cannot resolve the service's own origin (Management API predates the PRO-200 fix?)`, ); } return v; @@ -171,15 +172,15 @@ export interface PrismaCloudOptions { /** Defaults to the PRISMA_WORKSPACE_ID environment variable. */ workspaceId?: string; /** Defaults to the PRISMA_REGION environment variable when set. */ - region?: Prisma.ComputeRegion; + region?: AlchemyPrisma.Types.PrismaRegionId; } -// Prisma.COMPUTE_REGIONS is the runtime source of truth ComputeRegion is +// Upstream's KNOWN_REGION_IDS is the runtime source of truth PrismaRegionId is // derived from, so this can never fall behind — no hand-maintained list, no // exhaustiveness gymnastics to keep it honest. -const KNOWN_REGION_SET: ReadonlySet = new Set(Prisma.COMPUTE_REGIONS); +const KNOWN_REGION_SET: ReadonlySet = new Set(AlchemyPrisma.KNOWN_REGION_IDS); -function isComputeRegion(value: string): value is Prisma.ComputeRegion { +function isComputeRegion(value: string): value is AlchemyPrisma.Types.PrismaRegionId { return KNOWN_REGION_SET.has(value); } @@ -292,7 +293,7 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { if (!isComputeRegion(region)) { throw new Error( `prismaCloud(): environment variable PRISMA_REGION="${region}" is not a known region ` + - `(expected one of: ${Prisma.COMPUTE_REGIONS.join(', ')}).`, + `(expected one of: ${AlchemyPrisma.KNOWN_REGION_IDS.join(', ')}).`, ); } return { workspaceId, region, providerParams: PROVIDER_PARAMS }; @@ -345,28 +346,21 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // CLI removes the Branch/Project. teardown: (input) => runTeardown(input), - // Runs once per lowering, before any service: references the CLI-ensured - // Project, with the poison DATABASE_URL variables written immediately so - // nothing can ever rely on the platform default. Per-binding service keys - // are no longer minted here (ADR-0031): core's provision phase invokes - // `provisions` below, graph-wide, before any service lowers. + // Runs once per lowering, before any service: it resolves the CLI-ensured + // Project into the application handle every descriptor reads. Per-binding + // service keys are not minted here (ADR-0031): core's provision phase + // invokes `provisions` below, graph-wide, before any service lowers. + // + // `DATABASE_URL`/`DATABASE_URL_POOLED` are deliberately absent from this + // graph: the platform seeds them and marks them system-managed, and + // alchemy's EnvironmentVariable refuses to manage a system-managed + // variable at all. What keeps a service off the platform default is the + // ban at the authoring end — `param.ts` and `secret.ts` reject both names, + // so no Composer-written row can carry one (docs/guides/deploying.md). application: { provision: (ctx) => - Effect.gen(function* () { + Effect.sync(() => { const { projectId, branchId } = prismaCloudContainerOf(ctx.container); - for (const key of ['DATABASE_URL', 'DATABASE_URL_POOLED']) { - yield* Prisma.EnvironmentVariable(`${key}-poison`, { - projectId, - key, - // "-", not "": the API rejects empty env-var values with - // "String must contain at least 1 character" (verified at the R4 - // deploy proof). Any garbage value fails a real connect loudly. - value: '-', - class: branchId ? 'preview' : 'production', - ...(branchId !== undefined ? { branchId } : {}), - }); - } - return { projectId, branchId } satisfies CloudApplication; }), }, 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 47c2900a..1d463244 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 @@ -2,9 +2,11 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; +import { appAfterEnvironment, packageComputeArtifact } from '@internal/lowering'; import * as Output from 'alchemy/Output'; +import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; import { GeneratedParam } from '../generated-param-resource.ts'; import { paramBindingFor, paramName } from '../param.ts'; import { provisionedEdges } from '../provisioned-edges.ts'; @@ -28,10 +30,10 @@ import { * compute's provision → serialize/deploy handoff. `serviceId` is an * `Output`, not a `string`: the whole stack effect runs before Alchemy * applies anything, so a yielded resource's attributes are lazy references - * that only resolve at apply time. It reaches `Deployment`'s - * `computeServiceId` unchanged — that prop takes `Input`, which - * accepts the reference. `projectId` really is a `string`: it comes from the - * CLI's environment, not from a resource attribute. + * that only resolve at apply time. It reaches `Deployment`'s `app` prop + * unchanged — that prop takes `Input`, which accepts the + * reference. `projectId` really is a `string`: it comes from the CLI's + * environment, not from a resource attribute. */ export interface ComputeProvisioned { readonly serviceId: Output.Output; @@ -50,6 +52,18 @@ export interface ComputeSerialized { readonly input?: InputDocumentRow; } +/** + * Every env-var value goes to the platform wrapped in `Redacted`: the + * Management API never reads a value back, so alchemy persists the desired one + * in state to repair drift, and `Redacted` is what keeps it out of the + * serialized state row. A value that is still an unresolved deploy-time + * reference is wrapped inside the map, at the same point it becomes a string. + */ +const envValue = ( + value: string | Output.Output, +): Redacted.Redacted | Output.Output> => + Output.isOutput(value) ? Output.map(value, Redacted.make) : Redacted.make(value); + /** * Returns the PRECISE descriptor type, not the erased `NodeDescriptor`: the * registry in control.ts erases it on assignment anyway (method bivariance), @@ -68,13 +82,13 @@ export function computeDescriptor( validateName(id, 'service name (from provision id)'); const projectId = projectIdOf(application); const branchId = cloudApplicationOf(application).branchId; - const svc = yield* Prisma.ComputeService(`${id}-svc`, { - projectId, - name: id, - region: o().region ?? DEFAULT_REGION, + const svc = yield* Prisma.App(`${id}-svc`, { + project: projectId, + displayName: id, + regionId: o().region ?? DEFAULT_REGION, ...(branchId !== undefined ? { branchId } : {}), }); - return { serviceId: svc.id, projectId, endpointDomain: svc.endpointDomain }; + return { serviceId: svc.appId, projectId, endpointDomain: svc.appEndpointDomain }; }), // Two channels of rows: PARAMS (reserved-param literals JSON-encoded; @@ -110,9 +124,9 @@ export function computeDescriptor( : encode(d.owner, value); records.push( yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, + project: projectId, key, - value: rowValue, + value: envValue(rowValue), class: cls, ...branch, }), @@ -127,12 +141,12 @@ export function computeDescriptor( if (inputRow !== undefined) { records.push( yield* Prisma.EnvironmentVariable(`${inputRow.key}-var`, { - projectId, + project: projectId, key: inputRow.key, // The defaults-applied document — secret leaves are `$secret` // pointers, generated leaves are `$generated` pointers, naming // platform vars, never values (ADR-0042). - value: inputRow.value, + value: envValue(inputRow.value), class: cls, ...branch, }), @@ -148,9 +162,9 @@ export function computeDescriptor( }); records.push( yield* Prisma.EnvironmentVariable(`${leaf.varName}-var`, { - projectId, + project: projectId, key: leaf.varName, - value: resource.value, + value: envValue(resource.value), class: cls, ...branch, }), @@ -213,9 +227,9 @@ export function computeDescriptor( : encode('service', raw); records.push( yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, + project: projectId, key, - value, + value: envValue(value), class: cls, ...branch, }), @@ -237,7 +251,7 @@ export function computeDescriptor( // identically; the fs/tar work itself lives in @internal/lowering. package: ({ id }, { assembled, address }) => Effect.try(() => - Prisma.packageComputeArtifact({ + packageComputeArtifact({ id, bundleDir: assembled.dir, appEntry: assembled.entry, @@ -245,17 +259,27 @@ export function computeDescriptor( }), ), - // The environment prop references serialize's env-var records, so the deploy depends on them. deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { const deployment = yield* Prisma.Deployment(`${id}-deploy`, { - computeServiceId: provisioned.serviceId, + // `app` carries the ordering edge on serialize's variable writes as + // well as the app id — see `appAfterEnvironment` for why it is the + // only prop that can (PRO-211). + app: appAfterEnvironment(provisioned.serviceId, serialized.environment), artifactPath: artifact.path, - artifactHash: artifact.sha256, - environment: serialized.environment, + // The artifact IS a gzipped tar (see @internal/lowering's packager); + // upstream sends this as the upload's Content-Type and folds it into + // the fingerprint that decides whether a new deployment is needed. + artifactContentType: 'application/gzip', // Route to the port the app actually binds (the service's `port` // param, resolved by serialize) — not a hardcoded constant. - port: serialized.port, + portMapping: { http: serialized.port }, + // A Composer deploy always ships: upload the artifact, wait for it + // to run, then move the app's stable endpoint onto it. Neither is + // configurable — "deployed but not serving" is not a state Composer + // expresses. + start: true, + promote: true, }); // `url` IS published here: a Compute service's deployed URL is a // public endpoint, and this descriptor is the only party that knows @@ -279,12 +303,12 @@ export function computeDescriptor( } : {}; return { - outputs: { url: deployment.deployedUrl, projectId: provisioned.projectId }, + outputs: { url: deployment.appEndpointDomain, projectId: provisioned.projectId }, entities: [ { kind: 'compute-service', id: provisioned.serviceId, - url: deployment.deployedUrl, + url: deployment.appEndpointDomain, ...inputDetails, }, ], 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 da98ba83..823e8f5c 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 @@ -1,7 +1,7 @@ /** Helpers shared by the per-node-kind descriptors under `src/descriptors/` and the extension factory in `control.ts`. */ -import type * as Prisma from '@internal/lowering'; import type * as Output from 'alchemy/Output'; +import type * as Prisma from 'alchemy/Prisma'; import type { ProviderParamEntry } from '../serializer.ts'; /** @@ -64,7 +64,7 @@ export interface ServiceProviderParam extends ProviderParamEntry { */ export interface ResolvedCloudOptions { readonly workspaceId: string; - readonly region?: Prisma.ComputeRegion; + readonly region?: Prisma.Types.PrismaRegionId; /** * This extension's reserved provider params, keyed by need brand — * edge-derived (`ProviderParam`) or service-derived (`ServiceProviderParam`). @@ -77,7 +77,7 @@ export interface ResolvedCloudOptions { } /** Where a resource lands when the deploy names no region. */ -export const DEFAULT_REGION: Prisma.ComputeRegion = 'us-east-1'; +export const DEFAULT_REGION: Prisma.Types.PrismaRegionId = 'us-east-1'; // Prisma's Connection create constrains `name` to 3–65 chars (Management API: // POST /v1/connections); applied here to every id-derived resource name as the diff --git a/packages/1-prisma-cloud/1-extensions/target/src/param.ts b/packages/1-prisma-cloud/1-extensions/target/src/param.ts index db96a863..69add7d3 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/param.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/param.ts @@ -19,14 +19,18 @@ export interface EnvParamPayload { } const RESERVED_PARAM_PREFIX = 'COMPOSER_'; -const POISONED_PARAM_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); +/** The names Prisma Cloud owns: it seeds and manages them, so Composer never binds one. */ +const PLATFORM_OWNED_PARAM_NAMES: ReadonlySet = new Set([ + 'DATABASE_URL', + 'DATABASE_URL_POOLED', +]); /** * Binds a param slot to a named Prisma Cloud platform env var — the non-secret * sibling of `envSecret` (spec: env-sourced config params). The platform * injects the value into the running instance per stage; the param's own * schema validates it at boot, unredacted. The name may not use the - * framework's reserved `COMPOSER_` prefix or the poisoned + * framework's reserved `COMPOSER_` prefix or the platform-owned * `DATABASE_URL(_POOLED)` keys — same parity as `envSecret`. */ export function envParam(name: string): ParamSource { @@ -41,10 +45,11 @@ export function envParam(name: string): ParamSource { "reserved for the framework's own generated config keys.", ); } - if (POISONED_PARAM_NAMES.has(name)) { + if (PLATFORM_OWNED_PARAM_NAMES.has(name)) { throw new Error( - `envParam name "${name}" is reserved — ${[...POISONED_PARAM_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a param.', + `envParam name "${name}" is reserved — ${[...PLATFORM_OWNED_PARAM_NAMES].join(' and ')} ` + + 'are seeded and managed by Prisma Cloud itself, so the framework refuses to bind them. ' + + 'Declare the database your service uses and read its url from that connection.', ); } return paramSource({ [PRISMA_CLOUD_PARAM_SOURCE]: true, name }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 5fcec7de..28230e1f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -109,8 +109,9 @@ async function existsOnPlatform( /** * Provision `key`=`value` directly via the Management API for the target * stage's scope (a production template for the default stage; a preview branch - * override for a named stage — the same scope the pack writes config rows to, - * EnvironmentVariable.ts). A 409 means a concurrent deploy already provisioned + * override for a named stage — the same scope the pack's config rows are + * written to, through alchemy's `Prisma.EnvironmentVariable`). A 409 means a + * concurrent deploy already provisioned * it — tolerated. The value is never logged. */ async function fillMissing( diff --git a/packages/1-prisma-cloud/1-extensions/target/src/secret.ts b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts index f9a9a029..6efab2ee 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/secret.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts @@ -19,12 +19,16 @@ export interface EnvSecretPayload { } const RESERVED_SECRET_PREFIX = 'COMPOSER_'; -const POISONED_SECRET_NAMES: ReadonlySet = new Set(['DATABASE_URL', 'DATABASE_URL_POOLED']); +/** The names Prisma Cloud owns: it seeds and manages them, so Composer never binds one. */ +const PLATFORM_OWNED_SECRET_NAMES: ReadonlySet = new Set([ + 'DATABASE_URL', + 'DATABASE_URL_POOLED', +]); /** * Binds a secret slot to a named Prisma Cloud platform env var (ADR-0029). The * value is provisioned out-of-band; only the name is carried. The name may not - * use the framework's reserved `COMPOSER_` prefix or the poisoned + * use the framework's reserved `COMPOSER_` prefix or the platform-owned * `DATABASE_URL(_POOLED)` keys. */ export function envSecret(name: string): SecretSource { @@ -39,10 +43,11 @@ export function envSecret(name: string): SecretSource { "reserved for the framework's own generated config keys.", ); } - if (POISONED_SECRET_NAMES.has(name)) { + if (PLATFORM_OWNED_SECRET_NAMES.has(name)) { throw new Error( - `envSecret name "${name}" is reserved — ${[...POISONED_SECRET_NAMES].join(' and ')} are ` + - 'poisoned at project provision and cannot back a secret.', + `envSecret name "${name}" is reserved — ${[...PLATFORM_OWNED_SECRET_NAMES].join(' and ')} ` + + 'are seeded and managed by Prisma Cloud itself, so the framework refuses to bind them. ' + + 'Declare the database your service uses and read its url from that connection.', ); } return secretSource({ [PRISMA_CLOUD_SECRET_SOURCE]: true, name }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index ff2deb92..91fd8256 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -8,8 +8,9 @@ * root — empty for a lone-service deploy, the "unprefixed" case), then the * owner (the input name, dropped for the service's own params), then the * param name. auth's db.url ↔ AUTH_DB_URL; a lone service's db.url ↔ DB_URL. - * The platform's DATABASE_URL is never among them — forbidden and poisoned - * at project provision (see docs/design/05-prisma-cloud/alchemy-lowering.md). + * The platform's DATABASE_URL is never among them: Prisma Cloud owns that name + * and the framework refuses to bind it (see + * docs/design/05-prisma-cloud/alchemy-lowering.md). * * This module works off the node's RAW params (`node.params` and each * `node.inputs[k].connection.params`) rather than `configOf`'s pure-data @@ -76,9 +77,10 @@ export const configKey = ( const owner = d.owner === 'service' ? [] : [d.owner.input]; // Every generated key lives in the framework's reserved COMPOSER_ namespace // (ADR-0029), so it can never collide with — and silently overwrite — a - // user-provisioned platform var (e.g. a secret's external name). The poison - // keys DATABASE_URL(_POOLED) are written directly in control.ts, not here, so - // they stay unprefixed (they are the platform's own names). + // user-provisioned platform var (e.g. a secret's external name). Composer + // writes no unprefixed variable at all: DATABASE_URL(_POOLED) are the + // platform's own names, banned in param.ts/secret.ts and owned by the + // platform (control/extension.ts). return ['COMPOSER', ...segments, ...owner, d.name].join('_').toUpperCase(); }; diff --git a/scripts/ci-cleanup-utils.ts b/scripts/ci-cleanup-utils.ts index cae20ad5..0e14d271 100644 --- a/scripts/ci-cleanup-utils.ts +++ b/scripts/ci-cleanup-utils.ts @@ -77,10 +77,11 @@ const isActiveDeployment409 = (r: HttpResponse): boolean => r.status === 409 && r.body.includes('active deployment'); /** - * The app DELETE 409s with this exact wording while its - * deployment is still winding down — the same "not delete-safe yet" match - * alchemy's ComputeService provider retries on (everything else is a real - * failure and must surface, not be retried). + * The app DELETE 409s with this exact wording while its deployment is still + * winding down. This cleanup retries on this wording alone; everything else is + * a real failure and must surface, not be retried. Alchemy's own App delete + * retries any conflict, but only about four seconds' worth — which is why this + * script keeps its own, longer, wording-specific budget. */ const isDeleteNotSafeYet409 = (r: HttpResponse): boolean => r.status === 409 && r.body.includes('did not reach a delete-safe state'); diff --git a/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 57addca0..a93eefdb 100644 --- a/test/integration/test/local-dev.integration.ts +++ b/test/integration/test/local-dev.integration.ts @@ -539,7 +539,10 @@ async function main(): Promise { assert(typeof webInfo.pid === 'number', 'web service must report a pid'); assert(typeof bkgInfo.pid === 'number', 'bkg service must report a pid'); - // 10. env store correct: poison DATABASE_URL rows. The port-override row + // 10. env store correct: every row lives in the COMPOSER_ namespace. + // Composer writes no unprefixed variable — an unprefixed name is a + // platform-owned one (DATABASE_URL and friends), and the platform manages + // those itself. The port-override row // (COMPOSER_
_PORT) is deliberately NEVER persisted to env.json // (local-dev spec § 4: "Ports live nowhere here" — the Deployment // provider materializes it fresh into each deployment's own env, in @@ -548,8 +551,13 @@ async function main(): Promise { // to compare against) rather than by reading env.json for a key it never // receives. const env = readJson(path.join(devDir, 'env.json')) as Record; - assertEqual(env['DATABASE_URL'], '-', 'env.json DATABASE_URL is poisoned'); - assertEqual(env['DATABASE_URL_POOLED'], '-', 'env.json DATABASE_URL_POOLED is poisoned'); + assertEqual(env['DATABASE_URL'], undefined, 'env.json holds no DATABASE_URL row'); + assertEqual(env['DATABASE_URL_POOLED'], undefined, 'env.json holds no DATABASE_URL_POOLED row'); + assertEqual( + Object.keys(env).every((key) => key.startsWith('COMPOSER_')), + true, + 'every env.json row lives in the COMPOSER_ namespace', + ); assertEqual( health.portEnv, JSON.stringify(webInfo.port), From e006eb0c58c1823286829acf32fc0be3a1217507 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 20:27:32 +0200 Subject: [PATCH 09/22] =?UTF-8?q?docs(design):=20ADR-0043=20=E2=80=94=20Pr?= =?UTF-8?q?isma=20Cloud=20resources=20come=20from=20the=20upstream=20Alche?= =?UTF-8?q?my=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- ...come-from-the-upstream-alchemy-provider.md | 96 +++++++++++++++++++ docs/design/90-decisions/README.md | 1 + 2 files changed, 97 insertions(+) create mode 100644 docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md diff --git a/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md new file mode 100644 index 00000000..3485d235 --- /dev/null +++ b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -0,0 +1,96 @@ +# ADR-0043: Prisma Cloud resources come from the upstream Alchemy provider + +## Decision + +Composer does not implement Alchemy resources for Prisma Cloud's Management +API. The six resource families that talk to it — project, database, +connection, app, deployment, environment variable — are the upstream +`alchemy/Prisma` provider's classes (`Prisma.Project`, `Prisma.Database`, +`Prisma.Connection`, `Prisma.App`, `Prisma.Deployment`, +`Prisma.EnvironmentVariable`), registered in Composer's own provider +collection and driven by Composer's lowering. + +Composer keeps defining resources only where no Management API exists behind +them: mint-once values (`ServiceKey`, `GeneratedParam`, `S3Credentials`), +`PnMigration`, `PgWarm`, and — until the upstream contribution ships in a +release — `Bucket`/`BucketKey`. The hosted state store keeps its policy layer +(state lives in a framework-owned database on the stage's Branch, ADR-0034); +its generic Postgres core is contributed upstream. + +The compute family binds the **low-level** trio, not the composite +`Prisma.Compute`: + +- A service's `COMPOSER_
_ORIGIN` row carries that service's own + platform-assigned domain (ADR-0039). One composite resource owning app, + environment rows, and deployment makes that row an input of the resource + that produces the domain — a self-edge the planner rejects. Splitting lets + the App surface `appEndpointDomain` in `provision`, before any row is + written. +- `Compute` owns environment rows through an internal ownership map with no + honest migration from per-key resource rows. +- `Compute` carries build, framework detection, and bundling. `artifactPath` + bypasses them, but the bypass is a prop value; `Prisma.Deployment` has no + build path at all, which is the structural form of ADR-0005's guarantee. + +The env→deployment ordering edge rides the deployment's `app` prop as an +Output over every environment row's id (`deployment-edge.ts`). It must not +ride `artifactPath`: upstream's diff requires that prop to be *resolved* +before comparing artifacts, and a brand-new environment row is unresolved at +plan time — the diff then degrades to a generic update that reuses the running +deployment while recording the new artifact's fingerprint, permanently and +silently skipping a code deploy. + +Local dev is unchanged in shape (ADR-0041): the local target rebinds the same +upstream resource classes to Composer's emulator providers at the +`LowerOptions.providers` seam. Upstream's own dev mode (`providers({ dev })`, +which Composer contributed) is not used, because Composer swaps the whole +layer rather than one provider's dev half. + +## Consequences + +- Composer's collection tag is `'PrismaComposer'` and its remaining resource + type-ids are `PrismaComposer.*` — the `Prisma.*` namespace belongs to + upstream. Rows written before the adoption migrate on read in the hosted + store (`state/legacy-resources.ts`): type-ids, attribute shapes, and the + neutralization of the retired poison rows. +- Branch-stage databases carry generated physical names. Upstream refuses an + explicit name combined with branch attachment in create (correct: PDP + creates and attaches in separate transactions with no idempotency key), and + attach-after-create is reverted by upstream's reconcile. Production keeps + explicit names; branch stages attach at create and take the generated name. +- The platform's seeded `DATABASE_URL`/`DATABASE_URL_POOLED` are no longer + overwritten. They are system-managed and upstream refuses to manage them. + The authoring-side ban remains: `param.ts`/`secret.ts` reject the names, and + every Composer row is `COMPOSER_`-prefixed. An app reading + `process.env.DATABASE_URL` outside the framework sees the platform's value. +- Auth never touches Alchemy's profile store: Composer provides + `PrismaEnvironment` directly from `PRISMA_SERVICE_TOKEN`, with one base-URL + resolver shared by the upstream providers and Composer's own SDK client. +- Two regressions are accepted and filed as upstream asks: an environment + value change alone no longer replaces the running deployment (the only + non-secret change signal upstream exposes, `updatedAt`, moves on every + deploy, so no mechanism at this layer can express "the value changed"), and + the App delete conflict-retry budget is ~3.75 s where Composer's old + provider waited up to 5 minutes. + +## Alternatives considered + +- **Keep Composer's own resources.** Rejected: six API wrappers whose drift + against the Management API Composer pays for alone, with a less hardened + deploy lifecycle than upstream's (no failure cleanup, no terminal-status + fast-fail, promote-response trust instead of post-promote observation). +- **Composite `Prisma.Compute`.** Rejected for the self-edge, the ownership + map, and ADR-0005 (above). +- **Vendor `src/Prisma/` into Composer.** Works mechanically, inherits + `@prisma/dev` and permanent drift. Kept only as a fallback if a future + alchemy bump is unshippable. +- **Upstream's dev mode instead of the local target.** Would tie local dev's + iteration to upstream review cadence and replace a whole-layer seam that + already works with a per-provider one. + +## References + +Upstream provider: alchemy-run/alchemy PR #416; Composer's contribution +(buckets, `providers({dev})`/`liveProviders()`, Postgres state backend): +PR #1061. Supersedes the resource inventory in ADR-0033's reading of +`alchemy-lowering.md`; ADR-0005, ADR-0034, ADR-0039, ADR-0041 unchanged. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index e47f641a..d95248f1 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -64,3 +64,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor. - [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. +- [ADR-0043](ADR-0043-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 `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where 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 edge rides the deployment's `app` prop, legacy state rows migrate on read, branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. Two accepted regressions filed as upstream asks: env value changes alone don't replace the running deployment, and the App delete-retry budget shrinks. From c107f7f68e19cf2291b306cb1f315f6d318a2edf Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 21:00:02 +0200 Subject: [PATCH 10/22] fix(lowering): report migrated poison rows as retained, correct migration docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy poison DATABASE_URL rows are retired with removalPolicy retain: the engine drops the state row, calls no API, and prints "retained" — the previous "deleted" was false (the platform variable survives, still holding the "-" placeholder on migrated stages). deploying.md, alchemy-lowering.md and the design notes now say so, with the optional manual cleanup calls. Part of alchemy-provider-adoption (TML-3155). Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 25 ++++++-- .../05-prisma-cloud/alchemy-lowering.md | 21 ++++--- docs/guides/deploying.md | 39 +++++++++--- .../state/__tests__/legacy-resources.test.ts | 15 ++++- .../lowering/src/state/legacy-resources.ts | 60 ++++++++++++++----- 5 files changed, 122 insertions(+), 38 deletions(-) diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md index 437dee2b..74c458f1 100644 --- a/.drive/projects/alchemy-provider-adoption/design-notes.md +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -135,12 +135,25 @@ default. The platform marks both system-managed, and upstream's writes are removed rather than reshaped (they would fail the deploy). What still holds the line is the ban at the authoring end: `param.ts` and `secret.ts` reject both names, so no Composer-written row can carry one, and -`configKey` puts every Composer row in the `COMPOSER_` namespace. Existing -poison state rows are neutralised on read (see `state/legacy-resources.ts`) -so their deletion never touches the platform's own variable. - -Residual: an app that reads `process.env.DATABASE_URL` on Prisma Cloud now gets -the platform's real default rather than a value that fails loudly. +`configKey` puts every Composer row in the `COMPOSER_` namespace. + +Existing poison state rows are marked `removalPolicy: "retain"` on read (see +`state/legacy-resources.ts`), so the engine drops the state row, calls no API, +and reports `retained` — the truthful verb. The deployed smoke run caught the +first version of this: it reported `deleted`, which told an operator the +platform variable was gone when it was still there. + +Residual, and it differs by stage: + +- A stage Composer never deployed before the swap: `DATABASE_URL` holds the + platform's own template value. An app reading it directly gets a working + default rather than something that fails loudly — that is the protection we + lost. +- A stage Composer HAD deployed: the `"-"` placeholder it wrote is still on the + platform, user-managed (`isManagedBySystem: false`), and stays until an + operator deletes it. `docs/guides/deploying.md` gives the call. So a migrated + stage keeps the old fail-loudly behaviour by accident, indefinitely, unless + someone cleans up. ## What the swap costs us, precisely diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 8291cab9..23c4e2ad 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -52,14 +52,19 @@ every database URL a service consumes is an explicit, per-service variable the pack's `serialize` writes under its own named key, inside the `COMPOSER_` namespace. -The framework used to go further and overwrite both platform variables with a -garbage value, so that a direct reader failed loudly. It no longer does. The -platform marks those variables system-managed, and alchemy's -`EnvironmentVariable` refuses to manage a system-managed variable at all — an -overwrite would fail the deploy rather than protect anyone. What remains is the -ban at the authoring end: `param.ts` and `secret.ts` reject both names, so no -Composer-written row can carry one. A service that reads `process.env.DATABASE_URL` -behind the framework's back now reads the platform's own value. +The framework used to go further and overwrite both platform variables with the +placeholder `"-"`, so that a direct reader failed loudly. It no longer does. The +platform marks those variables as its own, and alchemy's `EnvironmentVariable` +refuses to manage one — an overwrite would fail the deploy rather than protect +anyone. What remains is the ban at the authoring end: `param.ts` and `secret.ts` +reject both names, so no Composer-written row can carry one. + +A service that reads `process.env.DATABASE_URL` behind the framework's back +therefore reads whatever the platform holds: the platform's own template on an +environment Composer never deployed before the swap, or the leftover `"-"` on +one it did — the migration retires those rows from deploy state without +touching the variables ([deploying.md](../../guides/deploying.md) has the +manual cleanup). ## The resource inventory diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index f07b516f..8ae3fa79 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -272,13 +272,38 @@ and promotes once, then goes quiet. Subsequent deploys are unchanged: a service whose code has not changed is not redeployed. **`DATABASE_URL` and `DATABASE_URL_POOLED` are no longer overwritten.** The -framework used to replace the platform's seeded values with a placeholder so -that nothing could depend on them by accident. Those variables are owned by -Prisma Cloud, and the upstream provider refuses to manage a platform-owned -variable, so the framework leaves them alone. Nothing you write can carry those -names — `envSecret`/`envParam` still reject them — but an application that -reads `process.env.DATABASE_URL` directly now sees the platform's own value. -Read your database URL from the connection your service declares. +framework used to replace those variables' values with the placeholder `"-"`, +so that nothing could depend on them by accident. It no longer touches them: +they belong to Prisma Cloud, and the upstream provider refuses to manage a +variable the platform marks as its own. Nothing you write can carry those names +— `envSecret`/`envParam` still reject them — and every database URL your +services use comes from the connection they declare. + +On the first deploy after the upgrade, the framework **stops tracking** those +two variables. The deploy log reports them as `retained`, which is exactly what +happens: the entry is dropped from deploy state and **no call is made to Prisma +Cloud**, so whatever value the variable holds today stays. + +**What that means for an environment the framework deployed before the +upgrade**: the `"-"` placeholder it wrote is still there. An application that +reads `process.env.DATABASE_URL` directly reads `"-"` until someone removes the +variable. Cleaning it up is optional — nothing the framework deploys reads it — +and it is a manual step, because the framework will not delete a variable it no +longer manages: + +```bash +# Find the variable (add &branchId= for a --stage environment). +curl -s -H "Authorization: Bearer $PRISMA_SERVICE_TOKEN" \ + "https://api.prisma.io/v1/environment-variables?projectId=&key=DATABASE_URL" + +# Remove it, using the id from the response. +curl -s -X DELETE -H "Authorization: Bearer $PRISMA_SERVICE_TOKEN" \ + "https://api.prisma.io/v1/environment-variables/" +``` + +Repeat for `DATABASE_URL_POOLED`. Once removed, the platform's own template +value applies again. In the Console the same variables are visible under the +project's environment variables. **Stage (`--stage`) environments see two one-time effects on their first deploy after the upgrade**, because a branch-attached database can no longer diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts index d084a12e..674f41e7 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -670,12 +670,18 @@ describe('legacy compute-family rows against upstream providers', () => { expect(diff).toEqual({ action: 'update' }); }); - test('a poison DATABASE_URL row is neutralised: delete touches nothing', async () => { + test('a poison DATABASE_URL row is RETAINED, not deleted: state row retired, platform variable untouched', async () => { const migrated = migrateLegacyResourceState(legacyEnvRow('DATABASE_URL')) as MigratedRow; + // `retain` is what makes the engine drop the state row, skip the provider + // entirely, and report the resource as `retained` — the truthful verb for + // "we let go of it and called no API". Reporting `deleted` would tell an + // operator the platform variable is gone when it is still there. + expect(migrated['removalPolicy']).toBe('retain'); expect(migrated.attr).toEqual({ environmentVariableId: 'dev:legacy-poison-DATABASE_URL', key: 'DATABASE_URL', }); + expect(migrateLegacyResourceState(migrated)).toEqual(migrated); const service = await environmentVariableService(); // The stub's deleteEnvironmentVariable/getEnvironmentVariable die on this // id, so completing proves the platform's own variable is never touched. @@ -707,6 +713,13 @@ describe('legacy compute-family rows against upstream providers', () => { expect((migrateLegacyResourceState(composerEnv) as MigratedRow).resourceType).toBe( 'Prisma.EnvironmentVariable', ); + const composerPoison = { + ...legacyEnvRow('DATABASE_URL_POOLED'), + resourceType: 'PrismaComposer.EnvironmentVariable', + }; + const migratedPoison = migrateLegacyResourceState(composerPoison) as MigratedRow; + expect(migratedPoison.resourceType).toBe('Prisma.EnvironmentVariable'); + expect(migratedPoison['removalPolicy']).toBe('retain'); }); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts index 47bf48ab..69cb9db8 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -43,8 +43,15 @@ * no branch id and the project's default branch id is not derivable * offline, so upstream re-reads the App and repairs the attribute in * place. Branch-stage apps recorded their branch id and converge silently. - * · The poison `DATABASE_URL`/`DATABASE_URL_POOLED` rows are neutralised — - * see {@link poisonKeyAttr}. + * · The poison `DATABASE_URL`/`DATABASE_URL_POOLED` rows are RETIRED FROM + * STATE, and the variables they named are left on the platform exactly as + * they are — including the `"-"` placeholder value Composer wrote there + * before the migration. The deploy reports them as `retained`, which is + * what happened: no Management API call is made for them, ever. An + * operator who wants the placeholder gone deletes the variable by hand + * (docs/guides/deploying.md gives the call); until then, a service that + * reads `process.env.DATABASE_URL` on a migrated stage still reads `"-"`. + * See {@link retirePoisonRow}. * * Scope: the HOSTED state store only. Local dev state (alchemy's local * store) is never migrated — a stale local row under an unregistered type-id @@ -175,20 +182,40 @@ const migrateProps = (family: Family, props: unknown): unknown => { }; /** - * A poison-key row named a variable the PLATFORM owns. Deleting one for real - * is not safe: the legacy adoption matched on `{projectId, class, key}` with - * no branch id, so the recorded scope may not equal the live variable's, and - * upstream's delete refuses — loudly, mid-deploy — on a scope mismatch. - * Handing it an id upstream reads as "not a cloud resource" - * (`isPrismaDevId`) makes that delete a guaranteed no-op: the state row goes - * away on the next deploy, the platform's own variable is left untouched, and - * nothing is left double-managed. + * A poison-key row named a variable Composer must stop managing. Deleting one + * for real is not safe: the legacy adoption matched on `{projectId, class, + * key}` with no branch id, so the recorded scope may not equal the live + * variable's, and upstream's delete refuses — loudly, mid-deploy — on a scope + * mismatch. Whether the live variable is the platform's own system-managed + * template or the `"-"` placeholder Composer wrote over it depends on the + * stage, and neither is Composer's to remove. + * + * Two halves, doing different jobs: + * + * · `removalPolicy: "retain"` on the ROW. Alchemy's engine honors it before + * the provider is ever consulted: it drops the state row, makes no API + * call, and reports the resource as `retained` rather than `deleted` — + * which is the truthful verb, and the one an operator reading the deploy + * log needs to see. + * · An `environmentVariableId` the engine reads as "not a cloud resource" + * (`isPrismaDevId`). This governs what the PROVIDER would do if it were + * ever handed these attributes on some other path: nothing. */ -const poisonKeyAttr = (key: string) => ({ - environmentVariableId: `dev:legacy-poison-${key}`, - key, +const retirePoisonRow = (row: Record, key: string) => ({ + ...row, + removalPolicy: 'retain', + attr: { environmentVariableId: `dev:legacy-poison-${key}`, key }, }); +/** The key a poison row named, from whichever half of the row still carries it. */ +const poisonKeyOf = (family: Family, props: unknown, attr: unknown): string | undefined => { + if (family !== 'EnvironmentVariable') return undefined; + const fromAttr = isRecord(attr) ? attr['key'] : undefined; + const fromProps = isRecord(props) ? props['key'] : undefined; + const key = typeof fromAttr === 'string' ? fromAttr : fromProps; + return typeof key === 'string' && POISON_KEYS.has(key) ? key : undefined; +}; + const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => { if (!isRecord(attr)) return attr; const oldProps = isRecord(props) ? props : {}; @@ -268,14 +295,12 @@ const migrateAttr = (family: Family, attr: unknown, props: unknown): unknown => } case 'EnvironmentVariable': { if (typeof attr['id'] !== 'string' || 'environmentVariableId' in attr) return attr; - const key = attr['key'] ?? oldProps['key']; - if (typeof key === 'string' && POISON_KEYS.has(key)) return poisonKeyAttr(key); return { environmentVariableId: attr['id'], projectId: oldProps['projectId'], branchId: oldProps['branchId'] ?? null, class: oldProps['class'] ?? 'production', - key, + key: attr['key'] ?? oldProps['key'], // The API never returns plaintext, so upstream's own cold-read // placeholder is what belongs here — the desired value arrives from // props on every reconcile. @@ -302,6 +327,9 @@ const migrateResourceRow = (row: Record): Record Date: Mon, 3 Aug 2026 21:30:48 +0200 Subject: [PATCH 11/22] feat(lowering): env changes always reach the running app again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the pre-swap guarantee that a deploy ships what you declared: the deploy hook hard-links the content-addressed artifact into a per-deploy-generation path, so upstream Deployment plans a replace every deploy (the updatedAt route is impossible: not in stables, and the env diff plans update every deploy, so plan-time values are unresolved). No secret material in state. The app ordering edge is untouched. Cost is the pre-swap profile: one deployment replacement per service per deploy, until the pinned alchemy version includes Deployment.redeployOn — the swap is one edit at the marked seam. Part of alchemy-provider-adoption (TML-3155). Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 90 ++++++++++++------- .../05-prisma-cloud/alchemy-lowering.md | 44 +++++---- docs/guides/deploying.md | 12 +-- gotchas.md | 2 +- .../compute/__tests__/always-redeploy.test.ts | 54 +++++++++++ .../compute/__tests__/deployment-edge.test.ts | 58 ++++++++++-- .../lowering/src/compute/always-redeploy.ts | 66 ++++++++++++++ .../lowering/src/compute/artifact.ts | 12 +-- .../lowering/src/compute/deployment-edge.ts | 5 ++ .../lowering/src/exports/compute.ts | 1 + .../src/__tests__/control-lowering.test.ts | 6 +- .../target/src/descriptors/compute.ts | 12 ++- 12 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md index 74c458f1..2a6284a1 100644 --- a/.drive/projects/alchemy-provider-adoption/design-notes.md +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -102,29 +102,51 @@ implementation of this slice used `artifactPath` and had exactly that defect; it drives the real Output machinery and upstream's real diff rather than eager-collapse stubs. -What does NOT survive the swap is a side effect the old provider had: because -Composer's deleted `Deployment` created a brand-new deployment on every -reconcile, a changed environment *value* shipped a new deployment as well. With -upstream, an unchanged artifact plans an update, and its reconcile re-uses the -existing deployment — so a value-only change reaches the platform's variable -row but not the running deployment until the next artifact change. - -We chose not to reproduce it, because every available mechanism is worse than -the gap: - -- Upstream recreates a deployment only when its artifact fingerprint moves. - Feeding the environment into that fingerprint means hashing the desired - values into Alchemy state — which the lowering doc rejects outright ("a hash - of a secret is itself a leak"), and which upstream itself only does inside - `Compute`, where the fingerprint is stored `Redacted`. -- Making the fingerprint move without hashing values (a per-generation artifact - hard link, or a generation counter in the deployment's logical id) works only - through upstream's *path-string* comparison, which upstream could drop at any - time — the fix would disappear silently. - -The honest fix is upstream's: either a `Prisma.Deployment` prop that takes -inputs the deployment should be recreated for, or `Compute`'s env-in-fingerprint -made available to the low-level resource. Slice 3 is the place to offer it. +The swap initially lost a side effect the old provider had: because Composer's +deleted `Deployment` created a brand-new deployment on every reconcile, a +changed environment *value* shipped a new deployment as well. With upstream +handed a stable artifact path, an unchanged artifact planned an update, its +reconcile re-used the existing deployment, and a value-only change reached the +platform's variable row but not the running deployment until the next artifact +change. + +**That regression is now closed Composer-side** (`compute/always-redeploy.ts`): +the deploy hook hands upstream a fresh `artifactPath` every deploy run — the +canonical content-addressed artifact hard-linked into a per-run +`deploy-` directory, generation minted once per process. Upstream's +resolved path comparison then plans a replace on EVERY deploy, restoring the +old always-redeploy behaviour with no secret material (not even a hash) in +state. The mechanism was picked over the alternatives we ruled out earlier, +with their objections answered rather than ignored: + +- Feeding values into the artifact fingerprint stays rejected — "a hash of a + secret is itself a leak" still holds; the generation path never sees a value. +- Riding an `EnvironmentVariable` attribute through a Deployment replacement + prop cannot work: the row's diff plans an update on every deploy (it + re-applies values to heal drift) and `updatedAt` is not in the variable's + stables (`stables: ["environmentVariableId"]`), so a plan-time reference to + it is an unresolved expression — which collapses upstream's replacement + block to "no opinion" and reintroduces the silent artifact skip the + deployment-edge fix closed. (`updatedAt` also moves on every deploy, so even + resolved it would encode always-redeploy, not value-change detection.) +- The recorded objection to the hard-link route — upstream could drop its + path-string comparison and the fix would vanish silently — is now guarded: + `deployment-edge.test.ts`'s always-redeploy block drives upstream's REAL + diff and fails the moment a same-bytes/new-path deploy stops planning a + replace. + +The cost is deliberate and precise: every deploy replaces every service's +deployment (create → start → promote → delete old) where the swap's steady +state re-used it — the reuse upstream's fingerprinting was built to save is +given up. The ordering edge (`app`, `deployment-edge.ts`) is untouched; the +generation path is a plain string, so the replacement block stays resolved at +plan time. + +This is a stopgap with a named exit: upstream `Prisma.Deployment` gains +`redeployOn` (inputs a deployment must be recreated for; a companion upstream +commit is in flight). When the pinned alchemy version includes it, +`alwaysRedeployArtifactPath` is deleted and the environment rows go on +`redeployOn` — one edit, in `descriptors/compute.ts`. ## The poison DATABASE_URL rows are gone @@ -157,7 +179,8 @@ Residual, and it differs by stage: ## What the swap costs us, precisely -Two behaviours got worse, and neither is mitigated on our side. +One behaviour got worse and is not mitigated on our side; a second was worse +for a while and is now restored (see the PRO-211 section above). **App delete retry budget: 5 minutes → about 4 seconds.** Composer's deleted `ComputeService` provider retried the platform's "did not reach a delete-safe @@ -171,15 +194,18 @@ deployment still winding down can still 409 the App delete past that budget. A destroy of a stage that was serving traffic seconds earlier is the case to watch. -**Environment-value change no longer redeploys.** Covered above. +**Environment-value change redeploys again — by replacing every deployment on +every deploy.** The gap and its Composer-side fix, its cost, and the +`redeployOn` hand-off are covered above. ## Upstream asks (slice 3) -- **A `Prisma.Deployment` prop for "recreate when these inputs change."** - Without one, the environment cannot be part of what identifies a deployment, - and the change-propagation gap above stays. `Compute` already folds `env` - into its fingerprint and stores that fingerprint `Redacted`; the low-level - resource needs the same seam. +- **A `Prisma.Deployment` prop for "recreate when these inputs change" + (`redeployOn`; companion upstream commit in flight).** Until it ships, + Composer pays for change propagation by replacing every deployment on every + deploy (`always-redeploy.ts`). `Compute` already folds `env` into its + fingerprint and stores that fingerprint `Redacted`; the low-level resource + needs the same seam. - **Raise or make configurable the App delete-retry budget** (or drain the app's deployments before deleting it). - **Export `PrismaUploadClient` / open the `alchemy/Prisma/Internal/*` subpath.** @@ -204,7 +230,9 @@ value moves": distinguishes "same value re-applied" from "new value". The durable statement: **the only attribute that moves at all fires on every -deploy.** Any real fix must come from the deployment side. +deploy** — and it is not in the variable's stables, so it cannot even ride a +plan-time diff. Any real fix must come from the deployment side, which is +where the always-redeploy generation path (and eventually `redeployOn`) sits. ## Open questions diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 23c4e2ad..db9c9efb 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -170,9 +170,9 @@ How the pieces map: named** variable — the same `serialize` path as any other config value. - **The connection** lowers to two edges: the producer's endpoint domain flows into a named `EnvironmentVariable`, and that variable's **id flows into the - consumer's `Deployment`** through its `artifactPath` prop. + consumer's `Deployment`** through its `app` prop. - Every `EnvironmentVariable` a Deployment boots with is threaded into its - `artifactPath` — database URLs and connection URLs alike — so the deployment + `app` — database URLs and connection URLs alike — so the deployment depends on its config being written first. - The Deployment's `portMapping.http` rides the same seam: `serialize` resolves the service's `port` param from the typed Config and surfaces it in its @@ -187,24 +187,32 @@ The edge's job is **ordering**: the variable write completes before deployment-create, so the first deployment boots with a complete environment. Without it the two race — the failure documented as PRO-211 in `gotchas.md`. -**Why the edge rides `artifactPath`.** Upstream's `Prisma.Deployment` has no +**Why the edge rides `app`.** Upstream's `Prisma.Deployment` has no `environment` prop (Composer's deleted one did). Alchemy derives its dependency graph from the resource references a prop's *value* is built from, so the -descriptor builds `artifactPath` as an Output over every variable's id that -resolves to the path verbatim: the graph gains the edges, and upstream's -"replace when the artifact changed" diff sees exactly the path it would -otherwise have been handed. - -**Change propagation is a deferred follow-up, not wired.** A *value* change (a -rotated URL) reaches the platform's variable row but not the running -deployment: upstream recreates a deployment only when its artifact fingerprint -moves, and that fingerprint cannot include the values without persisting a hash -of them in state (a hash of a secret is itself a leak, and persisting the value -would put a credential in Alchemy state). The intended fix is upstream's — a -`Deployment` prop that names the inputs a deployment must be recreated for. It -is narrow in practice: promoted service endpoints are stable across producer -redeploys, so a wire's value rarely moves, and true secrets are platform-sourced -and rotate through the platform, not this edge (see the +descriptor builds `app` as an Output over the app id AND every variable's id, +resolving to the app id itself: the graph gains the edges. It cannot ride +`artifactPath` (or any of upstream's other replacement-block props): the diff +reads that block as one unit and gives no opinion the moment any member is +unresolved — and a brand-new variable's reference IS unresolved at plan time — +which would skip the artifact comparison and silently drop a code change. +`compute/deployment-edge.ts` records the full argument and its test drives +upstream's real diff. + +**Change propagation is wired by replacing the deployment on every deploy.** +The platform freezes a deployment's environment at create, so a *value* change +(a rotated URL) reaches a running service only through a new deployment — and +the fingerprint upstream recreates on cannot include the values without +persisting a hash of them in state (a hash of a secret is itself a leak). +Instead, the deploy hook hands upstream a fresh per-deploy-run artifact path — +the content-addressed artifact hard-linked into a per-run generation directory +(`compute/always-redeploy.ts`) — so upstream's path comparison plans a replace +on every deploy: same bytes, new path, no value and no hash in state. The +deliberate cost is that an unchanged service replaces (create, start, promote, +delete old) instead of nooping. This is a stopgap: when upstream's +`Prisma.Deployment` gains `redeployOn` (inputs a deployment must be recreated +for) and the pinned alchemy version includes it, the generation path is +removed and the environment rows ride `redeployOn` (see the [config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)). The framework's core constructs these edges when lowering a connection (the diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 8ae3fa79..61ae996a 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -265,11 +265,13 @@ existing resources in place — deploy state is migrated automatically on read, and production environments redeploy with no changes to their databases or connections. -**Every service ships one fresh deployment on the first deploy after the -upgrade**, with the artifact it is already running: the new provider identifies -a deployment by a fingerprint the old state rows cannot reproduce, so it ships -and promotes once, then goes quiet. Subsequent deploys are unchanged: a service -whose code has not changed is not redeployed. +**Every deploy ships a fresh deployment of every service**, changed code or +not — the same behaviour earlier framework versions had. It is what guarantees +a configuration change always reaches your running services: the platform +freezes a deployment's environment when the deployment is created, so a +changed value only takes effect through a new one. The deploy uploads the +artifact it already has, starts it, moves the stable endpoint over, and +removes the old deployment; your service's URL does not change. **`DATABASE_URL` and `DATABASE_URL_POOLED` are no longer overwritten.** The framework used to replace those variables' values with the placeholder `"-"`, diff --git a/gotchas.md b/gotchas.md index 909d2d6c..e4c5f886 100644 --- a/gotchas.md +++ b/gotchas.md @@ -202,7 +202,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **Cause (corrected after reading pdp-control-plane source).** Env vars are `ConfigVariable` rows **materialized into a version at version-create time** (`materializeBranchEnvVars` resolves the branch's map and hands it to Foundry with the version) and frozen there — version start does not re-resolve, and updating a variable touches only the row, never an existing version. So the race is the env-var POST vs the consumer's **version-create** call, issued by one apply with no dependency edge between them. Consequences: (1) a version created before the row exists never sees it, regardless of VM recycles; (2) config changes take effect only via a new version — there is no restart-on-config-change. _The original filing (and this entry's first version) claimed boot-time application and recycle-healing; the source model contradicts that. Our one observed recycle-heal is treated as a platform bug, not behavior to rely on._ -**Workaround.** Give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). In Prisma Composer the consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). That is the **ordering** half only: a deployment is recreated when its artifact changes, so a changed variable *value* reaches the row but not the running version until the next artifact change — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. +**Workaround.** Two halves, both handled in Prisma Composer. **Ordering:** give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). The consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). **Propagation:** since a version's environment is frozen at create, a changed variable *value* reaches a running service only via a new version — so every deploy ships one: the deploy hook hands the platform a fresh per-deploy-run artifact path (`compute/always-redeploy.ts`), replacing each service's deployment on every deploy, unchanged code included. That cost stands until alchemy's `Prisma.Deployment` grows `redeployOn` — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. **Reproduction.** diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts new file mode 100644 index 00000000..d77fd299 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts @@ -0,0 +1,54 @@ +/** + * The per-deploy artifact generation: same bytes, a fresh path per deploy run + * — what makes upstream's `Prisma.Deployment` replace on every deploy so a + * changed environment value always reaches the running app. Which plan action + * the fresh path produces is proven against upstream's real diff in + * `deployment-edge.test.ts`; this file pins the path mechanics. + */ + +import { afterAll, 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 { alwaysRedeployArtifactPath } from '../always-redeploy.ts'; + +const digestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'always-redeploy-')); +const canonicalPath = path.join(digestDir, 'auth.tar.gz'); +fs.writeFileSync(canonicalPath, 'artifact-bytes'); + +afterAll(() => { + fs.rmSync(digestDir, { recursive: true, force: true }); +}); + +describe('alwaysRedeployArtifactPath', () => { + test('two deploy runs (two generations) get two distinct paths to the same bytes', () => { + const first = alwaysRedeployArtifactPath(canonicalPath, 'run-1'); + const second = alwaysRedeployArtifactPath(canonicalPath, 'run-2'); + + expect(first).not.toBe(second); + expect(first).not.toBe(canonicalPath); + expect(fs.readFileSync(first, 'utf8')).toBe('artifact-bytes'); + expect(fs.readFileSync(second, 'utf8')).toBe('artifact-bytes'); + }); + + test('within one run the path is stable — the dev watch loop converges without churn', () => { + expect(alwaysRedeployArtifactPath(canonicalPath, 'run-1')).toBe( + alwaysRedeployArtifactPath(canonicalPath, 'run-1'), + ); + // The default generation is minted once per process: same property. + expect(alwaysRedeployArtifactPath(canonicalPath)).toBe( + alwaysRedeployArtifactPath(canonicalPath), + ); + }); + + test('the generation lives beside the canonical artifact, keyed by the run', () => { + const generationPath = alwaysRedeployArtifactPath(canonicalPath, 'run-3'); + expect(path.dirname(path.dirname(generationPath))).toBe(digestDir); + expect(path.basename(path.dirname(generationPath))).toBe('deploy-run-3'); + expect(path.basename(generationPath)).toBe('auth.tar.gz'); + }); + + test("the destroy-run placeholder ('' — no build) passes through untouched", () => { + expect(alwaysRedeployArtifactPath('', 'run-1')).toBe(''); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts index 79169c9f..655572c6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts @@ -34,6 +34,7 @@ import { sha256, sha256Object } from 'alchemy/Util/sha256'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; +import { alwaysRedeployArtifactPath } from '../always-redeploy.ts'; import { appAfterEnvironment } from '../deployment-edge.ts'; /** A stack the resource constructors register into; nothing ever applies it. */ @@ -78,9 +79,9 @@ afterAll(() => { }); /** The deploy hook's props, built by the same helper the descriptor uses. */ -const deploymentProps = () => ({ +const deploymentProps = (propArtifactPath: string = artifactPath) => ({ app: appAfterEnvironment(app.appId, environment), - artifactPath, + artifactPath: propArtifactPath, artifactContentType: 'application/gzip', portMapping: { http: 8080 }, start: true, @@ -144,7 +145,10 @@ const persistedOutput = (artifactHash: string) => ({ * resolve to), every other prop is a value. `olds` are the previous deploy's * persisted props, where `app` had resolved to the app id. */ -const diffAgainst = async (output: Record) => { +const diffAgainst = async ( + output: Record, + paths: { oldPath?: string; newPath?: string } = {}, +) => { const service = await deploymentService(); if (service.diff === undefined) throw new Error('provider must expose diff'); return Effect.runPromise( @@ -153,8 +157,8 @@ const diffAgainst = async (output: Record) => { id: 'auth-deploy', fqn: 'auth-deploy', instanceId: 'inst-deploy', - olds: { ...deploymentProps(), app: 'app-1' }, - news: deploymentProps(), + olds: { ...deploymentProps(paths.oldPath), app: 'app-1' }, + news: deploymentProps(paths.newPath), output, session: undefined, bindings: [], @@ -193,10 +197,48 @@ describe('upstream Deployment.diff while the new variable is still unresolved', expect(diff).toEqual({ action: 'replace' }); }); - test('an unchanged artifact plans no replacement', async () => { + test('an identical artifactPath plans no replacement — the reuse always-redeploy opts out of', async () => { const diff = await diffAgainst(persistedOutput(await artifactFingerprint())); - // An update, not a replace: with start/promote asserted on every deploy, - // upstream re-asserts the lifecycle of the deployment it already has. + // An update, not a replace: with the SAME path and bytes, upstream reuses + // the deployment and only re-asserts start/promote. The platform never + // re-reads environment rows into a reused deployment, so this is exactly + // the plan Composer must never let a real deploy reach — + // `alwaysRedeployArtifactPath` hands upstream a fresh path every run. expect(diff).toEqual({ action: 'update' }); }); }); + +describe('always-redeploy: the per-deploy generation path, against upstream diff', () => { + test('same bytes, a new deploy run — replace is planned, so env values reach the app', async () => { + const previousRun = alwaysRedeployArtifactPath(artifactPath, 'run-1'); + const thisRun = alwaysRedeployArtifactPath(artifactPath, 'run-2'); + // The fingerprint has NOT moved — the path alone carries the replace. + // This is the pinned always-redeploy choice, not an accident: an + // environment-value-only change (invisible to every Deployment prop, the + // platform never returns values) and a no-change redeploy are the same + // deploy to upstream, and BOTH must ship a fresh deployment because the + // platform materializes env rows only at deployment create (PRO-211). + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: previousRun, + newPath: thisRun, + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('a changed artifact on a new deploy run still plans a replace', async () => { + const diff = await diffAgainst(persistedOutput('the-previous-generations-fingerprint'), { + oldPath: alwaysRedeployArtifactPath(artifactPath, 'run-1'), + newPath: alwaysRedeployArtifactPath(artifactPath, 'run-2'), + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('the generation path stays a plain value — the diff never degrades to update', () => { + // The replacement block {portMapping, skipCodeUpload, artifactPath, + // artifactContentType} must be RESOLVED at plan time or upstream returns + // no opinion and the engine falls back to a plain update — the silent + // artifact skip all over again. The generation path is a string computed + // before lowering, never an Output. + expect(Output.isOutput(alwaysRedeployArtifactPath(artifactPath, 'run-3'))).toBe(false); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts new file mode 100644 index 00000000..d20a9a5c --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts @@ -0,0 +1,66 @@ +/** + * Every deploy ships a fresh deployment — the guarantee that a changed + * environment value always reaches the running app. + * + * The platform materializes environment rows into a deployment at create time + * and never re-reads them (gotchas.md, PRO-211), so a value change reaches the + * running app only through a NEW deployment. Upstream's `Prisma.Deployment` + * recreates only when a prop in its replacement block moves, and nothing an + * `EnvironmentVariable` exposes can ride that block: values are write-only, + * and the one attribute that moves at all (`updatedAt`) is not in the + * variable's stables — with the row planned as an update every deploy (it + * re-applies values to heal drift), a reference to it is an unresolved + * expression at plan time, which collapses upstream's diff to a plain update + * and reintroduces the silent artifact skip `appAfterEnvironment` closed. + * + * So the deployment is made to replace on EVERY deploy instead, by handing + * upstream a fresh `artifactPath` each deploy run: the canonical + * content-addressed artifact is hard-linked into a `deploy-` + * directory beside it, where the generation is minted once per process — one + * deploy run is one process, so every run's path differs from the last and + * upstream's resolved path comparison plans a replace. Same bytes, new path, + * no secret material anywhere near state. The cost is deliberate: the reuse + * upstream's fingerprinting was built to save is given up, and an unchanged + * service replaces (create, start, promote, delete old) rather than noops. + * + * The dev watch loop is untouched in practice: one session is one process, so + * the path is stable across converges, and the local Deployment provider + * reconciles unconditionally anyway. + * + * THE SEAM: upstream `Prisma.Deployment` gains `redeployOn` (inputs a + * deployment must be recreated for); when the pinned alchemy version includes + * it, replace this — pass the canonical `artifact.path` verbatim again and + * put the environment rows on `redeployOn`. The one call site is + * `descriptors/compute.ts`'s deploy hook. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Minted once per process: a deploy run is one process. */ +const DEPLOY_GENERATION = `${String(Date.now())}-${crypto.randomUUID().slice(0, 8)}`; + +/** + * Hard-links `artifactPath` into a sibling `deploy-` directory and + * returns the link's path — same bytes, a path no previous deploy run used. + * The empty path (`packageComputeArtifact`'s destroy-run placeholder) passes + * through untouched. `generation` is overridable for tests only. + */ +export function alwaysRedeployArtifactPath( + artifactPath: string, + generation: string = DEPLOY_GENERATION, +): string { + if (artifactPath === '') return artifactPath; + const generationDir = path.join(path.dirname(artifactPath), `deploy-${generation}`); + fs.mkdirSync(generationDir, { recursive: true }); + const generationPath = path.join(generationDir, path.basename(artifactPath)); + if (!fs.existsSync(generationPath)) { + try { + fs.linkSync(artifactPath, generationPath); + } catch { + // A filesystem without hard links still gets the fresh path. + fs.copyFileSync(artifactPath, generationPath); + } + } + return generationPath; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index de79c3ee..6c0be4d1 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -180,12 +180,12 @@ export function packageComputeArtifact(opts: PackageComputeArtifactOptions): Com const sha256 = crypto.createHash('sha256').update(gz).digest('hex'); // The output path must be content-addressed AND per-user. Content-addressed - // because `artifactPath` is a Deployment prop: a path that varies per call - // (e.g. mkdtemp) makes every redeploy diff as an update even when the bytes - // are identical, breaking the redeploy-noop guarantee. Per-user because a - // fixed shared dir under os.tmpdir() is owned by whichever OS user creates - // it first — everyone else's writes fail EACCES. Same content → same path - // (noop); new build → new hash → new path (update, as designed). uid is -1 + // so the local dev loop can memoize on it (a converge re-hashes and + // re-extracts nothing when the bytes didn't move) — this is the CANONICAL + // path; the deploy hook derives the per-deploy-run path the hosted + // Deployment is handed from it (`alwaysRedeployArtifactPath`). Per-user + // because a fixed shared dir under os.tmpdir() is owned by whichever OS + // user creates it first — everyone else's writes fail EACCES. uid is -1 // on Windows — still a valid, deterministic directory name. const outDir = path.join( os.tmpdir(), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts index edef20a3..a2149fca 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -25,6 +25,11 @@ * the code change would be silently dropped, and every later deploy would * agree it had already shipped. `app` sits outside that block, and its own * check treats an unresolved app as "unchanged" rather than as a change. + * + * This edge is the ORDERING half only. Getting a changed environment value + * into the running app is the other half, and it is not this edge's job: + * `alwaysRedeployArtifactPath` makes every deploy replace the deployment, so + * the fresh deployment materializes the rows this edge ordered first. */ import * as Output from 'alchemy/Output'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 919f806e..95d7b5ca 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts @@ -1,3 +1,4 @@ +export * from '../compute/always-redeploy.ts'; export * from '../compute/artifact.ts'; export * from '../compute/deployment-edge.ts'; export * from '../compute/ServiceKey.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 0f00a1e6..5b75d154 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 @@ -162,6 +162,10 @@ mock.module('@internal/lowering', () => ({ recorded.pkg.push([opts]); return { path: `/tmp/${opts.id}.tar.gz`, sha256: `sha-${opts.id}` }; }, + // The real one hard-links the artifact on disk; a marked pass-through keeps + // the data flow pure while letting deploy assertions pin that the hook + // routes the path through the per-deploy-generation seam. + alwaysRedeployArtifactPath: (artifactPath: string) => `${artifactPath}#per-deploy-generation`, })); // PgWarm is a real Alchemy Resource (needs the Stack service); stub it so the @@ -1178,7 +1182,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { 'auth-deploy', { app: 'auth-svc#cloud-id', - artifactPath: '/tmp/auth.tar.gz', + artifactPath: '/tmp/auth.tar.gz#per-deploy-generation', artifactContentType: 'application/gzip', portMapping: { http: 8080 }, start: true, 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 1d463244..e311183f 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 @@ -2,7 +2,11 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; -import { appAfterEnvironment, packageComputeArtifact } from '@internal/lowering'; +import { + alwaysRedeployArtifactPath, + appAfterEnvironment, + packageComputeArtifact, +} from '@internal/lowering'; import * as Output from 'alchemy/Output'; import * as Prisma from 'alchemy/Prisma'; import * as Effect from 'effect/Effect'; @@ -266,7 +270,11 @@ export function computeDescriptor( // well as the app id — see `appAfterEnvironment` for why it is the // only prop that can (PRO-211). app: appAfterEnvironment(provisioned.serviceId, serialized.environment), - artifactPath: artifact.path, + // A fresh per-deploy-run path for the SAME bytes, so upstream plans + // a replace on every deploy and a changed environment value always + // reaches the running app — see `alwaysRedeployArtifactPath` for the + // mechanism, its cost, and the hand-off to upstream's `redeployOn`. + artifactPath: alwaysRedeployArtifactPath(artifact.path), // The artifact IS a gzipped tar (see @internal/lowering's packager); // upstream sends this as the upload's Content-Type and folds it into // the fingerprint that decides whether a new deployment is needed. From f781a008190b29b46da2caa5388f98a780de9ecf Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 21:36:07 +0200 Subject: [PATCH 12/22] docs(drive): Composer PR body Signed-off-by: willbot Signed-off-by: Will Madden --- .../assets/composer-pr-body.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .drive/projects/alchemy-provider-adoption/assets/composer-pr-body.md diff --git a/.drive/projects/alchemy-provider-adoption/assets/composer-pr-body.md b/.drive/projects/alchemy-provider-adoption/assets/composer-pr-body.md new file mode 100644 index 00000000..9b10da8b --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/assets/composer-pr-body.md @@ -0,0 +1,44 @@ +Composer's six hand-written Alchemy resources for Prisma Cloud are gone. Deploys and local dev now run on the official `alchemy/Prisma` provider (alchemy 2.0.0-beta.67): + +```ts +// lowering/src/providers.ts — the whole live wiring is now composition, not implementation +Layer.mergeAll( + Prisma.ProjectProvider(), + Prisma.DatabaseProvider(), + Prisma.ConnectionProvider(), + Prisma.AppProvider(), + Prisma.DeploymentProvider(), + Prisma.EnvironmentVariableProvider(), + // still ours until upstream PR alchemy-run/alchemy#1061 releases: + BucketProvider(), BucketKeyProvider(), +) +``` + +Why: upstream tracks the Management API so we don't, and its deploy lifecycle is better than ours was — cleanup of failed deployments, terminal-status fast-fail, post-promote endpoint observation. Decision record: ADR-0043. + +## What changed + +- **Foundation** — alchemy beta.59 → beta.67 (plus the forced effect beta.100 train). Our provider collection tag and remaining resource type-ids renamed to `PrismaComposer.*`; the old ids are aliases so existing state rows resolve. +- **Postgres family** — upstream `Project`/`Database`/`Connection` classes, driven by our own auth layer (`PrismaEnvironment` from `PRISMA_SERVICE_TOKEN`, no interactive profile store; one base-URL resolver shared with our SDK client). Branch stages create their database attached with a generated physical name (upstream correctly refuses explicit-name-plus-branch; verified against PDP source). `directConnectionString` is bound explicitly — upstream's `databaseUrl` is pooled-first. +- **Compute family** — upstream's low-level `App`/`Deployment`/`EnvironmentVariable`, not composite `Compute`: the `COMPOSER_*_ORIGIN` self-edge needs the App to exist before env rows, and `Deployment` has no build path at all (ADR-0005 by structure). The env→deployment ordering edge rides the deployment's `app` prop as an Output (`deployment-edge.ts`) — riding `artifactPath` would silently skip code deploys when a new env row lands in the same deploy (proven with tests against alchemy's real Output machinery, and re-proven live). +- **Env changes always ship** (`always-redeploy.ts`) — the deploy hook hard-links the artifact into a per-deploy-generation path so every deploy replaces the deployment, restoring the pre-existing guarantee that a rotated value reaches the running app. Cost: one deployment replacement per service per deploy, same as before this PR's base. Removed at a marked seam when upstream's `Deployment.redeployOn` (in #1061) releases. +- **Legacy state migrates on read** (`state/legacy-resources.ts`) — old type-ids and attribute shapes rewrite in the hosted store; the retired poison `DATABASE_URL` rows are reported `retained` (state row dropped, platform variable untouched). The platform's seeded `DATABASE_URL` is no longer overwritten; the authoring-side name ban remains. +- **Local dev unchanged in shape** — the local target binds upstream's resource classes to our emulators at the same seam (ADR-0041). + +## Verified + +- Full suites green at every commit (build 36/36, typecheck 74/74, tests 62/63 with the one known dev-emulators flake; cast delta −8). +- Deployed smoke against real Prisma Cloud: fresh deploy + 2/2 smoke, idempotent redeploy (zero churn), the code-plus-new-var scenario that motivated the edge design (deployment replaced, new code live), clean destroy. +- A genuinely legacy stage (deployed from this PR's merge-base, redeployed from this branch): adopted in place — same database id, app ids, URLs — one-time deployment reship, then steady state byte-identical to a fresh stack. Clean destroy. + +## Operator notes (also in docs/guides/deploying.md) + +- First deploy after upgrading replaces each service's deployment once. +- Branch-stage databases migrate to generated physical names; the database's *default* connection credentials rotate once (the app's own connection is unaffected). +- Migrated stages keep the legacy `"-"` placeholder rows on the platform until manually removed; the guide has the exact calls. + +## Follow-up (tracked in TML-3156) + +When upstream PR alchemy-run/alchemy#1061 merges and releases: bump alchemy, delete our bucket resources, drop the alchemy pnpm patch, and swap `always-redeploy.ts` for `Deployment.redeployOn`. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) From d54e2b23482e416dfbc61f70d28213a8452526f9 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 21:47:15 +0200 Subject: [PATCH 13/22] =?UTF-8?q?docs(design):=20rewrite=20ADR-0043=20?= =?UTF-8?q?=E2=80=94=20grounding=20example,=20durable=20statements=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounding code first, decision led, alternatives last. Removed transient project/process references and corrected two statements the implementation had already overtaken (env changes now always ship via per-generation artifact paths; upstream dev mode is dual registration, not an option we pass). Signed-off-by: willbot Signed-off-by: Will Madden --- ...come-from-the-upstream-alchemy-provider.md | 259 ++++++++++++------ docs/design/90-decisions/README.md | 2 +- 2 files changed, 178 insertions(+), 83 deletions(-) diff --git a/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md index 3485d235..719042c2 100644 --- a/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md +++ b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -3,94 +3,189 @@ ## Decision Composer does not implement Alchemy resources for Prisma Cloud's Management -API. The six resource families that talk to it — project, database, -connection, app, deployment, environment variable — are the upstream -`alchemy/Prisma` provider's classes (`Prisma.Project`, `Prisma.Database`, -`Prisma.Connection`, `Prisma.App`, `Prisma.Deployment`, -`Prisma.EnvironmentVariable`), registered in Composer's own provider -collection and driven by Composer's lowering. - -Composer keeps defining resources only where no Management API exists behind -them: mint-once values (`ServiceKey`, `GeneratedParam`, `S3Credentials`), -`PnMigration`, `PgWarm`, and — until the upstream contribution ships in a -release — `Bucket`/`BucketKey`. The hosted state store keeps its policy layer -(state lives in a framework-owned database on the stage's Branch, ADR-0034); -its generic Postgres core is contributed upstream. - -The compute family binds the **low-level** trio, not the composite -`Prisma.Compute`: - -- A service's `COMPOSER_
_ORIGIN` row carries that service's own - platform-assigned domain (ADR-0039). One composite resource owning app, - environment rows, and deployment makes that row an input of the resource - that produces the domain — a self-edge the planner rejects. Splitting lets - the App surface `appEndpointDomain` in `provision`, before any row is - written. -- `Compute` owns environment rows through an internal ownership map with no - honest migration from per-key resource rows. -- `Compute` carries build, framework detection, and bundling. `artifactPath` - bypasses them, but the bypass is a prop value; `Prisma.Deployment` has no - build path at all, which is the structural form of ADR-0005's guarantee. - -The env→deployment ordering edge rides the deployment's `app` prop as an -Output over every environment row's id (`deployment-edge.ts`). It must not -ride `artifactPath`: upstream's diff requires that prop to be *resolved* -before comparing artifacts, and a brand-new environment row is unresolved at -plan time — the diff then degrades to a generic update that reuses the running -deployment while recording the new artifact's fingerprint, permanently and -silently skipping a code deploy. - -Local dev is unchanged in shape (ADR-0041): the local target rebinds the same -upstream resource classes to Composer's emulator providers at the -`LowerOptions.providers` seam. Upstream's own dev mode (`providers({ dev })`, -which Composer contributed) is not used, because Composer swaps the whole -layer rather than one provider's dev half. +API. It composes the official `alchemy/Prisma` provider's resources and +providers, and defines its own resources only where no Management API exists +behind them. + +The live wiring is composition, not implementation: + +```ts +// lowering/src/providers.ts — deploys run on upstream's providers +Layer.mergeAll( + Prisma.ProjectProvider(), + Prisma.DatabaseProvider(), + Prisma.ConnectionProvider(), + Prisma.AppProvider(), + Prisma.DeploymentProvider(), + Prisma.EnvironmentVariableProvider(), +), +// + Composer's own resources: Bucket, BucketKey, ServiceKey, +// GeneratedParam, S3Credentials, PnMigration, PgWarm +``` + +and a lowered compute service is upstream resources wired by Composer's +descriptors: + +```ts +const app = Prisma.App(`${id}-svc`, { project, regionId, branchId }); +const vars = records.map((r) => Prisma.EnvironmentVariable(...)); +const deployment = Prisma.Deployment(`${id}-deploy`, { + app: dependsOnEnvironment(app, vars), // see "The ordering edge" below + artifactPath, // Composer's own tar.gz — never built by Alchemy + start: true, + promote: true, +}); +``` + +Local dev keeps the shape ADR-0041 defined: the local target binds the same +upstream resource *classes* to Composer's emulator providers at the +`LowerOptions.providers` seam. Upstream's built-in dev mode (its providers +register live and local variants and the engine picks by run mode) is never +mounted; Composer swaps the whole layer. + +Why hand Composer's most platform-critical surface to an external package: +the provider tracks the Management API at its source, and its deploy +lifecycle is stronger than what it replaced — failed deployments are cleaned +up rather than leaked, terminal statuses fail fast instead of polling to +timeout, and the stable endpoint is read by observing the App after promote +rather than trusting the promote response. + +## The compute family binds the low-level trio, not `Prisma.Compute` + +Upstream offers two shapes for compute: a composite `Prisma.Compute` that +owns app, environment, and deployment in one resource, and the low-level +`App` / `Deployment` / `EnvironmentVariable`. Composer uses the low-level +trio. The deciding constraint is a cycle: + +Every service's environment includes `COMPOSER_
_ORIGIN` — the +service's *own* platform-assigned endpoint domain (ADR-0039). A composite +resource that owns both the environment rows and the app makes that row an +input of the very resource that produces the domain: a self-edge the planner +rejects. Split, the wiring is legal: the App exists first and hands out +`appEndpointDomain`, environment rows are written from it, the Deployment +comes last. + +Two supporting reasons: + +- `Compute` owns environment rows through an internal ownership map and + refuses in-scope rows absent from it; Composer's per-key rows have no + honest mapping into that map. +- `Compute` carries build, framework detection, and bundling. Its + `artifactPath` prop bypasses them, but a bypass is a prop value; + `Prisma.Deployment` has **no build path at all**, which is ADR-0005's + guarantee in structural form. + +What the trio costs: `Compute`'s preview/stable health checks and automatic +rollback are not inherited, and deployment reuse must be handled by Composer +(next two sections). + +## The ordering edge rides the `app` prop + +Environment rows must be written before the deployment is created, because +the platform snapshots the branch environment into a deployment at create. +Upstream's `Deployment` has no prop for that dependency, so Composer builds +the edge into the `app` prop: an Output over the app id *and* every +environment row's id, resolving to the app id +(`lowering/src/compute/deployment-edge.ts`). Alchemy derives its graph from +the resource references inside prop values, so every row is scheduled first. + +The edge must not ride `artifactPath`. Upstream's diff reads +`{portMapping, skipCodeUpload, artifactPath, artifactContentType}` as one +block and offers no opinion when any member is unresolved — and a brand-new +environment row is always unresolved at plan time. The consequence of +getting this wrong is severe and quiet: the artifact comparison never runs, +the engine falls back to a plain update, and the reconcile keeps the running +deployment while recording the new artifact's fingerprint as deployed — a +code change silently never ships, and every later deploy agrees it already +did. The `app` prop sits outside that block and tolerates being unresolved. +`compute/__tests__/deployment-edge.test.ts` drives upstream's real diff and +real Output machinery and fails if the edge ever moves back. + +## Every deploy replaces the deployment + +The platform bakes environment values into a deployment at create, and +upstream reuses a deployment whose artifact is unchanged — so a value-only +change (a rotated secret) would update the platform's variable row and never +reach the running app. Composer instead guarantees that a deploy ships what +was declared: the deploy hook hard-links the content-addressed artifact into +a per-deploy-generation path (`compute/always-redeploy.ts`), the resolved +path differs every run, and upstream plans a replace — create, start, +promote, delete the old — for every service on every deploy. + +No value or hash of a value lands in deploy state. The alternatives are +worse: hashing values into state leaks (a hash of a secret is itself a +leak), and no upstream environment-variable attribute distinguishes "the +value changed" from "a deploy ran" (values are re-applied every deploy to +heal drift). + +The cost is deliberate: the deployment reuse upstream's fingerprinting +provides is given up. The exit is named in the code: when the pinned alchemy +version includes `Deployment.redeployOn` (inputs a deployment is recreated +for, fingerprinted `Redacted` upstream), the generation path is deleted and +the environment rows move onto that prop — one edit in +`descriptors/compute.ts`. ## Consequences -- Composer's collection tag is `'PrismaComposer'` and its remaining resource - type-ids are `PrismaComposer.*` — the `Prisma.*` namespace belongs to - upstream. Rows written before the adoption migrate on read in the hosted - store (`state/legacy-resources.ts`): type-ids, attribute shapes, and the - neutralization of the retired poison rows. -- Branch-stage databases carry generated physical names. Upstream refuses an - explicit name combined with branch attachment in create (correct: PDP - creates and attaches in separate transactions with no idempotency key), and - attach-after-create is reverted by upstream's reconcile. Production keeps - explicit names; branch stages attach at create and take the generated name. -- The platform's seeded `DATABASE_URL`/`DATABASE_URL_POOLED` are no longer - overwritten. They are system-managed and upstream refuses to manage them. - The authoring-side ban remains: `param.ts`/`secret.ts` reject the names, and - every Composer row is `COMPOSER_`-prefixed. An app reading - `process.env.DATABASE_URL` outside the framework sees the platform's value. -- Auth never touches Alchemy's profile store: Composer provides - `PrismaEnvironment` directly from `PRISMA_SERVICE_TOKEN`, with one base-URL - resolver shared by the upstream providers and Composer's own SDK client. -- Two regressions are accepted and filed as upstream asks: an environment - value change alone no longer replaces the running deployment (the only - non-secret change signal upstream exposes, `updatedAt`, moves on every - deploy, so no mechanism at this layer can express "the value changed"), and - the App delete conflict-retry budget is ~3.75 s where Composer's old - provider waited up to 5 minutes. +- **Namespaces.** The `Prisma.*` resource type-id namespace and the + `'Prisma'` collection tag belong to upstream. Composer's collection tag is + `'PrismaComposer'` and its own resources are `PrismaComposer.*`. Rows + persisted under retired Composer type-ids are rewritten on read by the + hosted state store (`state/legacy-resources.ts`): ids, attribute shapes, + and the retirement of the poison rows below. The module is the durable + compatibility boundary for state written by earlier Composer versions. +- **Branch-stage databases carry generated physical names.** Upstream + refuses an explicit name combined with branch attachment at create — and + it is right to: the Management API creates the database and attaches the + branch in separate transactions with no idempotency key, so a lost + response is indistinguishable from a foreign database. Attaching after + create does not survive either: upstream's reconcile detaches a branch its + props don't declare. So branch stages attach at create and take the + generated name; production keeps explicit names. +- **The platform's `DATABASE_URL` is left alone.** Prisma Cloud seeds + `DATABASE_URL`/`DATABASE_URL_POOLED` on every app and marks them + system-managed; upstream refuses to manage system-managed variables. + Composer neither overwrites nor tracks them. The guarantee that apps read + configuration through the framework is held at the authoring end instead: + `param.ts`/`secret.ts` reject the reserved names, and every + Composer-written row is `COMPOSER_`-prefixed. An app that reads + `process.env.DATABASE_URL` directly sees whatever the platform put there. +- **Auth never touches Alchemy's profile store.** Alchemy's own credential + flow prompts on a TTY and hard-fails non-interactive; Composer runs + alchemy as a subprocess with piped stdio. Composer provides + `PrismaEnvironment` directly from `PRISMA_SERVICE_TOKEN`, with one + base-URL resolver shared between upstream's providers and Composer's own + SDK client so both always target the same host. +- **A known weakness is inherited:** upstream retries a conflicting App + delete for only a few seconds, where Composer's own resource waited out + deployment drain for minutes. Slow drains can fail a destroy and need a + re-run. ## Alternatives considered -- **Keep Composer's own resources.** Rejected: six API wrappers whose drift - against the Management API Composer pays for alone, with a less hardened - deploy lifecycle than upstream's (no failure cleanup, no terminal-status - fast-fail, promote-response trust instead of post-promote observation). -- **Composite `Prisma.Compute`.** Rejected for the self-edge, the ownership - map, and ADR-0005 (above). -- **Vendor `src/Prisma/` into Composer.** Works mechanically, inherits - `@prisma/dev` and permanent drift. Kept only as a fallback if a future - alchemy bump is unshippable. -- **Upstream's dev mode instead of the local target.** Would tie local dev's - iteration to upstream review cadence and replace a whole-layer seam that - already works with a per-provider one. +- **Keep Composer's own resources.** Six Management API wrappers whose drift + Composer pays for alone, with a weaker deploy lifecycle than upstream's. +- **The composite `Prisma.Compute`.** Rejected for the self-edge, the + environment-ownership map, and ADR-0005 (above). +- **Vendor the provider's source into Composer.** Mechanically possible; + inherits its dependencies and permanent drift. Kept only as a fallback if + a future alchemy upgrade proves unshippable. +- **Adopt upstream's built-in dev mode instead of the local target.** Would + replace a whole-layer seam that already works with per-provider + substitution, and tie local-dev iteration to an external release cadence. +- **Detect environment changes instead of always redeploying.** Requires + either value hashes in state (a leak) or a change signal upstream does not + expose; both rejected above. ## References -Upstream provider: alchemy-run/alchemy PR #416; Composer's contribution -(buckets, `providers({dev})`/`liveProviders()`, Postgres state backend): -PR #1061. Supersedes the resource inventory in ADR-0033's reading of -`alchemy-lowering.md`; ADR-0005, ADR-0034, ADR-0039, ADR-0041 unchanged. +- ADR-0005 (the framework never builds or bundles user code), ADR-0034 + (hosted deploy state), ADR-0039 (a service's origin is a target-resolved + property), ADR-0041 (local dev runs the deploy pipeline against local + providers). +- The provider: the `alchemy/Prisma` module of the `alchemy` package, + 2.0.0-beta.67 or later. +- `docs/design/05-prisma-cloud/alchemy-lowering.md` — the current + resource-by-resource lowering map. +- `docs/guides/deploying.md` — operator-facing upgrade notes (one-time + deployment reship, branch-database renames, leftover placeholder rows). diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index d95248f1..5634def3 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -64,4 +64,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0040](ADR-0040-the-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor. - [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. -- [ADR-0043](ADR-0043-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 `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where 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 edge rides the deployment's `app` prop, legacy state rows migrate on read, branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. Two accepted regressions filed as upstream asks: env value changes alone don't replace the running deployment, and the App delete-retry budget shrinks. +- [ADR-0043](ADR-0043-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 `alchemy/Prisma` provider's classes, registered in Composer's `PrismaComposer` collection; Composer defines resources only where 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 every deploy replaces the deployment so environment changes always reach the running app (no value hashes in state). Legacy state rows migrate on read, branch-stage databases take generated physical names, and the platform's seeded `DATABASE_URL` is left system-managed. From e69ce48cbca730ca32ed74288f548c800c28c3d7 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:06:06 +0200 Subject: [PATCH 14/22] docs(design): DATABASE_URL is self-healed on first deploy, not absent createDatabase: false removes the default database but not the variable: the platform heals a missing DATABASE_URL template on the first Compute deploy from any ready database on the Project (verified in the control plane: healDefaultDatabaseUrl via materializeDeployEnv). The authoring-side name ban is the line that holds. Signed-off-by: willbot Signed-off-by: Will Madden --- .../05-prisma-cloud/alchemy-lowering.md | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index cda344c9..fed89340 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -49,13 +49,15 @@ never the container itself (see The platform writes `DATABASE_URL` / `DATABASE_URL_POOLED` templates pointing at a project's default database — a convenience for hand-provisioned single services, and precisely the kind of **implicit ambient config the framework -exists to eliminate**. The framework never reads it, never depends on it, and -narrows how it can exist at all. Framework-provisioned Projects are created -with `createDatabase: false`, so **no default database exists** on them (the -opt-out is workspace-actor-only — fine, deploys authenticate with service -tokens). Every database URL a service consumes is an explicit, per-service -variable the pack's `serialize` writes under its own named key, inside the -`COMPOSER_` namespace. +exists to eliminate**. The framework never reads it and never depends on it. +Framework-provisioned Projects are created with `createDatabase: false`, so no +default database exists on them — but that does not keep the variable away: +the platform self-heals a missing `DATABASE_URL` template on the first Compute +deploy, wiring it from any ready database on the Project (default first, then +oldest). On a Composer Project that is one of the app's own databases. Every +database URL a service actually consumes is an explicit, per-service variable +the pack's `serialize` writes under its own named key, inside the `COMPOSER_` +namespace. On a Project that predates the opt-out, or one adopted by name, the platform's seeded variables may exist. The framework leaves them alone: the platform marks @@ -65,11 +67,12 @@ the authoring end instead: `param.ts` and `secret.ts` reject both names, so no Composer-written row can carry one. A service that reads `process.env.DATABASE_URL` behind the framework's back -therefore reads whatever the platform holds — nothing at all on a -framework-created Project, the platform's template on an older one, or a -leftover `"-"` placeholder on an environment deployed by earlier Composer -versions, which retired the rows from deploy state without touching the -variables ([deploying.md](../../guides/deploying.md) has the manual cleanup). +therefore reads whatever the platform holds — the self-healed template +pointing at one of the app's own databases, or a leftover `"-"` placeholder on +an environment deployed by earlier Composer versions, which retired the rows +from deploy state without touching the variables +([deploying.md](../../guides/deploying.md) has the manual cleanup). The +authoring-side ban is what keeps framework services off it. ## The resource inventory From 09d46a07b00c33d1bf22b1a8aa16760cb9b21fca Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:25:29 +0200 Subject: [PATCH 15/22] feat(lowering): claim DATABASE_URL with poison values at provision application.provision claims DATABASE_URL/DATABASE_URL_POOLED (production + preview, project-level) with "-" via create-only POSTs, skipping 409s. The platform build-runner self-heals a missing DATABASE_URL template on first deploy from any ready database, so without the claim a bypass reader quietly gets a live credential to one of the app's own databases; with it, direct readers fail loudly. The rows are never alchemy resources (nothing in state, upstream never owns them); legacy projects with platform-seeded rows 409 and no-op. Part of alchemy-provider-adoption (TML-3155). Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/__tests__/database-url-poison.test.ts | 135 ++++++++++++++++++ .../lowering/src/database-url-poison.ts | 73 ++++++++++ .../0-lowering/lowering/src/exports/index.ts | 1 + .../0-lowering/lowering/src/http.ts | 14 ++ .../src/__tests__/control-lowering.test.ts | 37 +++-- .../target/src/control/extension.ts | 23 +-- 6 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-poison.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/database-url-poison.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-poison.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-poison.test.ts new file mode 100644 index 00000000..43de5aa8 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/database-url-poison.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test'; +import * as Effect from 'effect/Effect'; +import type { ManagementApiClient } from '../client.ts'; +import { ManagementClient } from '../client.ts'; +import { claimPoisonDatabaseUrl } from '../database-url-poison.ts'; +import { PrismaApiError } from '../http.ts'; + +interface PostCall { + readonly path: string; + readonly body: Record; +} + +interface FakeState { + readonly posts: PostCall[]; + /** `${key}:${class}` combinations the platform already holds — a create for one 409s. */ + readonly existing: ReadonlySet; + /** When set, every create returns this status instead of 201. */ + readonly failWith?: number; +} + +const newFakeState = (overrides: Partial = {}): FakeState => ({ + posts: [], + existing: new Set(), + ...overrides, +}); + +/** + * A stubbed `ManagementApiClient` covering only `POST + * /v1/environment-variables`. `as any as ManagementApiClient` is acceptable + * here (test file — exempt from the no-bare-cast rule): the fake's shape + * already guarantees the safety a hand-written openapi-fetch signature would. + */ +const fakeClient = (state: FakeState): ManagementApiClient => { + const POST = (path: string, init: { body?: Record } = {}) => { + if (path !== '/v1/environment-variables') { + throw new Error(`fakeClient: unexpected POST ${path}`); + } + const body = init.body ?? {}; + state.posts.push({ path, body }); + + if (state.failWith !== undefined) { + return Promise.resolve({ + data: undefined, + error: { message: 'stubbed failure' }, + response: new Response(null, { status: state.failWith }), + }); + } + if (state.existing.has(`${String(body['key'])}:${String(body['class'])}`)) { + return Promise.resolve({ + data: undefined, + error: { message: 'A variable with this key already exists in this environment.' }, + response: new Response(null, { status: 409 }), + }); + } + return Promise.resolve({ + data: { data: { id: `env-${state.posts.length}` } }, + error: undefined, + response: new Response(null, { status: 201 }), + }); + }; + + // biome-ignore lint/suspicious/noExplicitAny: test stub — see the doc comment above. + return { POST } as any as ManagementApiClient; +}; + +const run = (projectId: string, state: FakeState) => + Effect.runPromise( + claimPoisonDatabaseUrl(projectId).pipe( + Effect.provideService(ManagementClient, fakeClient(state)), + ), + ); + +describe('claimPoisonDatabaseUrl', () => { + test('creates both keys in both classes at project level, with a value that cannot connect', async () => { + const state = newFakeState(); + + await run('proj_1', state); + + expect(state.posts.map((p) => p.body)).toEqual([ + { projectId: 'proj_1', class: 'production', key: 'DATABASE_URL', value: '-' }, + { projectId: 'proj_1', class: 'preview', key: 'DATABASE_URL', value: '-' }, + { projectId: 'proj_1', class: 'production', key: 'DATABASE_URL_POOLED', value: '-' }, + { projectId: 'proj_1', class: 'preview', key: 'DATABASE_URL_POOLED', value: '-' }, + ]); + // Project-level rows only: a branch id would scope the claim to one branch + // and leave every other stage's preview unclaimed. + for (const post of state.posts) expect(post.body['branchId']).toBeUndefined(); + }); + + // A row already on the platform is the platform's own system-managed one, or + // one an earlier deploy claimed. Either way it stays exactly as it is: the + // 409 is swallowed and no PATCH or DELETE follows. + test('a 409 on one key is skipped, and the remaining claims still run', async () => { + const state = newFakeState({ existing: new Set(['DATABASE_URL:production']) }); + + await run('proj_1', state); + + expect(state.posts).toHaveLength(4); + expect(state.posts.every((p) => p.path === '/v1/environment-variables')).toBe(true); + }); + + test('every key already present is a complete no-op — four creates, four 409s, nothing else', async () => { + const state = newFakeState({ + existing: new Set([ + 'DATABASE_URL:production', + 'DATABASE_URL:preview', + 'DATABASE_URL_POOLED:production', + 'DATABASE_URL_POOLED:preview', + ]), + }); + + await expect(run('proj_1', state)).resolves.toBeUndefined(); + expect(state.posts).toHaveLength(4); + }); + + test('any other API error fails, carrying the status — the deploy must not proceed unclaimed', async () => { + const state = newFakeState({ failWith: 422 }); + + const exit = await Effect.runPromise( + claimPoisonDatabaseUrl('proj_1').pipe( + Effect.provideService(ManagementClient, fakeClient(state)), + Effect.flip, + ), + ); + + expect(exit).toBeInstanceOf(PrismaApiError); + expect(exit.status).toBe(422); + // Failed on the first claim: the rest are never attempted. + expect(state.posts).toHaveLength(1); + }); + + test('no Management API client in context — the local target — claims nothing', async () => { + await expect(Effect.runPromise(claimPoisonDatabaseUrl('local'))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-poison.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-poison.ts new file mode 100644 index 00000000..4da23c58 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-poison.ts @@ -0,0 +1,73 @@ +/** + * Claiming the platform's `DATABASE_URL` / `DATABASE_URL_POOLED` environment + * variables for the app's project, with a value that cannot connect anywhere. + * + * The problem this solves: when a project has no project-level production + * `DATABASE_URL`, Prisma Cloud fills one in by itself on the next compute + * deploy — picking one of the project's own ready databases — and injects it + * into every service the project runs. A service that read + * `process.env.DATABASE_URL` directly, behind the framework's back, would then + * hold live credentials for a database nothing wired it to. Claiming both keys + * with `"-"` first means the platform finds them taken and writes nothing. + * + * `"-"`, not `""`: the API rejects an empty value ("String must contain at + * least 1 character"). Any real connect attempt against `"-"` fails loudly, + * which is the point. + * + * These variables are NOT alchemy resources, deliberately. They are not + * Composer's to own: it must never patch or delete one, and a state row would + * plan exactly those calls. They stay out of the deploy state entirely — the + * only call ever made for them is the create below. + */ + +import * as Effect from 'effect/Effect'; +import * as Option from 'effect/Option'; +import { type ManagementApiClient, ManagementClient } from './client.ts'; +import { callCreateOnly, type PrismaApiError } from './http.ts'; + +const POISON_VALUE = '-'; + +/** The two names Prisma Cloud fills in for itself, and that no Composer service may bind. */ +const POISON_KEYS = ['DATABASE_URL', 'DATABASE_URL_POOLED'] as const; + +/** + * Both environment classes, each at PROJECT level (no branch id). A preview + * branch with no override of its own reads the project-level preview row, so + * these two rows cover every stage the app will ever deploy — including + * stages that do not exist yet. + */ +const POISON_CLASSES = ['production', 'preview'] as const; + +const claim = ( + client: ManagementApiClient, + projectId: string, + key: string, + environmentClass: (typeof POISON_CLASSES)[number], +) => + callCreateOnly(() => + client.POST('/v1/environment-variables', { + body: { projectId, class: environmentClass, key, value: POISON_VALUE }, + }), + ); + +/** + * Claims both keys in both classes for `projectId`, create-only: a 409 means + * the variable already exists — whether Prisma Cloud seeded it or an earlier + * deploy claimed it — and is skipped, never overwritten and never removed. So + * this is a no-op on a project that already has the rows, and repeating it is + * always safe. + * + * Does nothing when no {@link ManagementClient} is in context: that is the + * local target, which has no Management API at all and no platform to fill a + * `DATABASE_URL` in. + */ +export const claimPoisonDatabaseUrl = (projectId: string): Effect.Effect => + Effect.gen(function* () { + const client = yield* Effect.serviceOption(ManagementClient); + if (Option.isNone(client)) return; + for (const key of POISON_KEYS) { + for (const environmentClass of POISON_CLASSES) { + yield* claim(client.value, projectId, key, environmentClass); + } + } + }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 4a8c6bb7..e1a32d40 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts @@ -15,6 +15,7 @@ export { } from '../client.ts'; export * from '../container.ts'; export * from '../credentials.ts'; +export * from '../database-url-poison.ts'; export * from '../providers.ts'; export * from './buckets.ts'; export * from './compute.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts index 4420e710..15703a07 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts @@ -54,3 +54,17 @@ export const callVoid = ( r.response.status === 404 || r.error === undefined ? Effect.void : fail(r), ), ); + +/** + * Fire a CREATE call, tolerating a 409 (it already exists). Gives the caller + * create-only semantics: the thing is created when absent, and an existing one + * — whoever created it — is left exactly as it is. + */ +export const callCreateOnly = ( + f: () => Promise, +): Effect.Effect => + attempt(f).pipe( + Effect.flatMap((r) => + r.response.status === 409 || r.error === undefined ? Effect.void : fail(r), + ), + ); 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 5b75d154..c207b19f 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 @@ -53,6 +53,7 @@ const recorded: { bucket: Array<[string, unknown]>; bucketKey: Array<[string, unknown]>; generated: Array<[string, unknown]>; + poisonClaims: string[]; } = { envVar: [], envVarProps: [], @@ -67,6 +68,7 @@ const recorded: { bucket: [], bucketKey: [], generated: [], + poisonClaims: [], }; mock.module('alchemy/Output', () => ({ @@ -135,6 +137,14 @@ mock.module('alchemy/Prisma', () => ({ mock.module('@internal/lowering', () => ({ ...RealPrismaAlchemy, providers: () => ({ stub: 'providers' }), + // Talks to the Management API directly (no Alchemy resource, by design); + // stubbed so the application hook runs purely. The projectId it claims for + // is recorded — what the claim POSTs is pinned by its own test in + // @internal/lowering. + claimPoisonDatabaseUrl: (projectId: string) => { + recorded.poisonClaims.push(projectId); + return Effect.void; + }, // A real Alchemy Resource (needs the Stack service); stubbed so // application.provision's mint runs purely. The returned "value" is // derived from `id` (which itself carries the edge id), so distinct edges @@ -341,9 +351,10 @@ describe("projectIdOf — narrowing ctx.application to this extension's own prod }); describe('prismaCloud().application.provision (once-per-lowering hook)', () => { - test('default stage: references the resolved container project (no Project minted), writes NO environment variable', () => { + test('default stage: references the resolved container project (no Project minted) and claims the DATABASE_URL keys', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); - const before = recorded.envVar.length; + const beforeEnv = recorded.envVar.length; + const beforeClaims = recorded.poisonClaims.length; const container = new PrismaCloudContainer( { appName: 'shop', stage: undefined }, 'shop-project-id', @@ -358,17 +369,18 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: undefined }); - // DATABASE_URL/DATABASE_URL_POOLED used to be overwritten with garbage - // here. The platform owns those variables (it marks them system-managed, - // and alchemy refuses to manage a system-managed variable), so this hook - // writes nothing at all; param.ts/secret.ts still reject both names, so no - // Composer row can carry one. - expect(recorded.envVar.slice(before)).toEqual([]); + expect(recorded.poisonClaims.slice(beforeClaims)).toEqual(['shop-project-id']); + // The claim is a direct Management API create, NOT an alchemy resource: + // Composer must never plan a write or a delete for either variable, and a + // state row would do exactly that. So no EnvironmentVariable is declared + // here — for the DATABASE_URL keys or anything else. + expect(recorded.envVar.slice(beforeEnv)).toEqual([]); }); - test('named stage: still writes no environment variable', () => { + test('named stage: claims the same project-level keys, and still declares no environment variable', () => { const target = prismaCloud({ workspaceId: 'ws_1' }); - const before = recorded.envVar.length; + const beforeEnv = recorded.envVar.length; + const beforeClaims = recorded.poisonClaims.length; const container = new PrismaCloudContainer( { appName: 'shop', stage: 'staging' }, 'shop-project-id', @@ -383,7 +395,10 @@ describe('prismaCloud().application.provision (once-per-lowering hook)', () => { ); expect(result).toEqual({ projectId: 'shop-project-id', branchId: 'branch_1' }); - expect(recorded.envVar.slice(before)).toEqual([]); + // The branch id never reaches the claim: the rows are project-level, so + // one claim covers every stage of the project. + expect(recorded.poisonClaims.slice(beforeClaims)).toEqual(['shop-project-id']); + expect(recorded.envVar.slice(beforeEnv)).toEqual([]); }); test('fails with the container-missing error when the CLI parent never resolved one', () => { 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 3ce7a794..8cf18e96 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 @@ -351,20 +351,21 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor teardown: (input) => runTeardown(input), // Runs once per lowering, before any service: it resolves the CLI-ensured - // Project into the application handle every descriptor reads. Per-binding - // service keys are not minted here (ADR-0031): core's provision phase - // invokes `provisions` below, graph-wide, before any service lowers. - // - // `DATABASE_URL`/`DATABASE_URL_POOLED` are deliberately absent from this - // graph: the platform seeds them and marks them system-managed, and - // alchemy's EnvironmentVariable refuses to manage a system-managed - // variable at all. What keeps a service off the platform default is the - // ban at the authoring end — `param.ts` and `secret.ts` reject both names, - // so no Composer-written row can carry one (docs/guides/deploying.md). + // Project into the application handle every descriptor reads, and claims + // the project's `DATABASE_URL`/`DATABASE_URL_POOLED` with a value that + // cannot connect, so Prisma Cloud never fills them in with one of the + // app's own databases (`claimPoisonDatabaseUrl` explains the whole + // mechanism). The claim is create-only and is NOT part of the resource + // graph: alchemy owns nothing here, so nothing plans a write or a delete + // for either variable. Binding them at the authoring end stays rejected by + // `param.ts`/`secret.ts`. Per-binding service keys are not minted here + // (ADR-0031): core's provision phase invokes `provisions` below, + // graph-wide, before any service lowers. application: { provision: (ctx) => - Effect.sync(() => { + Effect.gen(function* () { const { projectId, branchId } = prismaCloudContainerOf(ctx.container); + yield* Prisma.claimPoisonDatabaseUrl(projectId); return { projectId, branchId } satisfies CloudApplication; }), }, From 7cffe00017b1c2ad8908b1d85c959b510a303e06 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:26:32 +0200 Subject: [PATCH 16/22] docs: DATABASE_URL is claimed with a poison value at provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lowering doc and deploy guide describe the create-only claim, why it exists (the platform fills a missing DATABASE_URL from any ready database on first deploy), and that manual deletion is not useful — the claim or the platform recreates the row; a user-set value wins over both. Signed-off-by: willbot Signed-off-by: Will Madden --- .../05-prisma-cloud/alchemy-lowering.md | 42 ++++++++------- docs/guides/deploying.md | 54 ++++++++----------- 2 files changed, 43 insertions(+), 53 deletions(-) diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index fed89340..1096f00a 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -51,28 +51,30 @@ a project's default database — a convenience for hand-provisioned single services, and precisely the kind of **implicit ambient config the framework exists to eliminate**. The framework never reads it and never depends on it. Framework-provisioned Projects are created with `createDatabase: false`, so no -default database exists on them — but that does not keep the variable away: -the platform self-heals a missing `DATABASE_URL` template on the first Compute -deploy, wiring it from any ready database on the Project (default first, then -oldest). On a Composer Project that is one of the app's own databases. Every -database URL a service actually consumes is an explicit, per-service variable -the pack's `serialize` writes under its own named key, inside the `COMPOSER_` -namespace. - -On a Project that predates the opt-out, or one adopted by name, the platform's -seeded variables may exist. The framework leaves them alone: the platform marks -them as its own, and alchemy's `EnvironmentVariable` refuses to manage one — an -overwrite would fail the deploy rather than protect anyone. The ban is held at -the authoring end instead: `param.ts` and `secret.ts` reject both names, so no -Composer-written row can carry one. +default database exists on them — but that alone does not keep the variable +away: the platform self-heals a missing `DATABASE_URL` template on the first +Compute deploy, wiring it from any ready database on the Project (default +first, then oldest) — on a Composer Project, one of the app's own databases. + +So the framework claims the keys first. `application.provision` writes +`DATABASE_URL` and `DATABASE_URL_POOLED` (production and preview class, +project-level) with the poison value `"-"` via create-only calls +(`lowering/src/database-url-poison.ts`). The platform's writes are also +create-only, so whoever writes first wins permanently: on a fresh Project the +claim lands first and the self-heal never fires; on a Project whose variables +the platform already seeded, the claim gets a 409 and no-ops. The rows are +plain platform variables, never Alchemy resources — nothing enters deploy +state and upstream's `EnvironmentVariable` never owns them. Alongside the +claim, the authoring-side ban holds: `param.ts` and `secret.ts` reject both +names, so no Composer-declared row can carry one. Every database URL a +service actually consumes is an explicit, per-service variable the pack's +`serialize` writes under its own named key, inside the `COMPOSER_` namespace. A service that reads `process.env.DATABASE_URL` behind the framework's back -therefore reads whatever the platform holds — the self-healed template -pointing at one of the app's own databases, or a leftover `"-"` placeholder on -an environment deployed by earlier Composer versions, which retired the rows -from deploy state without touching the variables -([deploying.md](../../guides/deploying.md) has the manual cleanup). The -authoring-side ban is what keeps framework services off it. +therefore reads the poison `"-"` and fails loudly — or, on a Project the +platform seeded before the framework ever deployed to it, the platform's own +template ([deploying.md](../../guides/deploying.md) covers the leftover rows +and manual cleanup). ## The resource inventory diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 61ae996a..1873105f 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -273,39 +273,27 @@ changed value only takes effect through a new one. The deploy uploads the artifact it already has, starts it, moves the stable endpoint over, and removes the old deployment; your service's URL does not change. -**`DATABASE_URL` and `DATABASE_URL_POOLED` are no longer overwritten.** The -framework used to replace those variables' values with the placeholder `"-"`, -so that nothing could depend on them by accident. It no longer touches them: -they belong to Prisma Cloud, and the upstream provider refuses to manage a -variable the platform marks as its own. Nothing you write can carry those names -— `envSecret`/`envParam` still reject them — and every database URL your -services use comes from the connection they declare. - -On the first deploy after the upgrade, the framework **stops tracking** those -two variables. The deploy log reports them as `retained`, which is exactly what -happens: the entry is dropped from deploy state and **no call is made to Prisma -Cloud**, so whatever value the variable holds today stays. - -**What that means for an environment the framework deployed before the -upgrade**: the `"-"` placeholder it wrote is still there. An application that -reads `process.env.DATABASE_URL` directly reads `"-"` until someone removes the -variable. Cleaning it up is optional — nothing the framework deploys reads it — -and it is a manual step, because the framework will not delete a variable it no -longer manages: - -```bash -# Find the variable (add &branchId= for a --stage environment). -curl -s -H "Authorization: Bearer $PRISMA_SERVICE_TOKEN" \ - "https://api.prisma.io/v1/environment-variables?projectId=&key=DATABASE_URL" - -# Remove it, using the id from the response. -curl -s -X DELETE -H "Authorization: Bearer $PRISMA_SERVICE_TOKEN" \ - "https://api.prisma.io/v1/environment-variables/" -``` - -Repeat for `DATABASE_URL_POOLED`. Once removed, the platform's own template -value applies again. In the Console the same variables are visible under the -project's environment variables. +**`DATABASE_URL` and `DATABASE_URL_POOLED` hold the placeholder `"-"`, and the +framework never modifies or deletes them.** At provision the framework claims +both names (production and preview class, project level) with the placeholder, +using create-only writes: if the variable already exists — yours, or one Prisma +Cloud seeded — the claim does nothing. The placeholder is deliberate. Without +it, Prisma Cloud fills a missing `DATABASE_URL` in on the first deploy with a +live credential to one of your app's own databases, and anything reading +`process.env.DATABASE_URL` directly would quietly work against a database it +was never wired to. With it, a direct read fails loudly. Nothing you declare +can carry those names — `envSecret`/`envParam` reject them — and every database +URL your services use comes from the connection they declare. + +On the first deploy after the upgrade, the framework also **stops tracking** +the two variables in deploy state. The deploy log reports them as `retained`: +the entry is dropped from state and no call is made to Prisma Cloud. + +Deleting the variables by hand is not useful: the next deploy's claim (or the +platform's own template filler) recreates them. If you genuinely want a value +there — for a tool outside the framework that insists on `DATABASE_URL` — set +your own value in the Console; both the framework's claim and the platform's +filler are create-only and will leave your value alone. **Stage (`--stage`) environments see two one-time effects on their first deploy after the upgrade**, because a branch-attached database can no longer From ce6c2cdeb279a6d76a01f09c3e71b2a8f37a9825 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 07:42:12 +0200 Subject: [PATCH 17/22] =?UTF-8?q?docs:=20address=20PR=20197=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20names,=20propagation=20claims,=20--fresh=20?= =?UTF-8?q?wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR index origin API naming (App.appEndpointDomain per ADR-0043), layering Service row, glossary beta pin, spec open question resolved, design-notes provider-scope claim, gotchas branch-attach fix line matches its cause, --fresh documented as data-destroying, and the two propagation passages now describe env changes reaching the running deployment. Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 4 +++- .drive/projects/alchemy-provider-adoption/spec.md | 4 ++-- docs/design/03-domain-model/glossary.md | 2 +- docs/design/03-domain-model/layering.md | 2 +- docs/design/05-prisma-cloud/pdp-data-model.md | 4 ++-- docs/design/10-domains/core-model.md | 15 ++++++++------- docs/design/90-decisions/README.md | 2 +- docs/guides/running-locally.md | 6 ++++-- gotchas.md | 8 +++++--- 9 files changed, 27 insertions(+), 20 deletions(-) diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md index 2a6284a1..80dfe43c 100644 --- a/.drive/projects/alchemy-provider-adoption/design-notes.md +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -11,7 +11,9 @@ ## The model -One provider (upstream's), two provider *layers* on Composer's side: +Upstream's provider for the postgres and compute families (buckets stay +Composer-provided until the upstream release ships them); two provider +*layers* on Composer's side: - deploy: upstream's live providers (needs the `liveProviderLayer` export or a local rebuild of its wiring — client layer + individual `*Provider()`s). diff --git a/.drive/projects/alchemy-provider-adoption/spec.md b/.drive/projects/alchemy-provider-adoption/spec.md index 60743437..be19e86a 100644 --- a/.drive/projects/alchemy-provider-adoption/spec.md +++ b/.drive/projects/alchemy-provider-adoption/spec.md @@ -147,8 +147,8 @@ providers with its own local providers — today it is private and only hosted store) vs `Provider.aliases` (beta.65+ mechanism) vs destroy-and-recreate per stage? Decide in the migration slice after testing aliases against a scratch stage. -- **Which released beta** first contains the provider (beta.66 expected within - days of the spike; verify at slice start). +- ~~Which released beta first contains the provider~~ — resolved: + `alchemy@2.0.0-beta.67` is the adopted pin. # References diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 1cbffef0..7e8774f7 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -357,7 +357,7 @@ substituted at any Input) and a real deployment. ## Provisioning plane — the compile target (Alchemy / Effect) The exact substrate the authoring nouns lower **down to**, grounded in what our -providers already use (`packages/alchemy`, `alchemy@2.0.0-beta.59`, +providers already use (`packages/alchemy`, `alchemy@2.0.0-beta.67`, `effect@4-beta`). Building the next layer of abstraction means defining each authoring noun as *the compile-target terms it emits*. Two families: Alchemy's IaC definition language, and the Effect primitives Alchemy is itself built on. diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index 4e0a2d43..2ea627c9 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -35,7 +35,7 @@ resource graph, which deploys to the cloud. | Authoring (Prisma Composer) | Provisioning (Alchemy/Effect) | Hosting (Prisma Cloud) | | --- | --- | --- | | **Module** (bounded context) | a subgraph: Resources/Platforms + a Layer exposing its ports | **no single object** — spans Compute services + a DB schema slice + streams + endpoints | -| **Service** (your code; entrypoint + ingress) | Platform (compute Resource running the bundle) | App → Deployment (tar.gz bundle + manifest + endpoint) | +| **Service** (your code; entrypoint + ingress) | App + Deployment (ordinary Resources) | App → Deployment (tar.gz bundle + manifest + endpoint) | | **Resource** (managed lifecycle, state-first) | Alchemy Resource + Provider (`reconcile`/`delete`/…); Postgres via the Prisma Postgres provider | a Database (1:1 in an Environment), bucket, cache, or provisioned third-party | | **Input/Output — communication** (request/response, stream) | Binding (RPC/HTTP client; stream pub/sub) | endpoint URL + injected client; stream | | **Data Input** (method TCP/HTTP + contract) | data binding to a Postgres Resource | connection injected, scoped by contract | diff --git a/docs/design/05-prisma-cloud/pdp-data-model.md b/docs/design/05-prisma-cloud/pdp-data-model.md index b2d90473..8dde23da 100644 --- a/docs/design/05-prisma-cloud/pdp-data-model.md +++ b/docs/design/05-prisma-cloud/pdp-data-model.md @@ -83,8 +83,8 @@ Consequences Prisma Composer designs around: restart-on-config-change and no live re-resolution; a late-written variable never reaches an existing version. Propagating a changed value (e.g. a producer's new URL) into a consumer therefore means creating a new consumer - version — which Composer does NOT do today: a deployment is recreated when - its artifact changes, not when a variable's value does (see the + version — which Composer does: a deploy whose environment changed replaces + the deployment, so the change reaches the running service (see the change-propagation note in [alchemy-lowering.md](alchemy-lowering.md#the-lowering-graphs)). 3. **`DATABASE_URL` is not a separate mechanism.** It is a platform-written diff --git a/docs/design/10-domains/core-model.md b/docs/design/10-domains/core-model.md index 2f56ec30..b4d711d8 100644 --- a/docs/design/10-domains/core-model.md +++ b/docs/design/10-domains/core-model.md @@ -728,13 +728,14 @@ PDP's own dataflow restored (the deployment-create call literally contains the materialized env map). See the lowering graphs in [`../05-prisma-cloud/alchemy-lowering.md`](../05-prisma-cloud/alchemy-lowering.md). This is what makes the fresh-deploy config race (PRO-211) structurally impossible -on every target — the edge's **ordering** job. Its second job, **propagating** a -wire whose value genuinely changes, is not wired: a deployment is recreated only -when its artifact changes, and an env-var resource exposes no attribute a value -change moves. The fix belongs on the deployment resource (inputs it must be -recreated for), never on a hash of the values, and is a deferred follow-up — -narrow in practice, since promoted service endpoints are stable across producer -redeploys. +on every target — the edge's **ordering** job. Its second job, **propagating** +a wire whose value genuinely changes, is wired by the deploy hook: a deploy +whose environment differs from the running deployment's replaces the +deployment, so changed values reach the running service. Secret *values* are +never hashed or persisted for this — the mechanism keys off the non-secret +material Composer's rows carry (ADR-0042 pointers) and platform metadata. The +long-term carrier is the deployment resource itself (inputs it must be +recreated for), tracked as an upstream follow-up. Secrets are platform-sourced and rotate through the platform, not this edge (see the [config/secret glossary](../03-domain-model/glossary.md#configuration--config-and-secrets)). diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 5634def3..45dd79b5 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -60,7 +60,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [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-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 `ComputeService.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 service's own `endpointDomain` — 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-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-0043) — 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-pn-binding-carries-the-url-and-a-lazy-client.md) — `pnPostgres(contract)`'s dependency binding is `{ url, client }`: the raw connection string plus the typed client, constructed lazily and memoized on first `client` access — `hydrate` builds nothing. The contract remains the compatibility interface (hash check and deploy-time migration unchanged, ADR-0022); the binding becomes a strict superset of plain `postgres()`'s `{ url }`, so an app that owns its database client still gets framework-run migrations. Contract validation cost and failure move from `load()` (where one bad input poisoned every input, unattributed) to the first `client` access. Cross-kind satisfaction (`'prisma-next'` satisfying `'postgres'`) rejected in its favor. - [ADR-0041](ADR-0041-local-dev-runs-the-deploy-pipeline-against-local-providers.md) — `prisma-composer dev` runs the **same deploy pipeline** (Load → assemble → lower → Alchemy converge) against local implementations of the same Alchemy resource types, declared on an optional `localTarget` field of `ExtensionDescriptor` (a lazy thunk resolving a `LocalTargetDescriptor`; subpaths `@prisma/composer/local-target` and `@prisma/composer-prisma-cloud/local-target` — "dev" names only the user-facing command/prefix/state dir) (providers, container, preflight, emulators, attach, teardown — **no** `nodes`/`provisions`, so the lowering cannot diverge; no `state` either — dev uses Alchemy's own `localState()` through `LowerOptions.state`). The target runs **emulators per node kind**: Compute and buckets are machine-global, multi-tenant daemons (the Compute emulator owns the service child processes — deployment PUTs, crash supervision, logs; buckets serve the S3 wire over plain files on disk), while Postgres runs one detached ORM `prisma dev` instance per `Database` resource under the ORM CLI's own manager. Providers provision instances by communicating with the emulators during converge, and the dev command is a view through `attach`; `ServiceKey`/`S3Credentials`/`PgWarm`/`PnMigration` are shared verbatim. Credential-free by requirement. Rejects a local Management API (reimplements another team's server-side semantics, drifts silently) and per-kind dev descriptors (an open-set parallel seam). - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. diff --git a/docs/guides/running-locally.md b/docs/guides/running-locally.md index 11b40629..d80e122e 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -50,8 +50,10 @@ instances and data before starting. app's local dev state — the symptom is a plan-time error naming an unregistered resource type (for example `No provider is registered for resource type 'PrismaComposer.Database'`). -Local dev state is never migrated across framework versions; wiping it is -always safe because everything local is rebuilt on the next start. +Local dev state is never migrated across framework versions. Note `--fresh` +wipes local *data* too — database contents, bucket objects, instance state — +not just the resource bookkeeping; the next start rebuilds empty resources. +Use it when the local data is disposable, which in a dev loop it usually is. ## Logs diff --git a/gotchas.md b/gotchas.md index e5c3c4ab..800cf7cf 100644 --- a/gotchas.md +++ b/gotchas.md @@ -300,9 +300,11 @@ process.on("unhandledRejection", (e) => console.error(e)); **References.** - Upstream: [PRO-215](https://linear.app/prisma-company/issue/PRO-215/management-api-project-scoped-compute-service-create-collides-with) -- Fix: both resources are alchemy's `Prisma.App` / `Prisma.Database` now, which - create on the target Branch directly (`node_modules/alchemy/src/Prisma/App.ts`, - `.../Database.ts`) +- Fix: both resources are alchemy's `Prisma.App` / `Prisma.Database`, which + encode the two opposite mechanisms the Cause describes: `App` passes + `branchId` in the create body (`node_modules/alchemy/src/Prisma/App.ts`), + while `Database` creates project-scoped and attaches the Branch afterwards + via `PATCH` (`.../Database.ts`, `branchNeedsSync`) --- From 5b0e934615bf7820d9b6b780a705f9999e7cdeb9 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 08:00:44 +0200 Subject: [PATCH 18/22] feat(lowering): deployment replaced when its environment changes, reused otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-run generation path with a deploy fingerprint: the artifact hard-link directory is named from a hash of the service's environment material, so upstream's artifactPath comparison replaces exactly when the environment or artifact changed. The material is non-secret by construction (ADR-0042 rows carry literals and pointers); pointed platform variables contribute updatedAt metadata, read at preflight and carried across the CLI->alchemy process boundary on a new framework preflight-transport channel (the in-process implementation lost the timestamps at spawn — caught in review, now covered by a boundary-spanning test). Secret-bearing rows contribute wiring identity only; the accepted narrowing is recorded in the module. Docs updated to the shipped mechanism. Part of alchemy-provider-adoption (TML-3155); addresses PR 197 A11. Signed-off-by: willbot Signed-off-by: Will Madden --- .../alchemy-provider-adoption/design-notes.md | 69 +++--- .../05-prisma-cloud/alchemy-lowering.md | 33 +-- ...come-from-the-upstream-alchemy-provider.md | 50 +++-- gotchas.md | 2 +- .../src/__tests__/preflight-transport.test.ts | 71 +++++++ .../1-core/core/src/container-transport.ts | 12 +- .../1-core/core/src/control/app-config.ts | 16 +- .../1-core/core/src/preflight-transport.ts | 61 ++++++ .../0-framework/3-tooling/cli/src/main.ts | 15 +- .../3-tooling/cli/src/run-alchemy.ts | 5 +- .../compute/__tests__/always-redeploy.test.ts | 54 ----- .../__tests__/deploy-fingerprint.test.ts | 200 ++++++++++++++++++ .../compute/__tests__/deployment-edge.test.ts | 92 +++++--- .../lowering/src/compute/always-redeploy.ts | 66 ------ .../lowering/src/compute/artifact.ts | 4 +- .../src/compute/deploy-fingerprint.ts | 174 +++++++++++++++ .../lowering/src/compute/deployment-edge.ts | 5 +- .../lowering/src/exports/compute.ts | 2 +- .../src/__tests__/control-lowering.test.ts | 67 +++++- .../target/src/__tests__/invariants.test.ts | 4 +- .../src/__tests__/pointer-timestamps.test.ts | 192 +++++++++++++++++ .../target/src/__tests__/preflight.test.ts | 143 ++++++++++++- .../target/src/control/extension.ts | 32 ++- .../target/src/control/pointer-timestamps.ts | 90 ++++++++ .../target/src/descriptors/compute.ts | 88 +++++++- .../target/src/descriptors/shared.ts | 11 + .../1-extensions/target/src/preflight.ts | 83 ++++++-- .../1-extensions/target/src/serializer.ts | 15 +- 28 files changed, 1372 insertions(+), 284 deletions(-) create mode 100644 packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts create mode 100644 packages/0-framework/1-core/core/src/preflight-transport.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts create mode 100644 packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts diff --git a/.drive/projects/alchemy-provider-adoption/design-notes.md b/.drive/projects/alchemy-provider-adoption/design-notes.md index 80dfe43c..b2843241 100644 --- a/.drive/projects/alchemy-provider-adoption/design-notes.md +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -112,43 +112,27 @@ reconcile re-used the existing deployment, and a value-only change reached the platform's variable row but not the running deployment until the next artifact change. -**That regression is now closed Composer-side** (`compute/always-redeploy.ts`): -the deploy hook hands upstream a fresh `artifactPath` every deploy run — the -canonical content-addressed artifact hard-linked into a per-run -`deploy-` directory, generation minted once per process. Upstream's -resolved path comparison then plans a replace on EVERY deploy, restoring the -old always-redeploy behaviour with no secret material (not even a hash) in -state. The mechanism was picked over the alternatives we ruled out earlier, -with their objections answered rather than ignored: - -- Feeding values into the artifact fingerprint stays rejected — "a hash of a - secret is itself a leak" still holds; the generation path never sees a value. -- Riding an `EnvironmentVariable` attribute through a Deployment replacement - prop cannot work: the row's diff plans an update on every deploy (it - re-applies values to heal drift) and `updatedAt` is not in the variable's - stables (`stables: ["environmentVariableId"]`), so a plan-time reference to - it is an unresolved expression — which collapses upstream's replacement - block to "no opinion" and reintroduces the silent artifact skip the - deployment-edge fix closed. (`updatedAt` also moves on every deploy, so even - resolved it would encode always-redeploy, not value-change detection.) -- The recorded objection to the hard-link route — upstream could drop its - path-string comparison and the fix would vanish silently — is now guarded: - `deployment-edge.test.ts`'s always-redeploy block drives upstream's REAL - diff and fails the moment a same-bytes/new-path deploy stops planning a - replace. - -The cost is deliberate and precise: every deploy replaces every service's -deployment (create → start → promote → delete old) where the swap's steady -state re-used it — the reuse upstream's fingerprinting was built to save is -given up. The ordering edge (`app`, `deployment-edge.ts`) is untouched; the -generation path is a plain string, so the replacement block stays resolved at -plan time. - -This is a stopgap with a named exit: upstream `Prisma.Deployment` gains -`redeployOn` (inputs a deployment must be recreated for; a companion upstream -commit is in flight). When the pinned alchemy version includes it, -`alwaysRedeployArtifactPath` is deleted and the environment rows go on -`redeployOn` — one edit, in `descriptors/compute.ts`. +**That regression is closed Composer-side** (`compute/deploy-fingerprint.ts`): +the artifact hard-link directory is named from a hash of the service's +environment material, so upstream's resolved-path comparison replaces the +deployment exactly when the environment (or artifact) changed and reuses it +otherwise. The material is non-secret by construction (ADR-0042 rows carry +literals and pointers, never values); pointed platform variables contribute +their `updatedAt` metadata, read at preflight and transported across the +CLI→Alchemy process boundary on the framework preflight channel (the transport +is load-tested end to end — the first implementation lost the timestamps at the +process boundary and no in-process test could see it). Secret-bearing rows +contribute wiring identity only; the module comment records the accepted +narrowing (a value re-issued under a stable resource identity waits for the +next fingerprint-moving change) and the flows it affects. `redeployOn` +(upstream, in review) is the eventual carrier at the marked seam. + +The mechanisms ruled out and why: value hashes in state (offline-guessing +target — the rule survives, refined to "non-secret material only"); +`EnvironmentVariable.updatedAt` through a Deployment replacement prop (not in +the variable's stables, and it moves on every deploy anyway); a per-run +generation path (shipped briefly — restored the old always-redeploy behavior +at the cost of all reuse; superseded by the fingerprint). ## The poison DATABASE_URL rows are gone @@ -204,10 +188,11 @@ every deploy.** The gap and its Composer-side fix, its cost, and the - **A `Prisma.Deployment` prop for "recreate when these inputs change" (`redeployOn`; companion upstream commit in flight).** Until it ships, - Composer pays for change propagation by replacing every deployment on every - deploy (`always-redeploy.ts`). `Compute` already folds `env` into its - fingerprint and stores that fingerprint `Redacted`; the low-level resource - needs the same seam. + Composer detects change itself via the deploy fingerprint + (`deploy-fingerprint.ts`), which cannot see a value re-issued under a stable + resource identity. `Compute` already folds `env` into its fingerprint and + stores it `Redacted`; the low-level resource needs the same seam to close + that last gap. - **Raise or make configurable the App delete-retry budget** (or drain the app's deployments before deleting it). - **Export `PrismaUploadClient` / open the `alchemy/Prisma/Internal/*` subpath.** @@ -234,7 +219,7 @@ value moves": The durable statement: **the only attribute that moves at all fires on every deploy** — and it is not in the variable's stables, so it cannot even ride a plan-time diff. Any real fix must come from the deployment side, which is -where the always-redeploy generation path (and eventually `redeployOn`) sits. +where the deploy fingerprint (and eventually `redeployOn`) sits. ## Open questions diff --git a/docs/design/05-prisma-cloud/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 1096f00a..a9f2f4c2 100644 --- a/docs/design/05-prisma-cloud/alchemy-lowering.md +++ b/docs/design/05-prisma-cloud/alchemy-lowering.md @@ -209,21 +209,24 @@ which would skip the artifact comparison and silently drop a code change. `compute/deployment-edge.ts` records the full argument and its test drives upstream's real diff. -**Change propagation is wired by replacing the deployment on every deploy.** -The platform freezes a deployment's environment at create, so a *value* change -(a rotated URL) reaches a running service only through a new deployment — and -the fingerprint upstream recreates on cannot include the values without -persisting a hash of them in state (a hash of a secret is itself a leak). -Instead, the deploy hook hands upstream a fresh per-deploy-run artifact path — -the content-addressed artifact hard-linked into a per-run generation directory -(`compute/always-redeploy.ts`) — so upstream's path comparison plans a replace -on every deploy: same bytes, new path, no value and no hash in state. The -deliberate cost is that an unchanged service replaces (create, start, promote, -delete old) instead of nooping. This is a stopgap: when upstream's -`Prisma.Deployment` gains `redeployOn` (inputs a deployment must be recreated -for) and the pinned alchemy version includes it, the generation path is -removed and the environment rows ride `redeployOn` (see the -[config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)). +**Change propagation is wired by an environment fingerprint in the artifact +path.** The platform freezes a deployment's environment at create, so a +*value* change (a rotated URL) reaches a running service only through a new +deployment. The deploy hook names the artifact hard-link directory from a hash +of the service's environment material (`compute/deploy-fingerprint.ts`), so +the resolved path upstream compares moves exactly when the environment does: +unchanged service → identical path → reuse; changed environment or artifact → +new path → replace. The hashed material is non-secret by construction — +environment rows carry config literals and pointers, never secret values (see +the [config/secret split](../03-domain-model/glossary.md#configuration--config-and-secrets)) +— and out-of-band rotation of a pointed platform variable is detected via its +`updatedAt` metadata, read at preflight and carried across the CLI→Alchemy +process boundary on the framework's preflight-transport channel. Secret-bearing +rows contribute wiring identity only; a value re-issued under a stable resource +identity does not move the fingerprint (the module comment records the +accepted narrowing). When upstream's `Prisma.Deployment` gains `redeployOn` +(inputs a deployment must be recreated for) and the pinned alchemy version +includes it, the fingerprint moves onto that prop at the marked seam. The framework's core constructs these edges when lowering a connection (the `serialize` env-var records thread into `deploy` through the service SPI); no pack diff --git a/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md index 719042c2..28c340a0 100644 --- a/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md +++ b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -101,29 +101,31 @@ did. The `app` prop sits outside that block and tolerates being unresolved. `compute/__tests__/deployment-edge.test.ts` drives upstream's real diff and real Output machinery and fails if the edge ever moves back. -## Every deploy replaces the deployment +## A deployment is replaced when its environment changes The platform bakes environment values into a deployment at create, and upstream reuses a deployment whose artifact is unchanged — so a value-only change (a rotated secret) would update the platform's variable row and never -reach the running app. Composer instead guarantees that a deploy ships what -was declared: the deploy hook hard-links the content-addressed artifact into -a per-deploy-generation path (`compute/always-redeploy.ts`), the resolved -path differs every run, and upstream plans a replace — create, start, -promote, delete the old — for every service on every deploy. - -No value or hash of a value lands in deploy state. The alternatives are -worse: hashing values into state leaks (a hash of a secret is itself a -leak), and no upstream environment-variable attribute distinguishes "the -value changed" from "a deploy ran" (values are re-applied every deploy to -heal drift). - -The cost is deliberate: the deployment reuse upstream's fingerprinting -provides is given up. The exit is named in the code: when the pinned alchemy -version includes `Deployment.redeployOn` (inputs a deployment is recreated -for, fingerprinted `Redacted` upstream), the generation path is deleted and -the environment rows move onto that prop — one edit in -`descriptors/compute.ts`. +reach the running app. Composer closes this with a deploy fingerprint +(`compute/deploy-fingerprint.ts`): the artifact hard-link directory is named +from a hash of the service's environment material, so the resolved +`artifactPath` upstream compares moves exactly when the environment does — +unchanged service, identical path, deployment reused; changed environment or +artifact, new path, replace. + +The fingerprint hashes only non-secret material. Composer's environment rows +carry none (ADR-0042: secrets are pointers to platform variables, not +values); secret-bearing rows contribute their wiring identity, not a value. +Out-of-band rotation of a pointed platform variable is detected through its +`updatedAt` metadata, read at preflight and carried to the Alchemy process +over the framework's preflight-transport channel (a timestamp, never a +value). One accepted narrowing, recorded in the module: a value re-issued +under a stable resource identity (a connection rotated in place, a re-minted +service key) does not move the fingerprint; the deployment ships it on the +next change that does. Upstream's `Deployment.redeployOn` closes that +properly once released — alchemy resolves and diffs those inputs inside its +own encrypted state — and the fingerprint then moves onto it at a marked +seam. ## Consequences @@ -173,9 +175,13 @@ the environment rows move onto that prop — one edit in - **Adopt upstream's built-in dev mode instead of the local target.** Would replace a whole-layer seam that already works with per-provider substitution, and tie local-dev iteration to an external release cadence. -- **Detect environment changes instead of always redeploying.** Requires - either value hashes in state (a leak) or a change signal upstream does not - expose; both rejected above. +- **Replace the deployment on every deploy** (the pre-adoption behavior). + Ships every change by brute force but gives up upstream's reuse entirely; + superseded by the fingerprint, which detects changes from non-secret + material only. +- **Hash environment values into the fingerprint.** A hash of a secret in + plaintext state is an offline-guessing target; rejected. The fingerprint + hashes only material that is non-secret by construction. ## References diff --git a/gotchas.md b/gotchas.md index 800cf7cf..efd9c05c 100644 --- a/gotchas.md +++ b/gotchas.md @@ -203,7 +203,7 @@ process.on("unhandledRejection", (e) => console.error(e)); **Cause (corrected after reading pdp-control-plane source).** Env vars are `ConfigVariable` rows **materialized into a version at version-create time** (`materializeBranchEnvVars` resolves the branch's map and hands it to Foundry with the version) and frozen there — version start does not re-resolve, and updating a variable touches only the row, never an existing version. So the race is the env-var POST vs the consumer's **version-create** call, issued by one apply with no dependency edge between them. Consequences: (1) a version created before the row exists never sees it, regardless of VM recycles; (2) config changes take effect only via a new version — there is no restart-on-config-change. _The original filing (and this entry's first version) claimed boot-time application and recycle-healing; the source model contradicts that. Our one observed recycle-heal is treated as a platform bug, not behavior to rely on._ -**Workaround.** Two halves, both handled in Prisma Composer. **Ordering:** give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). The consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). **Propagation:** since a version's environment is frozen at create, a changed variable *value* reaches a running service only via a new version — so every deploy ships one: the deploy hook hands the platform a fresh per-deploy-run artifact path (`compute/always-redeploy.ts`), replacing each service's deployment on every deploy, unchanged code included. That cost stands until alchemy's `Prisma.Deployment` grows `redeployOn` — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. +**Workaround.** Two halves, both handled in Prisma Composer. **Ordering:** give the consumer's version-create a real dependency on the env-var write in the deploy graph — the version genuinely consumes the environment (PDP's version-create call contains the materialized map). The consumer's deployment reads its app id through every variable's id, so the planner schedules each write ahead of version-create (`compute/deployment-edge.ts` in `@internal/lowering`; alchemy's `Prisma.Deployment` has no prop for the environment itself). **Propagation:** since a version's environment is frozen at create, a changed variable *value* reaches a running service only via a new version — so the deploy hook fingerprints each service's environment material (non-secret by construction; ADR-0042 rows carry pointers, not values) into the artifact path (`compute/deploy-fingerprint.ts`): a changed environment yields a new path and the deployment is replaced; an unchanged service is reused. Out-of-band platform-variable rotation is caught via `updatedAt` metadata read at preflight. The long-term carrier is alchemy's `Prisma.Deployment.redeployOn` — see the change-propagation note in [`docs/design/05-prisma-cloud/alchemy-lowering.md`](docs/design/05-prisma-cloud/alchemy-lowering.md). Manual stacks: create the variable, then ship a new version. **Reproduction.** diff --git a/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts b/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts new file mode 100644 index 00000000..7cbfad3f --- /dev/null +++ b/packages/0-framework/1-core/core/src/__tests__/preflight-transport.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { preflightEnv, preflightEnvVarName, readPreflightPayload } from '../preflight-transport.ts'; + +describe('preflightEnvVarName()', () => { + test('the documented mangling — the exact @prisma/composer-prisma-cloud expectation', () => { + expect(preflightEnvVarName('@prisma/composer-prisma-cloud')).toBe( + 'PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD', + ); + }); + + test('never collides with the container transport variable for the same extension', () => { + expect(preflightEnvVarName('@prisma/composer-prisma-cloud')).not.toBe( + 'PRISMA_COMPOSER_CONTAINER_PRISMA_COMPOSER_PRISMA_CLOUD', + ); + }); +}); + +describe('preflightEnv()', () => { + test('one var per extension, holding the payload that extension wrote, verbatim', () => { + expect( + preflightEnv( + new Map([ + ['@prisma/composer-prisma-cloud', '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}'], + ['acme.widgets/v2', 'whatever-this-extension-wrote'], + ]), + ), + ).toEqual({ + PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD: + '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}', + PRISMA_COMPOSER_PREFLIGHT_ACME_WIDGETS_V2: 'whatever-this-extension-wrote', + }); + }); + + test('an extension with nothing to carry sets no var', () => { + expect(preflightEnv(new Map([['acme.widgets', '']]))).toEqual({}); + expect(preflightEnv(new Map())).toEqual({}); + }); + + test('two ids that mangle to the same var name fail loudly, naming both', () => { + expect(() => + preflightEnv( + new Map([ + ['acme.widgets', 'a'], + ['acme/widgets', 'b'], + ]), + ), + ).toThrow(/both mangle to the preflight transport variable/); + }); +}); + +describe('readPreflightPayload()', () => { + test('reads back exactly what the CLI process wrote', () => { + const payload = '{"STRIPE_KEY":"2026-05-05T12:00:00.000Z"}'; + const env = preflightEnv(new Map([['@prisma/composer-prisma-cloud', payload]])); + + expect(readPreflightPayload('@prisma/composer-prisma-cloud', env)).toBe(payload); + }); + + test('a var belonging to another extension is not read as this one', () => { + const env = preflightEnv(new Map([['acme.widgets', 'not-mine']])); + + expect(readPreflightPayload('@prisma/composer-prisma-cloud', env)).toBeUndefined(); + }); + + test('an absent or empty var reads as nothing carried', () => { + expect(readPreflightPayload('acme.widgets', {})).toBeUndefined(); + expect( + readPreflightPayload('acme.widgets', { PRISMA_COMPOSER_PREFLIGHT_ACME_WIDGETS: '' }), + ).toBeUndefined(); + }); +}); 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 61e25eb6..d2708d23 100644 --- a/packages/0-framework/1-core/core/src/container-transport.ts +++ b/packages/0-framework/1-core/core/src/container-transport.ts @@ -51,13 +51,17 @@ export interface ContainerDescriptor Promise; + readonly preflight?: (input: PreflightInput) => Promise; /** * Destroy-time cleanup — the CLI runs it once, after `alchemy destroy` * succeeds and BEFORE the stage's Project/Branch are removed. A target uses diff --git a/packages/0-framework/1-core/core/src/preflight-transport.ts b/packages/0-framework/1-core/core/src/preflight-transport.ts new file mode 100644 index 00000000..d8f0ec34 --- /dev/null +++ b/packages/0-framework/1-core/core/src/preflight-transport.ts @@ -0,0 +1,61 @@ +/** + * Carries what an extension's deploy preflight learned from the CLI process + * into the alchemy process — the same two-process problem, and the same + * channel, as resolved containers (ADR-0037, container-transport.ts). + * + * Preflight runs in the CLI parent, because it is the step that talks to the + * platform. Alchemy then runs as a child process against the generated stack + * file, which re-imports the app config from scratch: every extension factory + * is called again, with none of the parent's state. Anything preflight learned + * that the lowering needs is therefore gone unless it is transported, and env + * vars are the only channel between the two processes. So the CLI writes each + * extension's preflight payload into one env var, and the extension reads its + * own var back in the alchemy process. The framework owns the vars; it never + * reads their contents. + * + * An extension must never put a SECRET VALUE in a payload: the alchemy child's + * environment is not a secret store, and the payload is not encrypted. Carry + * metadata (e.g. when a platform variable was last written), never values. + */ +import { mangleExtensionId } from './container-transport.ts'; + +/** + * What an extension's `preflight` hands back for the transport: a string only + * that extension reads, or `undefined` when it has nothing to carry (the usual + * case for an extension whose preflight only checks prerequisites). + */ +export type PreflightPayload = string | undefined; + +/** '@prisma/composer-prisma-cloud' → 'PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD' */ +export function preflightEnvVarName(extensionId: string): string { + return `PRISMA_COMPOSER_PREFLIGHT_${mangleExtensionId(extensionId)}`; +} + +/** The env entries the CLI sets on the alchemy process: `{ [preflightEnvVarName(id)]: payload }` for every extension whose preflight returned one. */ +export function preflightEnv(payloads: ReadonlyMap): Record { + const env: Record = {}; + const ownerByVarName = new Map(); + for (const [extensionId, payload] of payloads) { + if (payload.length === 0) continue; + const varName = preflightEnvVarName(extensionId); + const owner = ownerByVarName.get(varName); + if (owner !== undefined) { + throw new Error( + `Extension ids "${owner}" and "${extensionId}" both mangle to the preflight transport ` + + `variable "${varName}" — rename one of the extensions.`, + ); + } + ownerByVarName.set(varName, extensionId); + env[varName] = payload; + } + return env; +} + +/** The alchemy-process side: the payload this extension's own preflight wrote, or `undefined` when it wrote none (or when nothing ran a preflight at all). */ +export function readPreflightPayload( + extensionId: string, + env: Readonly>, +): PreflightPayload { + const payload = env[preflightEnvVarName(extensionId)]; + return payload === undefined || payload.length === 0 ? undefined : payload; +} diff --git a/packages/0-framework/3-tooling/cli/src/main.ts b/packages/0-framework/3-tooling/cli/src/main.ts index 5d9e56f6..a9bae9a5 100644 --- a/packages/0-framework/3-tooling/cli/src/main.ts +++ b/packages/0-framework/3-tooling/cli/src/main.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { RunAssembler } from '@internal/assemble'; import type { ContainerInstance, PrismaAppConfig } from '@internal/core/config'; -import { containerEnv } from '@internal/core/config'; +import { containerEnv, preflightEnv } from '@internal/core/config'; import { Cli, Command, Option, UsageError } from 'clipanion'; import { CliError } from './cli-error.ts'; import { runDev } from './dev/run-dev.ts'; @@ -367,11 +367,21 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< // prerequisites — e.g. that every secret env var in the provision manifest // exists for the resolved stage (ADR-0029) — BEFORE any stack file is written // or Alchemy runs, so a missing secret fails fast with nothing side-effected. + + // 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. + const preflightPayloads = new Map(); if (args.command === 'deploy') { for (const extension of config.extensions) { if (extension.preflight === undefined) continue; try { - await extension.preflight({ graph, container: containers.get(extension.id), stage }); + const payload = await extension.preflight({ + graph, + container: containers.get(extension.id), + stage, + }); + if (payload !== undefined) preflightPayloads.set(extension.id, payload); } catch (error) { throw error instanceof CliError ? error @@ -397,6 +407,7 @@ export async function run(argv: readonly string[], deps: RunDeps = {}): Promise< cwd, stage: alchemyStage, containerEnv: containerEnv(containers), + preflightEnv: preflightEnv(preflightPayloads), }); if (status !== 0) { console.error(`\nGenerated stack file: ${stackPath}`); diff --git a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index 4c4d178c..461cc10e 100644 --- a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts +++ b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts @@ -38,11 +38,13 @@ export interface RunAlchemyInput { readonly stage: string; /** Every extension's resolved container, serialized — one env var per extension (core's container-transport naming). Content-blind: the CLI never reads these values, only writes them. */ readonly containerEnv: Readonly>; + /** What each extension's deploy preflight handed back, serialized — one env var per extension (core's preflight-transport naming). Absent for destroy, which runs no preflight. Content-blind, like `containerEnv`. */ + readonly preflightEnv?: Readonly>; /** Defaults to `process.env`; overridable so tests can pin a fake bin's inputs. */ readonly env?: NodeJS.ProcessEnv; } -/** Runs `alchemy deploy|destroy --yes --stage `, inheriting stdio + env, plus every extension's resolved container. */ +/** Runs `alchemy deploy|destroy --yes --stage `, inheriting stdio + env, plus every extension's resolved container and preflight payload. */ export function runAlchemy(input: RunAlchemyInput): number { const bin = resolveAlchemyBin(input.cwd); const args = [input.command, input.stackFileRelativePath, '--yes', '--stage', input.stage]; @@ -53,6 +55,7 @@ export function runAlchemy(input: RunAlchemyInput): number { env: { ...(input.env ?? process.env), ...input.containerEnv, + ...input.preflightEnv, }, }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts deleted file mode 100644 index d77fd299..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/always-redeploy.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * The per-deploy artifact generation: same bytes, a fresh path per deploy run - * — what makes upstream's `Prisma.Deployment` replace on every deploy so a - * changed environment value always reaches the running app. Which plan action - * the fresh path produces is proven against upstream's real diff in - * `deployment-edge.test.ts`; this file pins the path mechanics. - */ - -import { afterAll, 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 { alwaysRedeployArtifactPath } from '../always-redeploy.ts'; - -const digestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'always-redeploy-')); -const canonicalPath = path.join(digestDir, 'auth.tar.gz'); -fs.writeFileSync(canonicalPath, 'artifact-bytes'); - -afterAll(() => { - fs.rmSync(digestDir, { recursive: true, force: true }); -}); - -describe('alwaysRedeployArtifactPath', () => { - test('two deploy runs (two generations) get two distinct paths to the same bytes', () => { - const first = alwaysRedeployArtifactPath(canonicalPath, 'run-1'); - const second = alwaysRedeployArtifactPath(canonicalPath, 'run-2'); - - expect(first).not.toBe(second); - expect(first).not.toBe(canonicalPath); - expect(fs.readFileSync(first, 'utf8')).toBe('artifact-bytes'); - expect(fs.readFileSync(second, 'utf8')).toBe('artifact-bytes'); - }); - - test('within one run the path is stable — the dev watch loop converges without churn', () => { - expect(alwaysRedeployArtifactPath(canonicalPath, 'run-1')).toBe( - alwaysRedeployArtifactPath(canonicalPath, 'run-1'), - ); - // The default generation is minted once per process: same property. - expect(alwaysRedeployArtifactPath(canonicalPath)).toBe( - alwaysRedeployArtifactPath(canonicalPath), - ); - }); - - test('the generation lives beside the canonical artifact, keyed by the run', () => { - const generationPath = alwaysRedeployArtifactPath(canonicalPath, 'run-3'); - expect(path.dirname(path.dirname(generationPath))).toBe(digestDir); - expect(path.basename(path.dirname(generationPath))).toBe('deploy-run-3'); - expect(path.basename(generationPath)).toBe('auth.tar.gz'); - }); - - test("the destroy-run placeholder ('' — no build) passes through untouched", () => { - expect(alwaysRedeployArtifactPath('', 'run-1')).toBe(''); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts new file mode 100644 index 00000000..48bc80de --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts @@ -0,0 +1,200 @@ +/** + * The environment fingerprint: the path a deploy hands upstream moves when the + * environment moved and stands still when it did not — and no secret byte, and + * no hash of one, is ever part of it. Which plan action a moved path actually + * produces is proven against upstream's real diff in `deployment-edge.test.ts`; + * this file pins what the fingerprint covers and what the path looks like. + */ + +import { afterAll, 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 { + deployEnvFingerprint, + deployEnvFingerprintMaterial, + type EnvFingerprintEntry, + fingerprintedArtifactPath, + type PointerUpdatedAt, +} from '../deploy-fingerprint.ts'; + +const digestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-fingerprint-')); +const canonicalPath = path.join(digestDir, 'auth.tar.gz'); +fs.writeFileSync(canonicalPath, 'artifact-bytes'); + +const otherDigestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deploy-fingerprint-')); +const otherArtifactPath = path.join(otherDigestDir, 'auth.tar.gz'); +fs.writeFileSync(otherArtifactPath, 'other-artifact-bytes'); + +afterAll(() => { + fs.rmSync(digestDir, { recursive: true, force: true }); + fs.rmSync(otherDigestDir, { recursive: true, force: true }); +}); + +/** A service's rows: a config literal, a pointer row, the input document, a generated row, a provider param. */ +const environment: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_AUTH_PORT', value: '3000' }, + { + key: 'COMPOSER_AUTH_TIER', + value: '"@composer-param-pointer:AUTH_TIER"', + pointers: ['AUTH_TIER'], + }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, + { key: 'COMPOSER_AUTH_SESSION_GENERATED', withheld: 'generated:32:true' }, + { key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:auth-svc' }, +]; + +const rotations = + (entries: Record): PointerUpdatedAt => + (name) => + entries[name]; + +const platform = rotations({ + AUTH_TIER: '2026-01-01T00:00:00.000Z', + STRIPE_KEY: '2026-01-02T00:00:00.000Z', +}); + +const pathFor = ( + entries: readonly EnvFingerprintEntry[], + updatedAt: PointerUpdatedAt = platform, + artifactPath: string = canonicalPath, +): string => fingerprintedArtifactPath(artifactPath, deployEnvFingerprint(entries, updatedAt)); + +/** The rows with one entry swapped for `replacement`, matched by key. */ +const withRow = (replacement: EnvFingerprintEntry): readonly EnvFingerprintEntry[] => + environment.map((entry) => (entry.key === replacement.key ? replacement : entry)); + +describe('the fingerprint moves exactly when the environment moved', () => { + test('the same environment and the same artifact give the same path — the deployment is reused', () => { + expect(pathFor(environment)).toBe(pathFor(environment)); + }); + + test('the row ORDER does not move it — the serializer may emit rows in any order', () => { + expect(pathFor([...environment].reverse())).toBe(pathFor(environment)); + }); + + test('a changed config value gives a new path', () => { + expect(pathFor(withRow({ key: 'COMPOSER_AUTH_PORT', value: '8080' }))).not.toBe( + pathFor(environment), + ); + }); + + test('an added row gives a new path', () => { + expect(pathFor([...environment, { key: 'COMPOSER_AUTH_DEBUG', value: 'true' }])).not.toBe( + pathFor(environment), + ); + }); + + test('a removed row gives a new path', () => { + expect(pathFor(environment.slice(1))).not.toBe(pathFor(environment)); + }); + + test('a rotated POINTED variable gives a new path, though every row is byte-identical', () => { + const rotated = rotations({ + AUTH_TIER: '2026-01-01T00:00:00.000Z', + STRIPE_KEY: '2026-06-30T09:15:00.000Z', + }); + expect(pathFor(environment, rotated)).not.toBe(pathFor(environment)); + }); + + test('a re-pointed row (same value shape, different platform variable) gives a new path', () => { + expect( + pathFor( + withRow({ + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY_2"}}', + pointers: ['STRIPE_KEY_2'], + }), + ), + ).not.toBe(pathFor(environment)); + }); + + test('a rewired withheld row (different producing resources) gives a new path', () => { + expect( + pathFor(withRow({ key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:billing-svc' })), + ).not.toBe(pathFor(environment)); + }); + + test('a changed artifact gives a new path — the canonical path is already content-addressed', () => { + expect(pathFor(environment, platform, otherArtifactPath)).not.toBe(pathFor(environment)); + }); +}); + +describe('no secret value can reach the fingerprint', () => { + // The row set a real service produces for its secret-bearing channels: a + // minted generated value, a dependency connection string, a minted service + // key. The sentinel is what each of those values would be. + const SENTINEL = 'hunter2-correct-horse-battery-staple'; + + const secretBearing: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_AUTH_SESSION_GENERATED', withheld: 'generated:32:true' }, + { key: 'COMPOSER_AUTH_DB_URL', withheld: 'input.db:db-postgres' }, + { key: 'COMPOSER_AUTH_STREAMS_API_KEY', withheld: 'provider.STREAMS_API_KEY:streamskey-auth' }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, + ]; + + // The pointed variable HOLDS the sentinel on the platform; what the deploy + // learns about it is a timestamp, and that is all the lookup can return. + const platformHoldingTheSentinel = rotations({ STRIPE_KEY: '2026-01-02T00:00:00.000Z' }); + + test('the hashed material contains the sentinel nowhere', () => { + const material = deployEnvFingerprintMaterial(secretBearing, platformHoldingTheSentinel); + expect(material).not.toContain(SENTINEL); + // What it DOES contain: the row keys, the pointer NAME, and the timestamp. + expect(material).toContain('COMPOSER_AUTH_DB_URL'); + expect(material).toContain('STRIPE_KEY'); + expect(material).toContain('2026-01-02T00:00:00.000Z'); + }); + + test('the path contains the sentinel nowhere', () => { + expect(pathFor(secretBearing, platformHoldingTheSentinel)).not.toContain(SENTINEL); + }); + + test('a withheld entry has no field a value could be passed in', () => { + const entry: EnvFingerprintEntry = { + key: 'COMPOSER_AUTH_DB_URL', + withheld: 'input.db:db-postgres', + }; + // @ts-expect-error a withheld row cannot also carry a value — the union forbids it. + const rejected: EnvFingerprintEntry = { ...entry, value: SENTINEL }; + expect(rejected.key).toBe('COMPOSER_AUTH_DB_URL'); + }); +}); + +describe('the fingerprinted path', () => { + test('lives beside the canonical artifact, named by the fingerprint, same bytes', () => { + const linked = pathFor(environment); + expect(path.dirname(path.dirname(linked))).toBe(digestDir); + expect(path.basename(path.dirname(linked))).toMatch(/^deploy-env-[0-9a-f]{12}$/); + expect(path.basename(linked)).toBe('auth.tar.gz'); + expect(fs.readFileSync(linked, 'utf8')).toBe('artifact-bytes'); + }); + + test("is a plain string, never an Output — upstream's replacement block must stay resolved", () => { + expect(typeof pathFor(environment)).toBe('string'); + }); + + test("the destroy-run placeholder ('' — no build) passes through untouched", () => { + expect(fingerprintedArtifactPath('', deployEnvFingerprint(environment, platform))).toBe(''); + }); +}); + +describe('dev, where no platform timestamps exist', () => { + const noPlatform: PointerUpdatedAt = () => undefined; + + test('every pointer reads as unknown and the fingerprint is still stable', () => { + expect(pathFor(environment, noPlatform)).toBe(pathFor(environment, noPlatform)); + }); + + test('an unknown timestamp is not the same as a known one — dev never collides with a deploy', () => { + expect(pathFor(environment, noPlatform)).not.toBe(pathFor(environment, platform)); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts index 655572c6..14eee4f6 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts @@ -34,7 +34,11 @@ import { sha256, sha256Object } from 'alchemy/Util/sha256'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import { alwaysRedeployArtifactPath } from '../always-redeploy.ts'; +import { + deployEnvFingerprint, + type EnvFingerprintEntry, + fingerprintedArtifactPath, +} from '../deploy-fingerprint.ts'; import { appAfterEnvironment } from '../deployment-edge.ts'; /** A stack the resource constructors register into; nothing ever applies it. */ @@ -197,48 +201,86 @@ describe('upstream Deployment.diff while the new variable is still unresolved', expect(diff).toEqual({ action: 'replace' }); }); - test('an identical artifactPath plans no replacement — the reuse always-redeploy opts out of', async () => { + test('an identical artifactPath plans no replacement', async () => { const diff = await diffAgainst(persistedOutput(await artifactFingerprint())); // An update, not a replace: with the SAME path and bytes, upstream reuses // the deployment and only re-asserts start/promote. The platform never - // re-reads environment rows into a reused deployment, so this is exactly - // the plan Composer must never let a real deploy reach — - // `alwaysRedeployArtifactPath` hands upstream a fresh path every run. + // re-reads environment rows into a reused deployment, so a real deploy may + // only reach this plan when its environment is unchanged too — which is + // what folding the environment into the path enforces. expect(diff).toEqual({ action: 'update' }); }); }); -describe('always-redeploy: the per-deploy generation path, against upstream diff', () => { - test('same bytes, a new deploy run — replace is planned, so env values reach the app', async () => { - const previousRun = alwaysRedeployArtifactPath(artifactPath, 'run-1'); - const thisRun = alwaysRedeployArtifactPath(artifactPath, 'run-2'); - // The fingerprint has NOT moved — the path alone carries the replace. - // This is the pinned always-redeploy choice, not an accident: an - // environment-value-only change (invisible to every Deployment prop, the - // platform never returns values) and a no-change redeploy are the same - // deploy to upstream, and BOTH must ship a fresh deployment because the - // platform materializes env rows only at deployment create (PRO-211). +/** + * What each environment produces as a path, and what upstream plans for it. + * The environment-value-only change is the case that has no other signal at + * all: no Deployment prop moves, and the platform never returns a value — so + * the fingerprint is the ONLY thing that can tell upstream to ship a fresh + * deployment, which the platform requires because it materializes env rows + * only at deployment create (PRO-211). + */ +const envRows = (port: string): readonly EnvFingerprintEntry[] => [ + { key: 'COMPOSER_AUTH_PORT', value: port }, + { + key: 'COMPOSER_AUTH_INPUT', + value: '{"apiKey":{"$secret":"STRIPE_KEY"}}', + pointers: ['STRIPE_KEY'], + }, +]; + +const rotatedAt = (updatedAt: string) => () => updatedAt; + +const pathForEnvironment = ( + entries: readonly EnvFingerprintEntry[], + updatedAt = '2026-01-02T00:00:00.000Z', +): string => + fingerprintedArtifactPath(artifactPath, deployEnvFingerprint(entries, rotatedAt(updatedAt))); + +describe('the environment fingerprint, against upstream diff', () => { + test('nothing changed — upstream reuses the deployment rather than replacing it', async () => { + const unchanged = pathForEnvironment(envRows('3000')); + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: unchanged, + newPath: unchanged, + }); + // An update, not a replace: same path, same bytes, so upstream keeps the + // running deployment and only re-asserts start/promote. This is the reuse + // the per-deploy-generation path gave up and the fingerprint restores. + expect(diff).toEqual({ action: 'update' }); + }); + + test('a changed environment VALUE plans a replace, though the bytes are identical', async () => { + const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { + oldPath: pathForEnvironment(envRows('3000')), + newPath: pathForEnvironment(envRows('8080')), + }); + expect(diff).toEqual({ action: 'replace' }); + }); + + test('a rotated POINTED platform variable plans a replace, though every row is identical', async () => { const diff = await diffAgainst(persistedOutput(await artifactFingerprint()), { - oldPath: previousRun, - newPath: thisRun, + oldPath: pathForEnvironment(envRows('3000'), '2026-01-02T00:00:00.000Z'), + newPath: pathForEnvironment(envRows('3000'), '2026-06-30T09:15:00.000Z'), }); expect(diff).toEqual({ action: 'replace' }); }); - test('a changed artifact on a new deploy run still plans a replace', async () => { - const diff = await diffAgainst(persistedOutput('the-previous-generations-fingerprint'), { - oldPath: alwaysRedeployArtifactPath(artifactPath, 'run-1'), - newPath: alwaysRedeployArtifactPath(artifactPath, 'run-2'), + test('a changed artifact plans a replace under an unchanged environment', async () => { + const unchanged = pathForEnvironment(envRows('3000')); + const diff = await diffAgainst(persistedOutput('the-previous-artifacts-fingerprint'), { + oldPath: unchanged, + newPath: unchanged, }); expect(diff).toEqual({ action: 'replace' }); }); - test('the generation path stays a plain value — the diff never degrades to update', () => { + test('the fingerprinted path stays a plain value — the diff never degrades to update', () => { // The replacement block {portMapping, skipCodeUpload, artifactPath, // artifactContentType} must be RESOLVED at plan time or upstream returns // no opinion and the engine falls back to a plain update — the silent - // artifact skip all over again. The generation path is a string computed - // before lowering, never an Output. - expect(Output.isOutput(alwaysRedeployArtifactPath(artifactPath, 'run-3'))).toBe(false); + // artifact skip all over again. The fingerprinted path is a string + // computed before lowering, never an Output. + expect(Output.isOutput(pathForEnvironment(envRows('3000')))).toBe(false); }); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts deleted file mode 100644 index d20a9a5c..00000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/always-redeploy.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Every deploy ships a fresh deployment — the guarantee that a changed - * environment value always reaches the running app. - * - * The platform materializes environment rows into a deployment at create time - * and never re-reads them (gotchas.md, PRO-211), so a value change reaches the - * running app only through a NEW deployment. Upstream's `Prisma.Deployment` - * recreates only when a prop in its replacement block moves, and nothing an - * `EnvironmentVariable` exposes can ride that block: values are write-only, - * and the one attribute that moves at all (`updatedAt`) is not in the - * variable's stables — with the row planned as an update every deploy (it - * re-applies values to heal drift), a reference to it is an unresolved - * expression at plan time, which collapses upstream's diff to a plain update - * and reintroduces the silent artifact skip `appAfterEnvironment` closed. - * - * So the deployment is made to replace on EVERY deploy instead, by handing - * upstream a fresh `artifactPath` each deploy run: the canonical - * content-addressed artifact is hard-linked into a `deploy-` - * directory beside it, where the generation is minted once per process — one - * deploy run is one process, so every run's path differs from the last and - * upstream's resolved path comparison plans a replace. Same bytes, new path, - * no secret material anywhere near state. The cost is deliberate: the reuse - * upstream's fingerprinting was built to save is given up, and an unchanged - * service replaces (create, start, promote, delete old) rather than noops. - * - * The dev watch loop is untouched in practice: one session is one process, so - * the path is stable across converges, and the local Deployment provider - * reconciles unconditionally anyway. - * - * THE SEAM: upstream `Prisma.Deployment` gains `redeployOn` (inputs a - * deployment must be recreated for); when the pinned alchemy version includes - * it, replace this — pass the canonical `artifact.path` verbatim again and - * put the environment rows on `redeployOn`. The one call site is - * `descriptors/compute.ts`'s deploy hook. - */ -import * as crypto from 'node:crypto'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -/** Minted once per process: a deploy run is one process. */ -const DEPLOY_GENERATION = `${String(Date.now())}-${crypto.randomUUID().slice(0, 8)}`; - -/** - * Hard-links `artifactPath` into a sibling `deploy-` directory and - * returns the link's path — same bytes, a path no previous deploy run used. - * The empty path (`packageComputeArtifact`'s destroy-run placeholder) passes - * through untouched. `generation` is overridable for tests only. - */ -export function alwaysRedeployArtifactPath( - artifactPath: string, - generation: string = DEPLOY_GENERATION, -): string { - if (artifactPath === '') return artifactPath; - const generationDir = path.join(path.dirname(artifactPath), `deploy-${generation}`); - fs.mkdirSync(generationDir, { recursive: true }); - const generationPath = path.join(generationDir, path.basename(artifactPath)); - if (!fs.existsSync(generationPath)) { - try { - fs.linkSync(artifactPath, generationPath); - } catch { - // A filesystem without hard links still gets the fresh path. - fs.copyFileSync(artifactPath, generationPath); - } - } - return generationPath; -} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index 6c0be4d1..28b89f44 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -182,8 +182,8 @@ export function packageComputeArtifact(opts: PackageComputeArtifactOptions): Com // The output path must be content-addressed AND per-user. Content-addressed // so the local dev loop can memoize on it (a converge re-hashes and // re-extracts nothing when the bytes didn't move) — this is the CANONICAL - // path; the deploy hook derives the per-deploy-run path the hosted - // Deployment is handed from it (`alwaysRedeployArtifactPath`). Per-user + // path; the deploy hook derives the environment-fingerprinted path the + // hosted Deployment is handed from it (`fingerprintedArtifactPath`). Per-user // because a fixed shared dir under os.tmpdir() is owned by whichever OS // user creates it first — everyone else's writes fail EACCES. uid is -1 // on Windows — still a valid, deterministic directory name. diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts new file mode 100644 index 00000000..e7854dab --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deploy-fingerprint.ts @@ -0,0 +1,174 @@ +/** + * The environment fingerprint that decides whether a deploy ships a NEW + * deployment — the guarantee that a changed environment value reaches the + * running app, without giving up reuse when nothing changed. + * + * The platform materializes environment rows into a deployment at create time + * and never re-reads them (gotchas.md, PRO-211), so a changed value reaches the + * running app only through a NEW deployment. Upstream's `Prisma.Deployment` + * recreates only when a prop in its replacement block moves, and nothing an + * `EnvironmentVariable` exposes can ride that block: values are write-only, and + * the one attribute that moves at all (`updatedAt`) is not in the variable's + * stables — with the row planned as an update every deploy (it re-applies + * values to heal drift), a reference to it is an unresolved expression at plan + * time, which collapses upstream's diff to a plain update and reintroduces the + * silent artifact skip `appAfterEnvironment` closed. + * + * So the environment is folded into `artifactPath` instead: the canonical + * content-addressed artifact is hard-linked into a sibling directory NAMED BY + * A HASH OF THE ENVIRONMENT. Same environment and same artifact produce the + * same path, so upstream reuses the deployment (noop/update); a changed value, + * a rotated platform variable, or changed code produces a different path, and + * upstream's resolved path comparison plans a replace. + * + * WHAT GOES INTO THE HASH — and what deliberately does not. Composer's env + * rows carry secret POINTERS, not secret values (ADR-0042), so hashing a + * row's stored text is leak-free for every row whose text is secret-free BY + * CONSTRUCTION. The rows that are not — a minted generated value, a dependency + * connection string, a minted service key — hand over no text at all: the + * `withheld` entry variant has nowhere to put one. It contributes the row's + * key and a description of what PRODUCES the row (its upstream resources), so + * rewiring still moves the fingerprint while no secret byte, and no hash of + * one, is ever computed. See `EnvFingerprintEntry`. + * + * Platform variables a row POINTS at are the operator's, not Composer's: + * Composer never writes them, so an out-of-band rotation is invisible in the + * row text. Each pointer therefore contributes the pointed variable's + * `updatedAt` TIMESTAMP — metadata, never a value. A variable Composer itself + * writes must never contribute its `updatedAt`: alchemy re-applies those rows + * on every deploy, so their timestamp moves every deploy and would make the + * fingerprint churn forever. + * + * WHAT A WITHHELD ROW CANNOT SEE — an accepted limit, not an oversight. A + * withheld row's whole change signal is the set of upstream resources its + * value is built from, so it moves when the row is wired to different + * resources and stands still when the SAME resources hand back a DIFFERENT + * value. Three flows can do that: + * + * · a dependency connection rotated in place — the same Postgres resource + * issues new credentials, so the connection string changes under a stable + * resource name; + * · a provider param's key re-minted in place — a `ServiceKey`-backed value + * (rpc peer keys, streams API keys) re-issued for the same resource; + * · a generated param re-minted in place — `GeneratedParam` persists its + * value precisely so this does not normally happen, but a deliberate + * rotation of the stored value would not move the fingerprint either. + * + * In each case the new value is written to the env row, but the running + * deployment keeps the value it materialized at create time until something + * else moves the fingerprint (a code change, a rewiring, a config change) or + * the deployment is replaced by hand. + * + * There is no cheap leak-free signal to close this with. The one thing that + * always tracks such a change is the resolved VALUE, and hashing possibly- + * secret resolved values into deploy state is forbidden here — salted or not. + * The obvious non-secret stand-ins are not available either: the value is an + * unresolved alchemy `Output` when this hash is computed (the path handed to + * `Prisma.Deployment` must be a plain resolved string, or upstream's diff + * collapses to a plain update — the same trap the top of this file describes), + * so no attribute of the producing resource, including its own last-changed + * time, can be read at this point. Closing it properly is upstream's + * `redeployOn` seam below: alchemy resolves those inputs itself and diffs them + * inside its own state, where a value that must not enter Composer's deploy + * state is not Composer's to hold. + * + * `prisma-composer dev` has no platform behind it, so no pointer timestamp is + * available; the lookup returns undefined for every name and that part of the + * material is a constant. Nothing is lost: the local Deployment provider + * reconciles unconditionally, so a dev converge restarts the app regardless. + * + * THE SEAM: upstream `Prisma.Deployment` gains `redeployOn` (inputs a + * deployment must be recreated for); when the pinned alchemy version includes + * it, replace this — pass the canonical `artifact.path` verbatim again and put + * this fingerprint on `redeployOn`. The one call site is + * `descriptors/compute.ts`'s deploy hook. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * One environment row's contribution to the fingerprint. + * + * `value` is the row's stored text, for rows that are secret-free by + * construction (a JSON config literal, a pointer row naming a platform + * variable, the input document — whose secret and generated leaves are + * pointers). `withheld` is for every other row: it names what produces the + * value (e.g. the upstream resources of an unresolved reference) and there is + * no field a secret could be passed in, which is the point. + * + * `pointers` lists platform variables the row POINTS at and Composer never + * writes — each contributes its `updatedAt` timestamp so an out-of-band + * rotation forces a redeploy. + */ +export type EnvFingerprintEntry = { + readonly key: string; + readonly pointers?: readonly string[]; +} & ( + | { readonly value: string; readonly withheld?: never } + | { readonly withheld: string; readonly value?: never } +); + +/** The pointed platform variable's `updatedAt`, or undefined when it is unknown (dev, or a name the deploy just provisioned). */ +export type PointerUpdatedAt = (name: string) => string | undefined; + +/** + * The exact text the fingerprint hashes — exported so a test can assert what + * is, and is not, in it. Entries are sorted by key so the row order the + * serializer happens to produce cannot move the fingerprint. + */ +export function deployEnvFingerprintMaterial( + entries: readonly EnvFingerprintEntry[], + pointerUpdatedAt: PointerUpdatedAt, +): string { + const rows = entries + .map((entry) => [ + entry.key, + entry.value !== undefined ? ['value', entry.value] : ['withheld', entry.withheld], + [...(entry.pointers ?? [])].sort().map((name) => [name, pointerUpdatedAt(name) ?? '?']), + ]) + .sort((a, b) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1)); + return JSON.stringify(rows); +} + +/** The environment fingerprint: a sha256 hex digest of `deployEnvFingerprintMaterial`. */ +export function deployEnvFingerprint( + entries: readonly EnvFingerprintEntry[], + pointerUpdatedAt: PointerUpdatedAt, +): string { + return crypto + .createHash('sha256') + .update(deployEnvFingerprintMaterial(entries, pointerUpdatedAt)) + .digest('hex'); +} + +/** How much of the digest names the directory — enough that two environments never collide in practice, short enough to read in a log line. */ +const FINGERPRINT_PATH_LENGTH = 12; + +/** + * Hard-links `artifactPath` into a sibling `deploy-env-` + * directory and returns the link's path: same bytes, a path that moves if and + * only if the environment moved. The canonical path is already + * content-addressed, so a code change moves the parent directory and a + * fingerprint change moves the child — either one is a new path, which is what + * upstream plans a replace on. The empty path + * (`packageComputeArtifact`'s destroy-run placeholder) passes through untouched. + */ +export function fingerprintedArtifactPath(artifactPath: string, fingerprint: string): string { + if (artifactPath === '') return artifactPath; + const dir = path.join( + path.dirname(artifactPath), + `deploy-env-${fingerprint.slice(0, FINGERPRINT_PATH_LENGTH)}`, + ); + fs.mkdirSync(dir, { recursive: true }); + const linked = path.join(dir, path.basename(artifactPath)); + if (!fs.existsSync(linked)) { + try { + fs.linkSync(artifactPath, linked); + } catch { + // A filesystem without hard links still gets the fingerprinted path. + fs.copyFileSync(artifactPath, linked); + } + } + return linked; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts index a2149fca..8ad08fe4 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -28,8 +28,9 @@ * * This edge is the ORDERING half only. Getting a changed environment value * into the running app is the other half, and it is not this edge's job: - * `alwaysRedeployArtifactPath` makes every deploy replace the deployment, so - * the fresh deployment materializes the rows this edge ordered first. + * the environment fingerprint (`deploy-fingerprint.ts`) makes a deploy whose + * environment moved replace the deployment, so the fresh deployment + * materializes the rows this edge ordered first. */ import * as Output from 'alchemy/Output'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 95d7b5ca..ea3708b8 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts @@ -1,4 +1,4 @@ -export * from '../compute/always-redeploy.ts'; export * from '../compute/artifact.ts'; +export * from '../compute/deploy-fingerprint.ts'; export * from '../compute/deployment-edge.ts'; export * from '../compute/ServiceKey.ts'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index c207b19f..ca4b9738 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 @@ -172,10 +172,14 @@ mock.module('@internal/lowering', () => ({ recorded.pkg.push([opts]); return { path: `/tmp/${opts.id}.tar.gz`, sha256: `sha-${opts.id}` }; }, - // The real one hard-links the artifact on disk; a marked pass-through keeps - // the data flow pure while letting deploy assertions pin that the hook - // routes the path through the per-deploy-generation seam. - alwaysRedeployArtifactPath: (artifactPath: string) => `${artifactPath}#per-deploy-generation`, + // The real one hard-links the artifact on disk; a pass-through that appends + // the fingerprint keeps the data flow pure while letting deploy assertions + // pin BOTH that the hook routes the path through the fingerprint seam and + // what it fingerprinted. The real hashing is pinned in @internal/lowering's + // own `deploy-fingerprint.test.ts`, so the stub hashes nothing. + deployEnvFingerprint: (entries: unknown) => JSON.stringify(entries), + fingerprintedArtifactPath: (artifactPath: string, fingerprint: string) => + `${artifactPath}#${fingerprint}`, })); // PgWarm is a real Alchemy Resource (needs the Stack service); stub it so the @@ -671,6 +675,18 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { key: 'COMPOSER_AUTH_ORIGIN', }, ]); + // The same three rows as the deploy hook fingerprints them: the + // service's OWN literal param is config and is hashed as text; the + // dependency input's value is a provisioning ref (a connection string) + // and the provider param's may be a minted key, so both are withheld and + // named by the resources they are built from. Under these mocks a value + // is already resolved, so it has no upstream resources and the name is + // empty; the real Output machinery puts the resource names there. + expect(result.envFingerprint).toEqual([ + { key: 'COMPOSER_AUTH_DB_URL', withheld: 'input.db:' }, + { key: 'COMPOSER_AUTH_PORT', value: '3000' }, + { key: 'COMPOSER_AUTH_ORIGIN', withheld: 'provider.ORIGIN:' }, + ]); // serialize also surfaces the resolved listen port for deploy() — the // Deployment must route to whatever the app binds, not a constant. expect(result.port).toBe(3000); @@ -834,6 +850,16 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); // No serialized EnvironmentVariable output carries the secret's value. expect(JSON.stringify(writes)).not.toContain('sk_live'); + // Neither does what the deploy hook fingerprints: the document is + // hashed as text (it is secret-free by construction) and the platform + // variable it points at is named, so its rotation timestamp can join + // the hash — the VALUE is nowhere near it. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_INGEST_INPUT', + value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', + pointers: ['STRIPE_SECRET_KEY'], + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain('sk_live'); // The row also rides the serialize → deploy handoff, so deploy() can // put the document (secret-free by construction) on the report entity. expect(result.input).toEqual({ @@ -841,6 +867,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { value: '{"stripeEnabled":true,"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', absent: [], generated: [], + // The pointed platform variable, so the deploy hook can fold its + // rotation timestamp into the environment fingerprint. + secrets: ['STRIPE_SECRET_KEY'], }); }, ); @@ -905,6 +934,17 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { expect(result.input?.generated).toEqual([ { varName: 'COMPOSER_INGEST_SECRET_GENERATED', bytes: 48, redacted: true, path: 'secret' }, ]); + // The generated row holds a minted random value, so the deploy hook + // fingerprints it as withheld — and NOT by its platform `updatedAt` + // either: Composer rewrites this row every deploy, so that timestamp + // would move every deploy and the fingerprint would never settle. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_INGEST_SECRET_GENERATED', + withheld: 'generated:48:true', + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain( + 'generated-for-COMPOSER_INGEST_INPUT', + ); }); }); @@ -949,6 +989,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { value: '{}', absent: ['greeting → NOT_SET_GREETING_VAR'], generated: [], + secrets: [], }); }); }); @@ -991,7 +1032,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { const config = { service: { port: envParam('PLATFORM_PORT') }, inputs: {} }; const before = recorded.envVar.length; - run( + const result = run( serviceDescriptorOf(target, 'compute').serialize(ctx, provisioned, config), ); @@ -1005,6 +1046,15 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { }); // No serialized EnvironmentVariable output carries the actual value. expect(JSON.stringify(writes)).not.toContain('8443'); + // The deploy hook fingerprints the pointer row by its text AND by the + // platform variable it names, so rotating PLATFORM_PORT out of band + // ships a new deployment even though the row itself never moves. + expect(result.envFingerprint).toContainEqual({ + key: 'COMPOSER_WEB_PORT', + value: '@composer-param-pointer:PLATFORM_PORT', + pointers: ['PLATFORM_PORT'], + }); + expect(JSON.stringify(result.envFingerprint)).not.toContain('8443'); }, ); }); @@ -1179,6 +1229,9 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { key: 'COMPOSER_AUTH_DB_URL', }, ], + // A dependency-input row: its value is a provisioning ref (a connection + // string), so serialize withholds the text and names what produces it. + envFingerprint: [{ key: 'COMPOSER_AUTH_DB_URL', withheld: 'input.db:db-postgres' }], // A non-default port from serialize must reach the Deployment verbatim. port: 8080, }; @@ -1197,7 +1250,8 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { 'auth-deploy', { app: 'auth-svc#cloud-id', - artifactPath: '/tmp/auth.tar.gz#per-deploy-generation', + artifactPath: + '/tmp/auth.tar.gz#[{"key":"COMPOSER_AUTH_DB_URL","withheld":"input.db:db-postgres"}]', artifactContentType: 'application/gzip', portMapping: { http: 8080 }, start: true, @@ -1939,6 +1993,7 @@ describe("descriptors/compute.ts's provider-param loop is generic over the regis const o: ResolvedCloudOptions = { workspaceId: 'ws_1', providerParams, + pointerUpdatedAt: () => undefined, }; const node = compute({ name: 'multi', deps: {}, build, expose: { any: anyContract } }); const ctx = { 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 f62827c9..e356d256 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 @@ -122,7 +122,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', () => { - test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), teardown.ts (shell token), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env; plus the env the preflight transport is read from, which pointer-timestamps.ts takes as an argument rather than reaching for), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), teardown.ts (shell token), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -135,7 +135,7 @@ describe('invariant 4: environment touches are confined to the config serializer expect(hits.sort((a, b) => a.file.localeCompare(b.file))).toEqual([ { file: 'compute.ts', count: 1 }, { file: 'container.ts', count: 3 }, - { file: 'control/extension.ts', count: 2 }, + { file: 'control/extension.ts', count: 3 }, { file: 'local-target/preflight.ts', count: 2 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 12 }, diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts new file mode 100644 index 00000000..9b47ec78 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts @@ -0,0 +1,192 @@ +/** + * The rotation signal has to survive a PROCESS BOUNDARY: the deploy preflight + * reads a pointed variable's last-written time in the CLI process, and the + * environment fingerprint that decides whether a new deployment ships is built + * in the alchemy child process, which re-imports the app config from scratch. + * + * Every test here runs both halves — the CLI half produces a payload from a + * real `runPreflight` against a fake platform, the framework transport carries + * it as env vars, and the alchemy half rebuilds the lookup from nothing but + * those env vars and fingerprints with it. Injecting a lookup directly (as the + * fingerprint's own tests do) would pass even if nothing were transported at + * all. + * + * The assertions are on the exact text the fingerprint hashes + * (`deployEnvFingerprintMaterial`) rather than on the digest: the digest is a + * pure function of that text (`deployEnvFingerprint` is its sha256, pinned in + * @internal/lowering's own deploy-fingerprint.test.ts), and the digest function + * itself is not callable here — a sibling test file replaces it with a stub + * through `mock.module`, which is process-global in `bun test`. + */ +import { describe, expect, test } from 'bun:test'; +import { Load, module } from '@internal/core'; +import { preflightEnv } from '@internal/core/config'; +import { + deployEnvFingerprintMaterial, + type EnvFingerprintEntry, + type ManagementApiClient, +} from '@internal/lowering'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { PRISMA_CLOUD_EXTENSION_ID, PrismaCloudContainer } from '../container.ts'; +import { + deserializePointerUpdatedAt, + pointerUpdatedAtLookup, + serializePointerUpdatedAt, +} from '../control/pointer-timestamps.ts'; +import { compute } from '../exports/index.ts'; +import { runPreflight } from '../preflight.ts'; +import { envSecret } from '../secret.ts'; + +const build = { + extension: '@prisma/composer/node', + type: 'node', + module: 'file:///test/service.ts', + entry: 'server.js', +}; + +/** Load never validates a binding, so a pass-anything schema is enough here. */ +const anySchema: StandardSchemaV1 = { + '~standard': { version: 1, vendor: 'test', validate: (value) => ({ value }) }, +}; + +const graph = () => + Load( + module('app', ({ provision }) => { + provision(compute({ name: 'ingest', deps: {}, input: anySchema, build }), { + id: 'ingest', + input: { stripeKey: envSecret('STRIPE_SECRET_KEY') }, + }); + }), + ); + +/** A platform holding STRIPE_SECRET_KEY, last written at `updatedAt`. Its VALUE is never returned — the API returns none. */ +const fakePlatform = (updatedAt: string): ManagementApiClient => + ({ + GET: async () => ({ + data: { + data: [{ branchId: null, updatedAt }], + pagination: { nextCursor: null, hasMore: false }, + }, + error: undefined, + response: new Response(null, { status: 200 }), + }), + POST: async () => { + throw new Error('this fake platform already has every name; nothing should be created'); + }, + }) as unknown as ManagementApiClient; + +/** The service's rows as the deploy hook fingerprints them — the input document points at the rotating secret. */ +const envRows: readonly EnvFingerprintEntry[] = [ + { key: 'COMPOSER_INGEST_PORT', value: '3000' }, + { + key: 'COMPOSER_INGEST_INPUT', + value: '{"stripeKey":{"$secret":"STRIPE_SECRET_KEY"}}', + pointers: ['STRIPE_SECRET_KEY'], + }, +]; + +/** The CLI process: run the real preflight against a platform that last wrote the secret at `updatedAt`, and hand its findings to the framework transport. */ +async function cliProcess(updatedAt: string): Promise> { + const timestamps = await runPreflight( + { + graph: graph(), + container: new PrismaCloudContainer({ appName: 'app', stage: undefined }, 'proj', undefined), + stage: undefined, + }, + { client: fakePlatform(updatedAt) }, + ); + const payload = serializePointerUpdatedAt(timestamps); + return preflightEnv( + payload === undefined ? new Map() : new Map([[PRISMA_CLOUD_EXTENSION_ID, payload]]), + ); +} + +/** + * The alchemy process: it never ran a preflight, so its own map is empty and + * everything it knows comes from the transported env — exactly the state a + * fresh `prismaCloud()` is in there. Returns the text the deployment's + * fingerprint is the hash of. + */ +function alchemyProcessMaterial(env: Record): string { + return deployEnvFingerprintMaterial(envRows, pointerUpdatedAtLookup(new Map(), env)); +} + +describe('the rotation signal across the CLI → alchemy process boundary', () => { + test('a secret rotated on the platform moves the fingerprint in the alchemy process', async () => { + const before = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + const after = alchemyProcessMaterial(await cliProcess('2026-07-07T09:15:00.000Z')); + + expect(after).not.toBe(before); + }); + + test('an unchanged secret leaves it standing still — the deployment is reused', async () => { + const first = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + const second = alchemyProcessMaterial(await cliProcess('2026-05-05T12:00:00.000Z')); + + expect(second).toBe(first); + }); + + test('what the transport carries is the timestamp preflight read, under the pointed name', async () => { + const env = await cliProcess('2026-05-05T12:00:00.000Z'); + + expect(env).toEqual({ + PRISMA_COMPOSER_PREFLIGHT_PRISMA_COMPOSER_PRISMA_CLOUD: + '{"STRIPE_SECRET_KEY":"2026-05-05T12:00:00.000Z"}', + }); + }); + + test('without the transport the alchemy process learns nothing — the fingerprint cannot move', async () => { + // What the child saw before the timestamps were transported at all: every + // name unknown, so a rotation is invisible. This is the failure the + // transport exists to prevent. + const untransported = alchemyProcessMaterial({}); + const rotated = alchemyProcessMaterial(await cliProcess('2026-07-07T09:15:00.000Z')); + + expect(alchemyProcessMaterial({})).toBe(untransported); + expect(rotated).not.toBe(untransported); + }); +}); + +describe('what the payload may contain', () => { + test('timestamps only — a name, an ISO time, and nothing else', () => { + expect( + serializePointerUpdatedAt(new Map([['STRIPE_SECRET_KEY', '2026-05-05T12:00:00.000Z']])), + ).toBe('{"STRIPE_SECRET_KEY":"2026-05-05T12:00:00.000Z"}'); + }); + + test('the same times in a different order serialize identically — sorted by name', () => { + const one = new Map([ + ['A_KEY', '2026-01-01T00:00:00.000Z'], + ['B_KEY', '2026-02-02T00:00:00.000Z'], + ]); + const other = new Map([...one].reverse()); + + expect(serializePointerUpdatedAt(other)).toBe(serializePointerUpdatedAt(one)); + }); + + test('a deploy with no pointed variable carries no payload at all', () => { + expect(serializePointerUpdatedAt(new Map())).toBeUndefined(); + }); + + test('a round trip preserves every name', () => { + const timestamps = new Map([ + ['A_KEY', '2026-01-01T00:00:00.000Z'], + ['B_KEY', '2026-02-02T00:00:00.000Z'], + ]); + const payload = serializePointerUpdatedAt(timestamps); + + expect([...deserializePointerUpdatedAt(payload)]).toEqual([...timestamps]); + }); + + test('an absent payload reads as an empty map — dev, and any run with nothing to carry', () => { + expect(deserializePointerUpdatedAt(undefined).size).toBe(0); + }); + + test('a payload that is present but unreadable fails loudly rather than losing the signal', () => { + expect(() => deserializePointerUpdatedAt('not json')).toThrow(/did not survive the transport/); + expect(() => deserializePointerUpdatedAt('"a string"')).toThrow(/not a JSON object/); + expect(() => deserializePointerUpdatedAt('{"A_KEY":42}')).toThrow( + /"A_KEY" is not a string timestamp/, + ); + }); +}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts index 798ce384..a1f4adc9 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/preflight.test.ts @@ -24,8 +24,16 @@ interface Row { class: 'production' | 'preview'; key: string; branchId: string | null; + /** Defaulted by the fake client — only the rotation tests below care what it is. */ + updatedAt?: string; } +/** What a row the test did not date reads as. */ +const DEFAULT_UPDATED_AT = '2026-01-01T00:00:00.000Z'; + +/** What the fake platform stamps on a row preflight creates from the deploy shell. */ +const CREATED_UPDATED_AT = '2026-03-03T00:00:00.000Z'; + interface FakeState { gets: Record[]; posts: Record[]; @@ -43,7 +51,10 @@ const fakeClient = (state: FakeState): ManagementApiClient => (r) => r.projectId === q['projectId'] && r.class === q['class'] && r.key === q['key'], ); return { - data: { data: rows, pagination: { nextCursor: null, hasMore: false } }, + data: { + data: rows.map((r) => ({ ...r, updatedAt: r.updatedAt ?? DEFAULT_UPDATED_AT })), + pagination: { nextCursor: null, hasMore: false }, + }, error: undefined, response: new Response(null, { status: 200 }), }; @@ -58,7 +69,9 @@ const fakeClient = (state: FakeState): ManagementApiClient => }; } return { - data: { data: { id: 'ev-new', key: init.body['key'] } }, + data: { + data: { id: 'ev-new', key: init.body['key'], updatedAt: CREATED_UPDATED_AT }, + }, error: undefined, response: new Response(null, { status: 201 }), }; @@ -432,4 +445,130 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { expect(state.gets).toEqual([]); expect(state.posts).toEqual([]); }); + + // Preflight is the one deploy step that reads these rows off the platform, so + // it is where their last-written times come from. The compute deploy hook + // folds them into its environment fingerprint, which is how rotating a secret + // out of band ships a new deployment. + describe('the rotation timestamps it hands back', () => { + test('a name present on the platform reports when it was last written', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'production', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ); + + expect([...timestamps]).toEqual([['STRIPE_SECRET_KEY', '2026-05-05T12:00:00.000Z']]); + }); + + test('with a template and a branch override in scope, the NEWER row wins', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: 'br-1', + updatedAt: '2026-07-07T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'feature' }, + { client: fakeClient(state) }, + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe('2026-07-07T12:00:00.000Z'); + }); + + test('a row belonging to ANOTHER branch is not in scope and does not date this one', async () => { + state.rows = [ + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: null, + updatedAt: '2026-05-05T12:00:00.000Z', + }, + { + projectId: 'proj', + class: 'preview', + key: 'STRIPE_SECRET_KEY', + branchId: 'br-other', + updatedAt: '2026-09-09T12:00:00.000Z', + }, + ]; + + const timestamps = await runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', 'br-1'), stage: 'feature' }, + { client: fakeClient(state) }, + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe('2026-05-05T12:00:00.000Z'); + }); + + test('a name preflight fills from the shell reports the created row\u2019s time', async () => { + state.rows = []; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(timestamps.get('STRIPE_SECRET_KEY')).toBe(CREATED_UPDATED_AT); + }); + + test('a fill that lost the race (409) reports no time, so the next deploy redeploys once', async () => { + state.rows = []; + state.postStatus = 409; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_fill' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(timestamps.has('STRIPE_SECRET_KEY')).toBe(false); + }); + + test('a graph with nothing to check hands back an empty map', async () => { + const timestamps = await runPreflight( + { graph: noSecretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ); + + expect(timestamps.size).toBe(0); + }); + + test('no VALUE is ever handed back — the API returns none and preflight asks for none', async () => { + state.rows = []; + + const timestamps = await withEnv({ STRIPE_SECRET_KEY: 'sk_live_sentinel' }, () => + runPreflight( + { graph: secretGraph(), container: fakeContainer('proj', undefined), stage: undefined }, + { client: fakeClient(state) }, + ), + ); + + expect(JSON.stringify([...timestamps])).not.toContain('sk_live_sentinel'); + }); + }); }); 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 8cf18e96..429dfd4f 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 @@ -41,6 +41,7 @@ import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; import type { ProviderParamEntry } from '../serializer.ts'; import { STREAMS_API_KEY } from '../streams-keys.ts'; import { runTeardown } from '../teardown.ts'; +import { pointerUpdatedAtLookup, serializePointerUpdatedAt } from './pointer-timestamps.ts'; /** * ADR-0031's registered provisioner for RPC_PEER_KEY: mints one `ServiceKey` @@ -283,7 +284,7 @@ export const PROVIDER_PARAMS: ReadonlyMap { const workspaceId = opts.workspaceId ?? process.env['PRISMA_WORKSPACE_ID'] ?? ''; if (opts.region !== undefined) { @@ -311,17 +312,31 @@ function resolveOptions(opts: PrismaCloudOptions): ResolvedCloudOptions { * environment present, since it also builds the `localTarget` descriptor, which must * never require `PRISMA_WORKSPACE_ID`/`PRISMA_REGION`/`PRISMA_SERVICE_TOKEN`. */ -function lazyOptions(opts: PrismaCloudOptions): () => ResolvedCloudOptions { +function lazyOptions( + opts: PrismaCloudOptions, + pointerUpdatedAt: Prisma.PointerUpdatedAt, +): () => ResolvedCloudOptions { let cached: ResolvedCloudOptions | undefined; return () => { - cached ??= resolveOptions(opts); + cached ??= { ...resolveOptions(opts), pointerUpdatedAt }; return cached; }; } /** The Prisma Cloud extension descriptor — `prisma-composer.config.ts` lists it under `extensions`. */ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor => { - const o = lazyOptions(opts); + // When each platform variable a Composer row POINTS at was last written — + // filled by the deploy preflight below (the one step that reads those rows), + // read by the compute descriptor's environment fingerprint. Held in this + // factory's closure rather than a module variable so two `prismaCloud()` + // extensions in one process cannot see each other's, and left empty by + // `prisma-composer dev`, which runs the local preflight instead. + // + // In the ALCHEMY process this map is always empty — that process re-imports + // the config from scratch and runs no preflight — so the lookup falls back + // to what the CLI process transported (pointer-timestamps.ts). + const preflightTimestamps = new Map(); + const o = lazyOptions(opts, pointerUpdatedAtLookup(preflightTimestamps, process.env)); return { id: PRISMA_CLOUD_EXTENSION_ID, @@ -343,7 +358,14 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // Deploy-time prerequisite check (ADR-0029): verify every pointer secret in // the provision manifest exists for the resolved stage, filling absent-but- // in-shell names via a direct API POST — before any stack file or Alchemy. - preflight: (input) => runPreflight(input), + // The timestamps it reads are kept for this process AND handed to the + // framework's preflight transport, which is how they reach the alchemy + // process that actually builds the fingerprint (pointer-timestamps.ts). + preflight: (input) => + runPreflight(input).then((timestamps) => { + for (const [name, updatedAt] of timestamps) preflightTimestamps.set(name, updatedAt); + return serializePointerUpdatedAt(timestamps); + }), // Destroy-time cleanup (ADR-0034): remove the stage's deploy-state // database, once alchemy destroy has finished reading it and before the diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts new file mode 100644 index 00000000..6b0594c5 --- /dev/null +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/pointer-timestamps.ts @@ -0,0 +1,90 @@ +/** + * When each platform variable a Composer row POINTS at was last written, from + * the CLI process to the alchemy process. + * + * The deploy preflight reads those times off the platform, and the compute + * deploy hook folds them into its environment fingerprint so that rotating a + * secret out of band ships a new deployment. Those two steps run in DIFFERENT + * PROCESSES: preflight runs in the CLI parent, then the CLI spawns alchemy + * against the generated stack file, which re-imports the app config from + * scratch and so calls `prismaCloud()` again with none of the parent's state. + * Without this transport every name would read as unknown in the alchemy + * process and an out-of-band rotation would never move the fingerprint. + * + * The payload rides the framework's preflight transport (one env var per + * extension, the same channel resolved containers use). It carries ISO + * TIMESTAMPS ONLY — the Management API never returns an env-var value, and the + * alchemy child's environment is not a place to put one. + */ +import { readPreflightPayload } from '@internal/core/config'; +import type { PointerUpdatedAt } from '@internal/lowering'; +import { PRISMA_CLOUD_EXTENSION_ID } from '../container.ts'; + +/** The CLI-process side: what `preflight` hands the framework, or undefined when the deploy read no pointed variable at all. */ +export function serializePointerUpdatedAt( + timestamps: ReadonlyMap, +): string | undefined { + if (timestamps.size === 0) return undefined; + const sorted = [...timestamps].sort(([a], [b]) => (a < b ? -1 : 1)); + return JSON.stringify(Object.fromEntries(sorted)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The alchemy-process side: the timestamps the CLI process transported. An + * absent payload is the normal case for `prisma-composer dev` and for any run + * with no pointed variables, and reads as an empty map; a payload that is + * present but unreadable is a framework bug and throws rather than silently + * costing the deploy its rotation signal. + */ +export function deserializePointerUpdatedAt( + payload: string | undefined, +): ReadonlyMap { + const timestamps = new Map(); + if (payload === undefined) return timestamps; + + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch (error) { + throw payloadError(error instanceof Error ? error.message : String(error)); + } + if (!isRecord(parsed)) throw payloadError('it is not a JSON object'); + for (const [name, updatedAt] of Object.entries(parsed)) { + if (typeof updatedAt !== 'string') throw payloadError(`"${name}" is not a string timestamp`); + timestamps.set(name, updatedAt); + } + return timestamps; +} + +const payloadError = (reason: string): Error => + new Error( + "prisma-cloud: the deploy preflight's rotation timestamps did not survive the transport " + + `into the alchemy process — ${reason}. This is a framework bug; re-running the deploy ` + + 'will not fix it.', + ); + +/** + * The pointer lookup the node descriptors close over: `own` — filled by + * `preflight` — in the CLI process, and the transported payload in the alchemy + * process, where `own` is empty because that process never runs a preflight. + * A name in neither reads as unknown, which is every name under + * `prisma-composer dev`. + */ +export function pointerUpdatedAtLookup( + own: ReadonlyMap, + env: Readonly>, +): PointerUpdatedAt { + let transported: ReadonlyMap | undefined; + return (name) => { + const mine = own.get(name); + if (mine !== undefined) return mine; + transported ??= deserializePointerUpdatedAt( + readPreflightPayload(PRISMA_CLOUD_EXTENSION_ID, env), + ); + return transported.get(name); + }; +} 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 e311183f..c17317da 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 @@ -3,8 +3,10 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; import { - alwaysRedeployArtifactPath, appAfterEnvironment, + deployEnvFingerprint, + type EnvFingerprintEntry, + fingerprintedArtifactPath, packageComputeArtifact, } from '@internal/lowering'; import * as Output from 'alchemy/Output'; @@ -16,9 +18,11 @@ import { paramBindingFor, paramName } from '../param.ts'; import { provisionedEdges } from '../provisioned-edges.ts'; import { configKey, + decodeParamPointer, encode, encodeParamPointer, type InputDocumentRow, + isParamPointerRow, paramEntries, serializeInput, } from '../serializer.ts'; @@ -49,9 +53,11 @@ export interface ComputeProvisioned { readonly endpointDomain: Output.Output; } -/** compute's serialize → deploy handoff: the env-var rows deploy must depend on, the resolved port it routes to, and the serialized input document (when the service declares one) for the deploy report. */ +/** compute's serialize → deploy handoff: the env-var rows deploy must depend on, one fingerprint entry per row, the resolved port it routes to, and the serialized input document (when the service declares one) for the deploy report. */ export interface ComputeSerialized { readonly environment: readonly Prisma.EnvironmentVariable[]; + /** What the deploy hook fingerprints the environment by — one entry per row of `environment`, in the same order. */ + readonly envFingerprint: readonly EnvFingerprintEntry[]; readonly port: number; readonly input?: InputDocumentRow; } @@ -68,6 +74,17 @@ const envValue = ( ): Redacted.Redacted | Output.Output> => Output.isOutput(value) ? Output.map(value, Redacted.make) : Redacted.make(value); +/** + * The fingerprint stand-in for a row whose text this descriptor must NOT hash: + * `kind` says which channel the row came from, and the sorted names of the + * resources the value is built from say what produces it, so rewiring the row + * to a different resource moves the fingerprint. `Output.upstreamAny` is the + * same walker Alchemy builds its dependency graph with, so the names are the + * planner's own — no guessing at what a reference points to. + */ +const withheldSource = (kind: string, value: unknown): string => + `${kind}:${Object.keys(Output.upstreamAny(value)).sort().join(',')}`; + /** * Returns the PRECISE descriptor type, not the erased `NodeDescriptor`: the * registry in control.ts erases it on assignment anyway (method bivariance), @@ -109,6 +126,11 @@ export function computeDescriptor( const projectId = provisioned.projectId; const svc = node as ServiceNode; const records = []; + // One entry per row, appended in step with `records`. Every entry that + // carries text carries text that is secret-free BY CONSTRUCTION; the + // rest are `withheld` and have nowhere to put a value — see + // `deploy-fingerprint.ts`. + const fingerprint: EnvFingerprintEntry[] = []; for (const d of paramEntries(svc)) { const value = @@ -135,6 +157,23 @@ export function computeDescriptor( ...branch, }), ); + // A service's OWN param is config, never a secret (a secret reaches + // a service only through the input document, ADR-0042), so its row + // text is hashed as-is — a literal is JSON, a pointer row is the + // platform NAME it points at, and that name's rotation timestamp + // joins the fingerprint through `pointers`. A dependency input's + // value is a provisioning ref: a connection string or a minted + // per-binding token, so its text is withheld. + if (d.owner === 'service') { + const pointer = isParamPointerRow(rowValue) ? decodeParamPointer(rowValue) : undefined; + fingerprint.push({ + key, + value: rowValue, + ...(pointer !== undefined ? { pointers: [pointer] } : {}), + }); + } else { + fingerprint.push({ key, withheld: withheldSource(`input.${d.owner.input}`, value) }); + } } const inputRow = serializeInput( @@ -155,6 +194,15 @@ export function computeDescriptor( ...branch, }), ); + // The document itself is secret-free by construction, so it is + // hashed verbatim; each `$secret` pointer names an OPERATOR-owned + // platform variable Composer never writes, so its rotation shows up + // only as that variable's `updatedAt`. + fingerprint.push({ + key: inputRow.key, + value: inputRow.value, + pointers: inputRow.secrets, + }); // Each generated leaf: generate its value ONCE (the resource keeps it // stable across redeploys via its persisted output) and provision it // under the framework var the document's `$generated` pointer names. @@ -173,6 +221,16 @@ export function computeDescriptor( ...branch, }), ); + // A minted random value — withheld. Its `updatedAt` is no signal + // either: Composer writes this row on every deploy, so the + // timestamp would move every deploy. The value is stable by + // construction (`GeneratedParam` persists it), and the document's + // `$generated` pointer — already hashed above — carries the leaf's + // name and its redacted facet. + fingerprint.push({ + key: leaf.varName, + withheld: `generated:${String(leaf.bytes)}:${String(leaf.redacted)}`, + }); } } @@ -238,6 +296,12 @@ export function computeDescriptor( ...branch, }), ); + // A provider param's value may be a minted key (rpc, streams), so + // every one is withheld regardless of the brand — this descriptor is + // brand-blind and must not have to know which brands mint secrets. + // Wiring a consumer in or out changes the resources the value is + // built from, which is what moves the fingerprint. + fingerprint.push({ key, withheld: withheldSource(`provider.${entry.name}`, raw) }); } // Carries the resolved port to deploy(); falls back to 3000 if unset. @@ -246,6 +310,7 @@ export function computeDescriptor( const port = typeof config.service['port'] === 'number' ? config.service['port'] : 3000; return { environment: records, + envFingerprint: fingerprint, port, ...(inputRow !== undefined ? { input: inputRow } : {}), }; @@ -265,16 +330,25 @@ export function computeDescriptor( deploy: ({ id }, provisioned, artifact, serialized) => Effect.gen(function* () { + // Answers "unknown" for every name under `prisma-composer dev`: dev runs + // no platform preflight, so no rotation timestamps exist. That costs + // nothing — the local Deployment provider reconciles unconditionally. + const pointerUpdatedAt = o().pointerUpdatedAt; const deployment = yield* Prisma.Deployment(`${id}-deploy`, { // `app` carries the ordering edge on serialize's variable writes as // well as the app id — see `appAfterEnvironment` for why it is the // only prop that can (PRO-211). app: appAfterEnvironment(provisioned.serviceId, serialized.environment), - // A fresh per-deploy-run path for the SAME bytes, so upstream plans - // a replace on every deploy and a changed environment value always - // reaches the running app — see `alwaysRedeployArtifactPath` for the - // mechanism, its cost, and the hand-off to upstream's `redeployOn`. - artifactPath: alwaysRedeployArtifactPath(artifact.path), + // The SAME bytes under a path named by a hash of this service's + // environment, so upstream plans a replace exactly when the + // environment (or the code) moved and reuses the deployment + // otherwise — see `deploy-fingerprint.ts` for what the hash covers, + // why no secret reaches it, and the hand-off to upstream's + // `redeployOn`. + artifactPath: fingerprintedArtifactPath( + artifact.path, + deployEnvFingerprint(serialized.envFingerprint, pointerUpdatedAt), + ), // The artifact IS a gzipped tar (see @internal/lowering's packager); // upstream sends this as the upload's Content-Type and folds it into // the fingerprint that decides whether a new deployment is needed. 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 823e8f5c..395ff1c6 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 @@ -1,5 +1,6 @@ /** Helpers shared by the per-node-kind descriptors under `src/descriptors/` and the extension factory in `control.ts`. */ +import type { PointerUpdatedAt } from '@internal/lowering'; import type * as Output from 'alchemy/Output'; import type * as Prisma from 'alchemy/Prisma'; import type { ProviderParamEntry } from '../serializer.ts'; @@ -74,6 +75,16 @@ export interface ResolvedCloudOptions { * place a brand is named). */ readonly providerParams: ReadonlyMap; + /** + * When a platform variable a row POINTS at was last written, by name — the + * out-of-band rotation signal the compute deploy hook folds into its + * environment fingerprint. The deploy preflight supplies the times (it + * already reads exactly these names off the platform) and transports them to + * the alchemy process. Always present: a run with no times to offer — every + * `prisma-composer dev` run, which talks to no platform — supplies a lookup + * that answers "unknown" for every name, so no caller has to. + */ + readonly pointerUpdatedAt: PointerUpdatedAt; } /** Where a resource lands when the deploy names no region. */ diff --git a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts index 28230e1f..50040034 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/preflight.ts @@ -12,6 +12,13 @@ * Control-plane only (imported by control.ts → prisma-composer.config.ts); runs * in the CLI parent, so it builds its own Management API client from env — the * same credential path `container.ts`'s `ensure`/`locate` use. + * + * It also returns WHEN each of those names was last written. This is the only + * place in a deploy that reads those rows off the platform, so it is where the + * reading belongs; the compute deploy hook folds the timestamps into its + * environment fingerprint so that rotating a secret or an env-sourced param + * out of band ships a new deployment. A timestamp, never a value: env-var + * values are write-only and the API never returns one. */ import type { Graph } from '@internal/core'; import type { PreflightInput } from '@internal/core/config'; @@ -35,15 +42,12 @@ type EnvClass = 'production' | 'preview'; const classFor = (branchId: string | undefined): EnvClass => branchId === undefined ? 'production' : 'preview'; -/** - * Does `key` exist for the target stage's scope? Default stage → any - * production-class template. Named stage → a preview template (branchId null) - * OR this branch's own override — the platform's preview materialization - * (pdp-data-model.md). Metadata read only; env-var values are write-only. - */ /** The fields of one env-var list page that preflight consumes (metadata only; values are write-only). */ interface EnvVarListPage { - readonly data: readonly { readonly branchId: string | null }[]; + readonly data: readonly { + readonly branchId: string | null; + readonly updatedAt: string; + }[]; readonly pagination: { readonly nextCursor: string | null; readonly hasMore: boolean }; } interface EnvVarListResult { @@ -75,22 +79,39 @@ async function listEnvVars( >(res); } -async function existsOnPlatform( +/** What the platform holds for one name: whether it is there at all, and when it last changed. */ +interface PlatformVariable { + readonly exists: boolean; + /** The latest `updatedAt` across every row visible to this stage — undefined when the name is absent. */ + readonly updatedAt?: string; +} + +/** + * What the platform holds for `key` in the target stage's scope. Default stage + * → any production-class template. Named stage → a preview template (branchId + * null) OR this branch's own override — the platform's preview materialization + * (pdp-data-model.md). Metadata read only; env-var values are write-only. + * + * The whole list is walked rather than short-circuiting on the first visible + * row, because the newest `updatedAt` across every visible row is the rotation + * signal the compute deploy hook fingerprints on: stopping early would make + * that timestamp depend on where the page boundary happened to fall, and a + * fingerprint that moves for that reason would redeploy for no reason. A key + * with more rows than one page (a template plus many per-branch overrides) is + * rare, so this costs one request in practice. + */ +async function readPlatformVariable( client: ManagementApiClient, projectId: string, branchId: string | undefined, key: string, -): Promise { +): Promise { const cls = classFor(branchId); - // Default stage → any production template counts; named stage → a preview - // template (branchId null) OR this branch's own override. const visible = (row: { branchId: string | null }): boolean => branchId === undefined || row.branchId === null || row.branchId === branchId; - // The list is paginated: a key with more preview rows (template + many - // per-branch overrides) than one page must be followed to the end, or a - // present name is falsely reported missing. Short-circuit as soon as a - // visible row is seen. + let exists = false; + let latest: string | undefined; let cursor: string | null = null; do { const res = await listEnvVars( @@ -99,11 +120,15 @@ async function existsOnPlatform( ); if (res.error !== undefined) throw listFailedError(key, res.error); const page = res.data; - if (page === undefined) return false; - if (page.data.some(visible)) return true; + if (page === undefined) break; + for (const row of page.data) { + if (!visible(row)) continue; + exists = true; + if (latest === undefined || row.updatedAt > latest) latest = row.updatedAt; + } cursor = page.pagination.hasMore ? page.pagination.nextCursor : null; } while (cursor !== null); - return false; + return latest === undefined ? { exists } : { exists, updatedAt: latest }; } /** @@ -120,7 +145,7 @@ async function fillMissing( branchId: string | undefined, key: string, value: string, -): Promise { +): Promise { const res = await client.POST('/v1/environment-variables', { body: { projectId, @@ -133,6 +158,11 @@ async function fillMissing( if (res.error !== undefined && res.response.status !== 409) { throw fillFailedError(key, res.error); } + // The created row's timestamp, so this deploy fingerprints on the same value + // the next one will read back. A 409 (a concurrent deploy won the race) + // returns no row: the name reads as unknown for this run and the next deploy + // picks its timestamp up, which redeploys once — the safe direction. + return res.data?.data.updatedAt; } interface MissingBinding { @@ -197,7 +227,7 @@ async function managementClient(): Promise { export async function runPreflight( input: PreflightInput, deps?: { readonly client?: ManagementApiClient }, -): Promise { +): Promise> { const { projectId, branchId } = prismaCloudContainerOf(input.container); // One check per platform NAME (many leaves/services, secret or param, may @@ -211,20 +241,27 @@ export async function runPreflight( for (const meta of [...collected.secrets, ...collected.envParams]) { if (!names.has(meta.name)) names.set(meta.name, meta); } - if (names.size === 0) return; + if (names.size === 0) return new Map(); const client = deps?.client ?? (await managementClient()); const missing: MissingBinding[] = []; + const updatedAt = new Map(); for (const meta of names.values()) { - if (await existsOnPlatform(client, projectId, branchId, meta.name)) continue; + const platform = await readPlatformVariable(client, projectId, branchId, meta.name); + if (platform.exists) { + if (platform.updatedAt !== undefined) updatedAt.set(meta.name, platform.updatedAt); + continue; + } const shellValue = process.env[meta.name]; if (shellValue !== undefined && shellValue.length > 0) { - await fillMissing(client, projectId, branchId, meta.name, shellValue); + const filled = await fillMissing(client, projectId, branchId, meta.name, shellValue); + if (filled !== undefined) updatedAt.set(meta.name, filled); continue; } missing.push(meta); } if (missing.length > 0) throw missingError(missing, branchId, input.stage); + return updatedAt; } /** diff --git a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts index 91fd8256..2c7ce3b1 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/serializer.ts @@ -462,6 +462,13 @@ export interface InputDocumentRow { readonly absent: readonly string[]; /** Generated leaves the descriptor must provision (a `GeneratedParam` resource + env row each). */ readonly generated: readonly GeneratedLeaf[]; + /** + * The platform variable each `$secret` pointer in the document names — + * operator-provisioned, never written by Composer. The deploy hook folds + * each one's `updatedAt` into the environment fingerprint, so rotating a + * secret out of band ships a new deployment. + */ + readonly secrets: readonly string[]; } /** @@ -508,7 +515,13 @@ export function serializeInput( ); } const document = substitutePointers(validated, sentinels, generated, address); - return { key: inputKey(address), value: JSON.stringify(document), absent, generated }; + return { + key: inputKey(address), + value: JSON.stringify(document), + absent, + generated, + secrets: [...sentinels.values()], + }; } /** From 394903416617997eecb322953460a3b44e25313d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 08:03:25 +0200 Subject: [PATCH 19/22] refactor(local-target): one implementation of the shared upstream-attribute helpers The compute and postgres provider families each carried their own byte-identical DEV_TIMESTAMP, isRecord and projectIdOfInput. They now import them from src/upstream-attributes.ts, so the 'local' project fallback and the stamped timestamp cannot drift between the two. Signed-off-by: willbot Signed-off-by: Will Madden --- .../0-lowering/local-target/src/compute.ts | 14 +------------ .../0-lowering/local-target/src/postgres.ts | 14 +------------ .../local-target/src/upstream-attributes.ts | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 26 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts index 0a0e7015..a77f6857 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/compute.ts @@ -26,19 +26,7 @@ import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; import { extractComputeArtifact } from './artifact-extract.ts'; import { envStore, secretsStore } from './dev-store.ts'; - -const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ -function projectIdOfInput(value: unknown): string { - if (typeof value === 'string') return value; - if (isRecord(value) && typeof value['projectId'] === 'string') return value['projectId']; - return 'local'; -} +import { DEV_TIMESTAMP, isRecord, projectIdOfInput } from './upstream-attributes.ts'; /** Reads an app id from upstream's `app` input: a plain string or a resolved `Prisma.App` attributes record. */ function appIdOfInput(value: unknown): string { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts index 3eb6ff33..24671ed0 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/postgres.ts @@ -37,19 +37,7 @@ import * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; import { appNameOf } from './app-name.ts'; - -const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ -function projectIdOfInput(value: unknown): string { - if (typeof value === 'string') return value; - if (isRecord(value) && typeof value['projectId'] === 'string') return value['projectId']; - return 'local'; -} +import { DEV_TIMESTAMP, isRecord, projectIdOfInput } from './upstream-attributes.ts'; /** Reads a database id from upstream's `database` input: a plain string or a resolved `Prisma.Database` attributes record. */ function databaseIdOfInput(value: unknown): string | undefined { diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts new file mode 100644 index 00000000..07786f44 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts @@ -0,0 +1,21 @@ +/** + * Shared pieces of the upstream-attribute shapes every local provider emits. + * The compute and postgres provider families both fill upstream alchemy's + * attribute records, so the timestamp they stamp and the way they read a + * `project` reference live here — one implementation, so the two families + * cannot drift on the `'local'` project fallback or on the timestamp. + */ + +/** The fixed `createdAt`/`updatedAt` local providers stamp: local dev has no meaningful creation time. */ +export const DEV_TIMESTAMP = '1970-01-01T00:00:00.000Z'; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Reads a project id from upstream's `project` input: a plain string or a resolved `Prisma.Project` attributes record. */ +export function projectIdOfInput(value: unknown): string { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['projectId'] === 'string') return value['projectId']; + return 'local'; +} From 672466718a82da533c3597b0c7dcc77e2864debf Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 4 Aug 2026 08:05:48 +0200 Subject: [PATCH 20/22] fix(lowering): retire only LEGACY poison rows, decided by the props shape Upstream's own EnvironmentVariable rows carry the same type-id as the legacy ones, so a key match alone would retire a live, upstream-managed DATABASE_URL variable from state on every read. poisonKeyOf now applies the same props-shape discriminator the rest of the module uses. Signed-off-by: willbot Signed-off-by: Will Madden --- .../state/__tests__/legacy-resources.test.ts | 35 +++++++++++++++++++ .../lowering/src/state/legacy-resources.ts | 9 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts index 674f41e7..f37c6336 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -698,6 +698,41 @@ describe('legacy compute-family rows against upstream providers', () => { ); }); + test('an UPSTREAM-shaped DATABASE_URL row is left alone: the props shape, not the key, decides', () => { + // Upstream's own EnvironmentVariable rows carry the same type-id as the + // legacy ones, so only the props shape tells them apart. A live variable + // upstream manages must survive every state read untouched — retiring it + // would drop a real resource from state on each deploy. + const upstreamRow: CreatedResourceState = { + ...legacyEnvRow('DATABASE_URL'), + props: { + project: 'proj-1', + key: 'DATABASE_URL', + class: 'production', + value: Redacted.make('postgres://live'), + }, + attr: { + environmentVariableId: 'var-9', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'DATABASE_URL', + isManagedBySystem: false, + }, + } as CreatedResourceState; + const migrated = migrateLegacyResourceState(upstreamRow) as MigratedRow; + expect(migrated['removalPolicy']).toBeUndefined(); + expect(migrated.attr).toEqual({ + environmentVariableId: 'var-9', + projectId: 'proj-1', + branchId: null, + class: 'production', + key: 'DATABASE_URL', + isManagedBySystem: false, + }); + expect(migrated).toEqual(upstreamRow as unknown as MigratedRow); + }); + test('maps the unreleased PrismaComposer.* compute type-ids too', () => { const composerEra = { ...legacyAppRow('branch_1'), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts index 69cb9db8..56583195 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -207,9 +207,15 @@ const retirePoisonRow = (row: Record, key: string) => ({ attr: { environmentVariableId: `dev:legacy-poison-${key}`, key }, }); -/** The key a poison row named, from whichever half of the row still carries it. */ +/** + * The key a poison row named, from whichever half of the row still carries it + * — for LEGACY rows only. Upstream's own `EnvironmentVariable` rows share this + * type-id, so without the props-shape check a live, upstream-managed + * `DATABASE_URL` variable would be retired from state on every read. + */ const poisonKeyOf = (family: Family, props: unknown, attr: unknown): string | undefined => { if (family !== 'EnvironmentVariable') return undefined; + if (!isRecord(props) || !isLegacyProps(family, props)) return undefined; const fromAttr = isRecord(attr) ? attr['key'] : undefined; const fromProps = isRecord(props) ? props['key'] : undefined; const key = typeof fromAttr === 'string' ? fromAttr : fromProps; @@ -344,6 +350,7 @@ const migrateResourceRow = (row: Record): Record Date: Tue, 4 Aug 2026 08:06:01 +0200 Subject: [PATCH 21/22] fix(lowering): migrate a replaced poison row's old generation before retiring it The retirement returned early, so the displaced generation nested under `old` kept its legacy shape and reached the engine unmigrated. The old chain is rewritten first; the retirement now happens on the fully migrated row. Signed-off-by: willbot Signed-off-by: Will Madden --- .../state/__tests__/legacy-resources.test.ts | 31 +++++++++++++++++++ .../lowering/src/state/legacy-resources.ts | 9 ++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts index f37c6336..8fab8080 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -733,6 +733,37 @@ describe('legacy compute-family rows against upstream providers', () => { expect(migrated).toEqual(upstreamRow as unknown as MigratedRow); }); + test('a REPLACED poison row migrates its displaced old generation before retiring itself', () => { + // The row on top is retired, but the generation it displaced still rides + // along under `old` and the engine reads it. It must arrive in the + // upstream shape, so the old chain is rewritten before the retirement. + const legacy = legacyEnvRow('DATABASE_URL'); + const replaced = { + ...legacy, + status: 'replaced', + old: { props: legacy.props, attr: legacy.attr, bindings: [] }, + deleteFirst: false, + } as unknown as ReplacedResourceState; + const migrated = migrateLegacyResourceState(replaced) as ReplacedResourceState & { + removalPolicy?: string; + attr: Record; + old: { props: Record; attr: Record }; + }; + expect(migrated.removalPolicy).toBe('retain'); + expect(migrated.attr).toEqual({ + environmentVariableId: 'dev:legacy-poison-DATABASE_URL', + key: 'DATABASE_URL', + }); + expect(migrated.old.attr).toMatchObject({ + environmentVariableId: 'var-1', + projectId: 'proj-1', + key: 'DATABASE_URL', + isManagedBySystem: false, + }); + expect(migrated.old.props).toMatchObject({ project: 'proj-1', key: 'DATABASE_URL' }); + expect(Redacted.isRedacted(migrated.old.props['value'])).toBe(true); + }); + test('maps the unreleased PrismaComposer.* compute type-ids too', () => { const composerEra = { ...legacyAppRow('branch_1'), diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts index 56583195..c9e76bfc 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -333,9 +333,6 @@ const migrateResourceRow = (row: Record): Record): Record Date: Wed, 5 Aug 2026 09:01:03 +0200 Subject: [PATCH 22/22] fix(deps): pin alchemy's floating effect companions to the beta.100 constellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alchemy 2.0.0-beta.67 floats @effect/sql-d1 and @effect/vitest (>=4.0.0-beta.100), and optionally peers on @effect/platform-bun/node in the same range. Each release of those packages peers on its own exact effect beta, so the moment a newer beta is published a standalone npm install resolves a second effect next to our beta.100 pin (TML-3158 again, this time via beta.103). The public packages now pin the whole constellation at 4.0.0-beta.100 — including the platform-* pins that were left behind on beta.93, which fail alchemy's >=beta.100 peer range outright. The check's API probe moves from Schedule.either (called by alchemy beta.59, removed in effect beta.100) to Schedule.max (what beta.67's retry schedules actually call). Signed-off-by: willbot Signed-off-by: Will Madden --- .../composer-prisma-cloud/package.json | 8 +- packages/9-public/composer/package.json | 7 +- pnpm-lock.yaml | 177 ++++-------------- scripts/check-npm-effect-resolution.mjs | 8 +- 4 files changed, 50 insertions(+), 150 deletions(-) diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index 2142beef..c8cc5891 100644 --- a/packages/9-public/composer-prisma-cloud/package.json +++ b/packages/9-public/composer-prisma-cloud/package.json @@ -39,9 +39,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/platform-bun": "4.0.0-beta.93", - "@effect/platform-node": "4.0.0-beta.93", - "@effect/platform-node-shared": "4.0.0-beta.93", + "@effect/platform-bun": "4.0.0-beta.100", + "@effect/platform-node": "4.0.0-beta.100", + "@effect/platform-node-shared": "4.0.0-beta.100", + "@effect/sql-d1": "4.0.0-beta.100", + "@effect/vitest": "4.0.0-beta.100", "@prisma-next/cli": "0.16.0", "@prisma-next/config-loader": "0.16.0", "@prisma-next/contract": "0.16.0", diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 56ec934f..5f1e6a04 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -33,7 +33,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@effect/vitest": "4.0.0-beta.93", + "@effect/sql-d1": "4.0.0-beta.100", + "@effect/vitest": "4.0.0-beta.100", + "@prisma/management-api-sdk": "^1.50.0", "@standard-schema/spec": "^1.1.0", "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", @@ -41,8 +43,7 @@ "clipanion": "^3.2.1", "effect": "4.0.0-beta.100", "esbuild": "^0.28.1", - "postgres": "^3.4.9", - "@prisma/management-api-sdk": "^1.50.0" + "postgres": "^3.4.9" }, "devDependencies": { "@internal/assemble": "workspace:0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e93d3dc8..d09afb7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1182,9 +1182,12 @@ importers: packages/9-public/composer: dependencies: + '@effect/sql-d1': + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/vitest': - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) '@prisma/management-api-sdk': specifier: ^1.50.0 version: 1.50.0 @@ -1253,14 +1256,20 @@ importers: packages/9-public/composer-prisma-cloud: dependencies: '@effect/platform-bun': - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93(effect@4.0.0-beta.100) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/platform-node': - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) '@effect/platform-node-shared': - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93(effect@4.0.0-beta.100) + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/sql-d1': + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100) + '@effect/vitest': + specifier: 4.0.0-beta.100 + version: 4.0.0-beta.100(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) '@prisma-next/cli': specifier: 0.16.0 version: 0.16.0(typanion@3.14.0)(typescript@6.0.3) @@ -1290,7 +1299,7 @@ importers: version: 1.1.0 alchemy: specifier: 2.0.0-beta.67 - version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) + version: 2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) arktype: specifier: ^2.2.3 version: 2.2.3 @@ -1822,10 +1831,11 @@ packages: peerDependencies: effect: ^4.0.0-beta.100 - '@effect/platform-bun@4.0.0-beta.93': - resolution: {integrity: sha512-pny29d1NhJ7XLv19as7A0pIZr+QFi5KejnwB/lLW1c8P63t+PDMPEn21toy2vx2yzl9xRTo8heyiXPXRsqPN5w==} + '@effect/platform-node-shared@4.0.0-beta.100': + resolution: {integrity: sha512-PMsCXQeK2wnlmnqGCc79oqK9CX8ipZvoHxAy/CRojMF+zHIluxh61L3pzWAYEbMb19Be4Bxgqs7gK2hiV8H8Pg==} + engines: {node: '>=18.0.0'} peerDependencies: - effect: ^4.0.0-beta.93 + effect: ^4.0.0-beta.100 '@effect/platform-node-shared@4.0.0-beta.102': resolution: {integrity: sha512-gVd793I72MrkX4dXo7eYtRKfNj0RW4eMRfVEKEJI16h2+mBDCzQ+gqMrog2hSTHQnaIbvbYShNQ4TVGuRCYZeQ==} @@ -1833,12 +1843,6 @@ packages: peerDependencies: effect: ^4.0.0-beta.102 - '@effect/platform-node-shared@4.0.0-beta.93': - resolution: {integrity: sha512-XUqZ2u5GglBqY8q2jj4Q7GjN5K/enedk8auZM9rY/l5a/myaQTrQp3QnvpIK4/Yg0WFjLGuctGPMKWRk3OLIrA==} - engines: {node: '>=18.0.0'} - peerDependencies: - effect: ^4.0.0-beta.93 - '@effect/platform-node@4.0.0-beta.100': resolution: {integrity: sha512-nH5xgxOLfPj5Bi0/o4OaDsR98Z59lHKGty+TDqckg7zRz6jry82hGW982NRPduzX0MJloDsGp/3rfeN4Hu7Keg==} engines: {node: '>=18.0.0'} @@ -1846,28 +1850,26 @@ packages: effect: ^4.0.0-beta.100 ioredis: ^5.7.0 - '@effect/platform-node@4.0.0-beta.93': - resolution: {integrity: sha512-QagsCGR0ZOXaCQqS5qGR2mcDng4LiP2bYhiiX1D6UC8cT9vsusVVOHiJWn8CupeDx+yVnPcu81QmA/SDt6GM1w==} - engines: {node: '>=18.0.0'} + '@effect/sql-d1@4.0.0-beta.100': + resolution: {integrity: sha512-PBxlUPYAapFbm3GbYX6TVkVFtr+NyFVCzDPuMxxXTY0zlR8K10fH+RBqxUIDLcKsA4ho9GW2ljFkLOB9mLhpyg==} peerDependencies: - effect: ^4.0.0-beta.93 - ioredis: ^5.7.0 + effect: ^4.0.0-beta.100 '@effect/sql-d1@4.0.0-beta.102': resolution: {integrity: sha512-p6V1BqXEKF/EVH1JkWjjslPtg5Knl2TWopMnDdgto2jGfRpCHfhEaI8fA2OmR5J5H/TIH9ihYBiUA6Awru2Kvg==} peerDependencies: effect: ^4.0.0-beta.102 - '@effect/vitest@4.0.0-beta.102': - resolution: {integrity: sha512-4dipFAYG6imOzrY3zy3BgzCJkbb9xESyzUef0WSx8bsK0/SpITqbJpdwKeOyDWLZ+rimjua8IbTFMAde865pIQ==} + '@effect/vitest@4.0.0-beta.100': + resolution: {integrity: sha512-WoxrzPuxc+4QXb+7D1j8PwaBb9zVxHM2yw0sDz7fXij1Sx7X7T6LkYJ7m2xzWbfskIXxIdXVA+NnDSF8qAOGsw==} peerDependencies: - effect: ^4.0.0-beta.102 + effect: ^4.0.0-beta.100 vitest: ^3.0.0 || ^4.0.0 - '@effect/vitest@4.0.0-beta.93': - resolution: {integrity: sha512-gMAnZ9PiMeJMDED9s0jWgCOhc2JccrTCxowhur/KriImsHnHIRj4VG/vK0xLw0Axe4AkTWzXNdRsFrYOjBTl3A==} + '@effect/vitest@4.0.0-beta.102': + resolution: {integrity: sha512-4dipFAYG6imOzrY3zy3BgzCJkbb9xESyzUef0WSx8bsK0/SpITqbJpdwKeOyDWLZ+rimjua8IbTFMAde865pIQ==} peerDependencies: - effect: ^4.0.0-beta.93 + effect: ^4.0.0-beta.102 vitest: ^3.0.0 || ^4.0.0 '@electric-sql/pglite-socket@0.0.20': @@ -6236,16 +6238,6 @@ snapshots: '@effect/platform-bun': 4.0.0-beta.100(effect@4.0.0-beta.100) '@effect/platform-node': 4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1) - '@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)': - dependencies: - '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) - effect: 4.0.0-beta.100 - workerd: 1.20260704.1 - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.93(effect@4.0.0-beta.100) - '@effect/platform-node': 4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1) - '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.100(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1)': dependencies: '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) @@ -6260,20 +6252,6 @@ snapshots: - rolldown - workerd - '@distilled.cloud/cloudflare-vite-plugin@0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1)': - dependencies: - '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100) - effect: 4.0.0-beta.100 - vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.93(effect@4.0.0-beta.100) - '@effect/platform-node': 4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1) - transitivePeerDependencies: - - rolldown - - workerd - '@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100)': dependencies: '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) @@ -6331,15 +6309,7 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100)': - dependencies: - '@effect/platform-node-shared': 4.0.0-beta.93(effect@4.0.0-beta.100) - effect: 4.0.0-beta.100 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@effect/platform-node-shared@4.0.0-beta.102(effect@4.0.0-beta.100)': + '@effect/platform-node-shared@4.0.0-beta.100(effect@4.0.0-beta.100)': dependencies: '@types/ws': 8.18.1 effect: 4.0.0-beta.100 @@ -6348,7 +6318,7 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.93(effect@4.0.0-beta.100)': + '@effect/platform-node-shared@4.0.0-beta.102(effect@4.0.0-beta.100)': dependencies: '@types/ws': 8.18.1 effect: 4.0.0-beta.100 @@ -6368,28 +6338,22 @@ snapshots: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1)': + '@effect/sql-d1@4.0.0-beta.100(effect@4.0.0-beta.100)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.93(effect@4.0.0-beta.100) + '@cloudflare/workers-types': 5.20260801.1 effect: 4.0.0-beta.100 - ioredis: 5.11.1 - mime: 4.1.0 - undici: 8.7.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate '@effect/sql-d1@4.0.0-beta.102(effect@4.0.0-beta.100)': dependencies: '@cloudflare/workers-types': 5.20260801.1 effect: 4.0.0-beta.100 - '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.100(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': dependencies: effect: 4.0.0-beta.100 vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) - '@effect/vitest@4.0.0-beta.93(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': + '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))': dependencies: effect: 4.0.0-beta.100 vitest: 4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) @@ -8250,73 +8214,6 @@ snapshots: - vitest - workerd - alchemy@2.0.0-beta.67(patch_hash=2d8aacde553808d1c3724129275e35d2463d96e0dde449e9f94492d3ac9c4c44)(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.100)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1): - dependencies: - '@alchemy.run/node-utils': 0.0.5 - '@aws-sdk/credential-providers': 3.1077.0 - '@clack/prompts': 1.7.0 - '@distilled.cloud/aws': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/axiom': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-rolldown-plugin': 0.15.0(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100) - '@distilled.cloud/cloudflare-vite-plugin': 0.15.0(@distilled.cloud/cloudflare-runtime@0.15.0(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100))(@distilled.cloud/cloudflare@0.30.3(effect@4.0.0-beta.100))(@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.100))(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1))(effect@4.0.0-beta.100)(rolldown@1.1.5)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(workerd@1.20260704.1) - '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/neon': 0.30.3(effect@4.0.0-beta.100) - '@distilled.cloud/planetscale': 0.30.3(effect@4.0.0-beta.100) - '@effect/sql-d1': 4.0.0-beta.102(effect@4.0.0-beta.100) - '@effect/vitest': 4.0.0-beta.102(effect@4.0.0-beta.100)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) - '@libsql/client': 0.17.4 - '@octokit/rest': 22.0.1 - '@octokit/webhooks': 14.2.0 - '@prisma/dev': 0.20.0(typescript@6.0.3) - '@smithy/node-config-provider': 4.5.4 - '@smithy/shared-ini-file-loader': 4.6.4 - '@smithy/types': 4.16.1 - '@types/aws-lambda': 8.10.162 - '@vercel/nft': 1.10.2 - aws4fetch: 1.0.20 - capnweb: 0.6.1 - effect: 4.0.0-beta.100 - fast-glob: 3.3.3 - fast-xml-parser: 5.9.3 - ink: 6.8.0(@types/react@19.2.17)(react@19.2.8) - jszip: 3.10.1 - libsodium-wrappers: 0.8.4 - mongodb: 6.21.0(@aws-sdk/credential-providers@3.1077.0) - mysql2: 3.22.5(@types/node@26.1.1) - pathe: 2.0.3 - pg: 8.22.0 - picomatch: 4.0.5 - react: 19.2.8 - rolldown: 1.1.5 - undici: 7.28.0 - yaml: 2.9.0 - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.93(effect@4.0.0-beta.100) - '@effect/platform-node': 4.0.0-beta.93(effect@4.0.0-beta.100)(ioredis@5.11.1) - vite: 8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0) - ws: 8.21.1 - transitivePeerDependencies: - - '@mongodb-js/zstd' - - '@types/node' - - '@types/react' - - bufferutil - - encoding - - gcp-metadata - - kerberos - - mongodb-client-encryption - - pg-native - - react-devtools-core - - rollup - - snappy - - socks - - supports-color - - typescript - - utf-8-validate - - vitest - - workerd - ansi-escapes@7.3.0: dependencies: environment: 1.1.0 diff --git a/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 0d5e9d3f..79034ed9 100644 --- a/scripts/check-npm-effect-resolution.mjs +++ b/scripts/check-npm-effect-resolution.mjs @@ -2,7 +2,7 @@ // Regression check for TML-3158: a standalone `npm install` of the public // packages must resolve exactly ONE `effect` — the version the packages pin — // and alchemy's position in the tree must resolve that copy (with -// `Schedule.either`, removed in later betas, still present). +// `Schedule.max`, which alchemy's retry schedules call, present). // // Why: alchemy declares floating ranges on the effect ecosystem // (`@effect/vitest`, optional platform peers, `effect` itself as @@ -181,8 +181,8 @@ async function checkShape(label, tarballs) { } const { Schedule } = await import(pathToFileURL(effectEntry).href); - if (typeof Schedule.either !== 'function') { - fail(`[${label}] Schedule.either is missing from the effect alchemy resolves`); + if (typeof Schedule.max !== 'function') { + fail(`[${label}] Schedule.max is missing from the effect alchemy resolves`); } // Positive proof the CLI actually starts in this healthy tree — without it, @@ -200,7 +200,7 @@ async function checkShape(label, tarballs) { fail(`[${label}] the CLI crashed on its module graph in a healthy tree:\n${cli.output}`); } - process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.either present\n`); + process.stderr.write(`[${label}] OK — single effect@${pinnedEffect}, Schedule.max present\n`); } // The reported chain: an app dependency on `@effect/platform-node-shared`