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 000000000..9b10da8b0 --- /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) 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 000000000..bdf9ba363 --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/assets/upstream-pr-body.md @@ -0,0 +1,122 @@ +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. + +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`): + +- 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`. + +## 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). + +## 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. 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 000000000..b2843241b --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/design-notes.md @@ -0,0 +1,232 @@ +# 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 + +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). +- 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. + +## 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. + +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 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 + +`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 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 + +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 +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 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" + (`redeployOn`; companion upstream commit in flight).** Until it ships, + 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.** + 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** — 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 deploy fingerprint (and eventually `redeployOn`) sits. + +## Open questions + +Tracked in spec.md (state-migration mechanics; first released beta). The +Compute-vs-App+Deployment question is settled above. + +## 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 000000000..d8b96e63d --- /dev/null +++ b/.drive/projects/alchemy-provider-adoption/plan.md @@ -0,0 +1,69 @@ +# 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. +- **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) + +- [ ] 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 000000000..be19e86a2 --- /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~~ — resolved: + `alchemy@2.0.0-beta.67` is the adopted pin. + +# 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) diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 988a2d807..7e8774f73 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. @@ -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 209210a3c..2ea627c95 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) | 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/alchemy-lowering.md b/docs/design/05-prisma-cloud/alchemy-lowering.md index 6fbb08a65..a9f2f4c25 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 @@ -38,27 +44,37 @@ 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. First, the framework creates Projects with -`createDatabase: false`, so **no default database exists at all** on a -framework-provisioned Project (the opt-out is workspace-actor-only — fine, -deploys authenticate with service tokens). Second, as defense in depth (and -for Projects created before the opt-out, or adopted by name): 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. +Framework-provisioned Projects are created with `createDatabase: false`, so no +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 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 @@ -67,12 +83,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 @@ -119,7 +135,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 @@ -143,54 +160,73 @@ 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 `app` prop. +- Every `EnvironmentVariable` a Deployment boots with is threaded into its + `app` — 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 `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 `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 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/05-prisma-cloud/pdp-data-model.md b/docs/design/05-prisma-cloud/pdp-data-model.md index f518e5aed..8dde23da1 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: 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 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 465635811..bb7426bda 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 cedfbf510..b4d711d80 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,20 @@ 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. +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)). @@ -954,8 +955,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 +1005,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 }), }, @@ -1038,10 +1033,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 }, ), @@ -1058,10 +1053,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 +1071,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 +1086,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 e6cbad653..a84adab22 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/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 000000000..28c340a04 --- /dev/null +++ b/docs/design/90-decisions/ADR-0043-prisma-cloud-resources-come-from-the-upstream-alchemy-provider.md @@ -0,0 +1,197 @@ +# ADR-0043: Prisma Cloud resources come from the upstream Alchemy provider + +## Decision + +Composer does not implement Alchemy resources for Prisma Cloud's Management +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. + +## 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 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 + +- **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.** 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. +- **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 + +- 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 e47f641a7..45dd79b5c 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -60,7 +60,8 @@ _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. +- [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. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 546cd4ccd..6029f4650 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -296,6 +296,61 @@ 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 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 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` 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 +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 651576aa0..d80e122e1 100644 --- a/docs/guides/running-locally.md +++ b/docs/guides/running-locally.md @@ -46,6 +46,15 @@ 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. 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 ```sh diff --git a/examples/bucket/package.json b/examples/bucket/package.json index 663e0dfd1..327b641fa 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.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 22340b6e1..a314eb1b3 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.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.6.0", diff --git a/examples/pn-widgets/package.json b/examples/pn-widgets/package.json index e82922d46..8a1b8f667 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.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 5818b8f5f..ab5e76c6b 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.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.6.0", diff --git a/examples/store/package.json b/examples/store/package.json index 20c280f23..f301196d2 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.0", "@store/catalog": "workspace:0.6.0", "@store/orders": "workspace:0.6.0", "@store/promotions": "workspace:0.6.0", "@store/storefront": "workspace:0.6.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 c6d3a97e5..f314d5531 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.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.0", "@storefront-auth/auth": "workspace:0.6.0", "@storefront-auth/storefront": "workspace:0.6.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.6.0", diff --git a/gotchas.md b/gotchas.md index 8d18f6aba..efd9c05c6 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.** 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.** 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.** @@ -214,7 +214,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) --- @@ -300,7 +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: [`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`, 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`) --- diff --git a/packages/0-framework/1-core/core/package.json b/packages/0-framework/1-core/core/package.json index 1b08a503a..efc493b8e 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.6.0" }, "devDependencies": { 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 000000000..7cbfad3f5 --- /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 61e25eb67..d2708d23e 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 000000000..d8f0ec349 --- /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 5d9e56f65..a9bae9a51 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 4c4d178c3..461cc10ed 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/local-target/package.json b/packages/1-prisma-cloud/0-lowering/local-target/package.json index 4ae9951ed..c27f00cb8 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.6.0", "@internal/lowering": "workspace:0.6.0", "@internal/s3-protocol": "workspace:0.6.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__/compute-scoped-env.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/compute-scoped-env.test.ts index df7140e5a..8975ea35b 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/__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 47522aa8c..d90d8f841 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,25 +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', - 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 @@ -111,25 +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', - 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, @@ -155,43 +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', - 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', - 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 6bf2bdb49..a77f68578 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,29 +1,74 @@ /** - * 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 '@internal/lowering/postgres'; +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'; +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 { + 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 @@ -50,17 +95,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 +200,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 +247,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 +280,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 +295,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 +315,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, }), @@ -257,16 +345,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: DEV_TIMESTAMP, + 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 757346c2b..24671ed0b 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,12 +31,20 @@ 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'; +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 { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value['databaseId'] === 'string') return value['databaseId']; + return undefined; +} function noPrismaDevError(): Error { return new Error( @@ -62,18 +78,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 +107,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 +143,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 9db09ab0b..777d76bd1 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 '@internal/lowering/postgres'; +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/local-target/src/upstream-attributes.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/upstream-attributes.ts new file mode 100644 index 000000000..07786f44a --- /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'; +} diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index bb4812b3d..e7c92935f 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,11 +16,12 @@ "clean": "rm -rf dist" }, "dependencies": { + "@effect/platform-node": "4.0.0-beta.100", "@internal/core": "workspace:0.6.0", "@internal/foundation": "workspace:0.6.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 deleted file mode 100644 index 97299f445..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ComputeService.test.ts +++ /dev/null @@ -1,252 +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.both( - Schedule.spaced('1 millis'), - Schedule.during('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__/Database.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/Database.test.ts deleted file mode 100644 index 0be9b4631..000000000 --- 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/__tests__/EnvironmentVariable.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/EnvironmentVariable.test.ts deleted file mode 100644 index 328152680..000000000 --- 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/__tests__/ServiceKey.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/ServiceKey.test.ts index 56f760ee6..aecd387ad 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/__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 000000000..43de5aa8f --- /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/buckets/Bucket.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/buckets/Bucket.ts index e688eb045..2de2b9182 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 546ef16d9..dd7a9bfe5 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/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index ec3a54eff..7435b61f7 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/compute/ComputeService.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts deleted file mode 100644 index 70acc081b..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/ComputeService.ts +++ /dev/null @@ -1,131 +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.both( - Schedule.exponential('2 seconds', 2), - Schedule.during('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< - 'Prisma.ComputeService', - ComputeServiceProps, - ComputeServiceAttributes ->; - -/** A Prisma **Compute service** — the stable app identity behind a project. */ -export const ComputeService = Resource('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 a71de7d0a..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/Deployment.ts +++ /dev/null @@ -1,163 +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<'Prisma.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 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.both(Schedule.spaced('2 seconds'), Schedule.during('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 94f9e881a..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/EnvironmentVariable.ts +++ /dev/null @@ -1,129 +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< - 'Prisma.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('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__/deploy-fingerprint.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deploy-fingerprint.test.ts new file mode 100644 index 000000000..48bc80deb --- /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 new file mode 100644 index 000000000..14eee4f63 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/__tests__/deployment-edge.test.ts @@ -0,0 +1,286 @@ +/** + * 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 { + 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. */ +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 = (propArtifactPath: string = artifactPath) => ({ + app: appAfterEnvironment(app.appId, environment), + artifactPath: propArtifactPath, + 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, + paths: { oldPath?: string; newPath?: string } = {}, +) => { + 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(paths.oldPath), app: 'app-1' }, + news: deploymentProps(paths.newPath), + 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 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 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' }); + }); +}); + +/** + * 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: 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 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 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 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/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index de79c3eed..28b89f441 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 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. const outDir = path.join( os.tmpdir(), 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 000000000..e7854dab2 --- /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 new file mode 100644 index 000000000..8ad08fe44 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/deployment-edge.ts @@ -0,0 +1,52 @@ +/** + * 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. + * + * 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: + * 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'; +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/credentials.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts index 442ae5326..75544c626 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/database-url-poison.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/database-url-poison.ts new file mode 100644 index 000000000..4da23c581 --- /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/compute.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/compute.ts index 8a365d5da..ea3708b8b 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,4 @@ export * from '../compute/artifact.ts'; -export * from '../compute/ComputeService.ts'; -export * from '../compute/Deployment.ts'; -export * from '../compute/EnvironmentVariable.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/0-lowering/lowering/src/exports/index.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/index.ts index 8d401324a..0c4ae2f83 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,12 @@ /** * `@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) 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, @@ -11,8 +15,8 @@ export { } from '../client.ts'; export * from '../container.ts'; export * from '../credentials.ts'; +export * from '../database-url-poison.ts'; export * from '../pagination.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 888159e54..000000000 --- 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/http.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/http.ts index 4420e7109..15703a078 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/0-lowering/lowering/src/postgres/Connection.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts deleted file mode 100644 index 2de9ffd3c..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Connection.ts +++ /dev/null @@ -1,75 +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<'Prisma.Connection', ConnectionProps, ConnectionAttributes>; - -/** A **connection** to a Prisma Postgres database — yields the connection string. */ -export const Connection = Resource('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 2c3957e8f..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/postgres/Database.ts +++ /dev/null @@ -1,102 +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<'Prisma.Database', DatabaseProps, DatabaseAttributes>; - -/** A Prisma **Postgres database** inside a project. */ -export const Database = Resource('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 fe3afcf80..000000000 --- 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<'Prisma.Project', ProjectProps, ProjectAttributes>; - -/** A Prisma Developer Platform **Project** — the container for databases and compute services. */ -export const Project = Resource('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 2746d1870..cafb3e39e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -1,50 +1,106 @@ +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'; 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()('Prisma') {} +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 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 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 + // 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 * `{ 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( Providers, Provider.collection([ - Project, - Database, - Connection, - ComputeService, - Deployment, - EnvironmentVariable, + Prisma.Project, + Prisma.Database, + Prisma.Connection, + Prisma.App, + Prisma.Deployment, + Prisma.EnvironmentVariable, Bucket, BucketKey, ]), ).pipe( - Layer.provide( - Layer.mergeAll( - ProjectProvider(), - DatabaseProvider(), - ConnectionProvider(), - 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-resources.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts new file mode 100644 index 000000000..8fab80801 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/legacy-resources.test.ts @@ -0,0 +1,838 @@ +/** + * 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 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. + 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('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('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'), + 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', + ); + 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'); + }); +}); + +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/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index 5b350fe0c..7ff92db2e 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 @@ -76,7 +76,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/legacy-resources.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts new file mode 100644 index 000000000..c9e76bfc6 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/legacy-resources.ts @@ -0,0 +1,369 @@ +/** + * 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 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 + * 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 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 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 + * — 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; + 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 : {}; + 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; + return { + environmentVariableId: attr['id'], + projectId: oldProps['projectId'], + branchId: oldProps['branchId'] ?? null, + class: oldProps['class'] ?? 'production', + 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. + 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']) } : {}), + }; + } + + // Retiring the row happens AFTER the `old` chain is rewritten: a replaced + // poison row still carries the displaced generation, and it must reach the + // engine in the upstream shape even though this row is on its way out. + const poisonKey = poisonKeyOf(family, row['props'], row['attr']); + if (poisonKey !== undefined) return retirePoisonRow(migrated, poisonKey); + + 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 bc271ff74..394e4a56f 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 { migrateLegacyResourceState } from './legacy-resources.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 — 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" - >(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/0-lowering/lowering/src/state/transient.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts index b6ed2a46a..003bd142e 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/0-lowering/lowering/tsdown.config.ts b/packages/1-prisma-cloud/0-lowering/lowering/tsdown.config.ts index c681a314d..328306160 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/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index be0b0b2bd..2cda288c8 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__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 44bd6ecfe..ca4b9738a 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'; @@ -40,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]>; @@ -51,8 +53,10 @@ const recorded: { bucket: Array<[string, unknown]>; bucketKey: Array<[string, unknown]>; generated: Array<[string, unknown]>; + poisonClaims: string[]; } = { envVar: [], + envVarProps: [], db: [], conn: [], warm: [], @@ -64,6 +68,7 @@ const recorded: { bucket: [], bucketKey: [], generated: [], + poisonClaims: [], }; mock.module('alchemy/Output', () => ({ @@ -72,14 +77,73 @@ 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) 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 }); + }, + 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' }), - EnvironmentVariable: (id: string, props: { key: string }) => { - recorded.envVar.push([id, props]); - return Effect.succeed({ id: `${id}#cloud-id`, key: props.key }); + // 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 @@ -89,17 +153,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 }); @@ -115,22 +168,18 @@ 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}` }; }, + // 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 @@ -207,7 +256,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'> & { @@ -306,9 +355,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), poisons DATABASE_URL + DATABASE_URL_POOLED with "-", class production, no branchId', () => { + 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', @@ -323,32 +373,18 @@ 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', - }, - ], - ]); + 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: poison env vars carry class "preview" and branchId', () => { + 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', @@ -363,28 +399,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([ - [ - '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', - }, - ], - ]); + // 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', () => { @@ -419,10 +437,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 +462,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', }, @@ -540,7 +565,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' }, + ], ]); }); }); @@ -560,9 +588,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', }, ], @@ -606,7 +634,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', @@ -618,7 +646,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', @@ -629,7 +657,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', @@ -637,9 +665,27 @@ 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', + }, + ]); + // 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. @@ -647,6 +693,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' }); @@ -694,7 +783,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', @@ -754,13 +843,23 @@ 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', }); // 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({ @@ -768,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'], }); }, ); @@ -819,7 +921,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', @@ -832,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', + ); }); }); @@ -876,6 +989,7 @@ describe("prismaCloud().nodes['compute'] — the service descriptor", () => { value: '{}', absent: ['greeting → NOT_SET_GREETING_VAR'], generated: [], + secrets: [], }); }); }); @@ -918,20 +1032,29 @@ 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), ); 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', }); // 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'); }, ); }); @@ -972,7 +1095,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', @@ -1014,7 +1137,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', @@ -1024,7 +1147,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', @@ -1094,13 +1217,21 @@ 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 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, }; @@ -1109,15 +1240,22 @@ 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', - artifactPath: '/tmp/auth.tar.gz', - artifactHash: 'sha-auth', - environment: serialized.environment, - port: 8080, + app: 'auth-svc#cloud-id', + artifactPath: + '/tmp/auth.tar.gz#[{"key":"COMPOSER_AUTH_DB_URL","withheld":"input.db:db-postgres"}]', + artifactContentType: 'application/gzip', + portMapping: { http: 8080 }, + start: true, + promote: true, }, ], ]); @@ -1427,21 +1565,27 @@ 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); 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', @@ -1512,13 +1656,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', @@ -1596,7 +1740,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', @@ -1718,7 +1862,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', @@ -1849,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 = { @@ -1875,13 +2020,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/__tests__/generated-param.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/generated-param.test.ts index 8e41de831..3b5c66902 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__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index f62827c92..e356d2560 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__/pg-warm-resource.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pg-warm-resource.test.ts index e13999ea5..41eb5e9b7 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 eb01835f4..496733a93 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__/pointer-timestamps.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/pointer-timestamps.test.ts new file mode 100644 index 000000000..9b47ec78e --- /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 b97acc02d..a07b1cfd6 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[]; @@ -52,7 +60,9 @@ const fakeClient = (state: FakeState): ManagementApiClient => ); const offset = q['cursor'] === undefined ? 0 : Number(q['cursor']); const pageSize = state.pageSize ?? rows.length; - const data = rows.slice(offset, offset + pageSize); + const data = rows + .slice(offset, offset + pageSize) + .map((r) => ({ ...r, updatedAt: r.updatedAt ?? DEFAULT_UPDATED_AT })); const pagination = state.cursorStuck === true ? { nextCursor: String(offset), hasMore: true } @@ -81,7 +91,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 }), }; @@ -456,6 +468,132 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { 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'); + }); + }); + describe('env-var listing pagination (bounded — drivePagesAsync)', () => { test('a visible row beyond the first page still counts as present', async () => { state.pageSize = 1; @@ -474,8 +612,6 @@ describe('runPreflight — secret manifest verification (ADR-0029)', () => { }); test('a non-advancing cursor fails as broken pagination instead of looping', async () => { - // No matching rows: every page is empty, so the search never - // short-circuits and the stuck cursor is what ends it. state.pageSize = 1; state.cursorStuck = true; 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 972e0e137..5ad57d69a 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/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index ec12b3a87..429dfd4f6 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 { @@ -40,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` @@ -152,7 +154,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; @@ -175,15 +177,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); } @@ -282,7 +284,7 @@ export const PROVIDER_PARAMS: ReadonlyMap { const workspaceId = opts.workspaceId ?? process.env['PRISMA_WORKSPACE_ID'] ?? ''; if (opts.region !== undefined) { @@ -296,7 +298,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 }; @@ -310,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, @@ -342,35 +358,36 @@ 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 // 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, 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.gen(function* () { 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 } : {}), - }); - } - + yield* Prisma.claimPoisonDatabaseUrl(projectId); return { projectId, branchId } satisfies CloudApplication; }), }, 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 000000000..6b0594c54 --- /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 47c2900a6..c17317da6 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,17 +2,27 @@ import { isParamSource, type ServiceNode } from '@internal/core'; import type { ServiceLowering } from '@internal/core/deploy'; -import * as Prisma from '@internal/lowering'; +import { + appAfterEnvironment, + deployEnvFingerprint, + type EnvFingerprintEntry, + fingerprintedArtifactPath, + 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'; import { configKey, + decodeParamPointer, encode, encodeParamPointer, type InputDocumentRow, + isParamPointerRow, paramEntries, serializeInput, } from '../serializer.ts'; @@ -28,10 +38,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; @@ -43,13 +53,38 @@ 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; } +/** + * 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); + +/** + * 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), @@ -68,13 +103,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; @@ -91,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 = @@ -110,13 +150,30 @@ 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, }), ); + // 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( @@ -127,16 +184,25 @@ 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, }), ); + // 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. @@ -148,13 +214,23 @@ 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, }), ); + // 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)}`, + }); } } @@ -213,13 +289,19 @@ export function computeDescriptor( : encode('service', raw); records.push( yield* Prisma.EnvironmentVariable(`${key}-var`, { - projectId, + project: projectId, key, - value, + value: envValue(value), class: cls, ...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. @@ -228,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 } : {}), }; @@ -237,7 +320,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 +328,40 @@ 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* () { + // 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`, { - computeServiceId: provisioned.serviceId, - artifactPath: artifact.path, - artifactHash: artifact.sha256, - environment: serialized.environment, + // `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), + // 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. + 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 +385,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/postgres.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/postgres.ts index dc734ae2d..2a514c7ab 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 8f7b36a02..a73f6d3f1 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/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts b/packages/1-prisma-cloud/1-extensions/target/src/descriptors/shared.ts index da98ba839..395ff1c67 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,8 @@ /** 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 { PointerUpdatedAt } 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 +65,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`). @@ -74,10 +75,20 @@ 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. */ -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 db96a8636..69add7d34 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 41fea6d69..b5322b2d8 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'; @@ -36,15 +43,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 { @@ -76,24 +80,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-circuits as soon as a - // visible row is seen; bounded (drivePagesAsync) so broken pagination - // fails loudly instead of looping. - let found = false; + let exists = false; + let latest: string | undefined; await drivePagesAsync( `environment variables named "${key}"`, async (cursor) => { @@ -107,18 +126,23 @@ async function existsOnPlatform( return res.data ?? { data: [], pagination: { nextCursor: null, hasMore: false } }; }, (data) => { - found = data.some(visible); - return found; + for (const row of data) { + if (!visible(row)) continue; + exists = true; + if (latest === undefined || row.updatedAt > latest) latest = row.updatedAt; + } + return false; }, ); - return found; + return latest === undefined ? { exists } : { exists, updatedAt: latest }; } /** * 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( @@ -127,7 +151,7 @@ async function fillMissing( branchId: string | undefined, key: string, value: string, -): Promise { +): Promise { const res = await client.POST('/v1/environment-variables', { body: { projectId, @@ -140,6 +164,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 { @@ -204,7 +233,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 @@ -218,20 +247,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/secret.ts b/packages/1-prisma-cloud/1-extensions/target/src/secret.ts index f9a9a0293..6efab2eea 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 ff2deb92f..2c7ce3b19 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(); }; @@ -460,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[]; } /** @@ -506,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()], + }; } /** diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index fedffd679..c8cc58916 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", @@ -51,9 +53,9 @@ "@prisma/composer": "workspace:0.6.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 738892175..5f1e6a04e 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -33,16 +33,17 @@ "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.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" + "postgres": "^3.4.9" }, "devDependencies": { "@internal/assemble": "workspace:0.6.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 000000000..4c2bd1187 --- /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 9d7fea6e4..d09afb7f6 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.6.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.93(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.6.0 version: link:../../packages/9-public/composer @@ -104,14 +109,14 @@ importers: specifier: workspace:0.6.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.93(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.6.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.6.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.93(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.6.0 version: link:../../packages/9-public/composer @@ -253,14 +258,14 @@ importers: specifier: workspace:0.6.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.93(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.6.0 version: link:../../packages/9-public/composer @@ -408,14 +413,14 @@ importers: specifier: workspace:0.6.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.93(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.93(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.6.0 @@ -762,11 +767,11 @@ importers: specifier: workspace:0.6.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.93(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 @@ -792,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.6.0 version: link:../../../0-framework/1-core/core @@ -802,11 +810,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.93(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 +907,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.93(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 +987,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 @@ -1174,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.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))) + 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 @@ -1184,8 +1195,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.93(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 @@ -1196,8 +1207,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 @@ -1245,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.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.93 - version: 4.0.0-beta.93(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) '@effect/platform-node-shared': - specifier: 4.0.0-beta.93 - version: 4.0.0-beta.93(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/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) @@ -1281,14 +1298,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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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 @@ -1381,8 +1398,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.93(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) @@ -1399,8 +1416,8 @@ importers: specifier: workspace:0.6.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.93(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 @@ -1663,16 +1680,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'} @@ -1686,50 +1709,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: @@ -1737,27 +1763,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': @@ -1765,25 +1791,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==} @@ -1800,82 +1826,92 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - '@effect/platform-bun@4.0.0-beta.93': - resolution: {integrity: sha512-pny29d1NhJ7XLv19as7A0pIZr+QFi5KejnwB/lLW1c8P63t+PDMPEn21toy2vx2yzl9xRTo8heyiXPXRsqPN5w==} + '@effect/platform-bun@4.0.0-beta.100': + resolution: {integrity: sha512-UyH4bgzlV3aJOXbUr/3Zej3MBPe9TDrdkwStLDpVn9Yc6dKIKOJBd0G/8+OQhunGAuCL9a4SaNvwhkMJajahrg==} peerDependencies: - effect: ^4.0.0-beta.93 + effect: ^4.0.0-beta.100 - '@effect/platform-bun@4.0.0-beta.97': - resolution: {integrity: sha512-WYjC7nKiWfNywIz1zeBEXnrpuHJM86DOi3lSZSSBeHCPz8HYw7IT2FL7u+aaJHHsJCyFiZVAgg2KFS8aMFSJBQ==} - peerDependencies: - effect: ^4.0.0-beta.97 - - '@effect/platform-node-shared@4.0.0-beta.93': - resolution: {integrity: sha512-XUqZ2u5GglBqY8q2jj4Q7GjN5K/enedk8auZM9rY/l5a/myaQTrQp3QnvpIK4/Yg0WFjLGuctGPMKWRk3OLIrA==} + '@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.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.93': - resolution: {integrity: sha512-QagsCGR0ZOXaCQqS5qGR2mcDng4LiP2bYhiiX1D6UC8cT9vsusVVOHiJWn8CupeDx+yVnPcu81QmA/SDt6GM1w==} + '@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.93 + 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.100': + resolution: {integrity: sha512-PBxlUPYAapFbm3GbYX6TVkVFtr+NyFVCzDPuMxxXTY0zlR8K10fH+RBqxUIDLcKsA4ho9GW2ljFkLOB9mLhpyg==} + peerDependencies: + 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.92 + effect: ^4.0.0-beta.102 + + '@effect/vitest@4.0.0-beta.100': + resolution: {integrity: sha512-WoxrzPuxc+4QXb+7D1j8PwaBb9zVxHM2yw0sDz7fXij1Sx7X7T6LkYJ7m2xzWbfskIXxIdXVA+NnDSF8qAOGsw==} + peerDependencies: + 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': + 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==} @@ -2035,6 +2071,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'} @@ -2239,9 +2281,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] @@ -2422,9 +2476,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==} @@ -2738,6 +2789,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==} @@ -3045,12 +3099,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} @@ -3063,12 +3111,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} @@ -3081,12 +3123,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} @@ -3099,12 +3135,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} @@ -3117,12 +3147,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} @@ -3135,12 +3159,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} @@ -3153,12 +3171,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} @@ -3171,12 +3183,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} @@ -3189,12 +3195,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} @@ -3207,12 +3207,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} @@ -3225,12 +3219,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} @@ -3243,12 +3231,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} @@ -3261,11 +3243,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} @@ -3276,12 +3253,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} @@ -3294,12 +3265,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} @@ -3315,6 +3280,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==} @@ -3531,6 +3505,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==} @@ -3541,6 +3521,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==} @@ -3718,6 +3703,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==} @@ -3739,22 +3733,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': @@ -3806,6 +3807,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} @@ -3817,6 +3821,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'} @@ -3898,13 +3906,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==} @@ -3947,6 +3966,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'} @@ -4030,6 +4052,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'} @@ -4178,11 +4204,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==} @@ -4233,6 +4256,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==} @@ -4298,6 +4324,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'} @@ -4347,6 +4376,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'} @@ -4374,6 +4407,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'} @@ -4394,6 +4431,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'} @@ -4624,6 +4668,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==} @@ -4636,6 +4684,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==} @@ -4646,6 +4697,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'} @@ -4668,6 +4723,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'} @@ -4704,6 +4762,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==} @@ -4715,6 +4777,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==} @@ -4777,14 +4869,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'} @@ -4840,6 +4950,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==} @@ -4988,6 +5102,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==} @@ -5059,6 +5177,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 @@ -5070,6 +5191,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==} @@ -5123,11 +5248,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} @@ -5231,6 +5351,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'} @@ -5267,10 +5390,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'} @@ -5355,6 +5474,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 @@ -5599,6 +5725,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'} @@ -5613,8 +5753,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 @@ -5916,7 +6056,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 @@ -5927,39 +6067,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 @@ -6004,20 +6148,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': @@ -6027,185 +6175,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)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - - '@distilled.cloud/axiom@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/axiom@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 + '@distilled.cloud/core': 0.30.3(effect@4.0.0-beta.100) + effect: 4.0.0-beta.100 - '@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-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: - '@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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.93(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.93))(@effect/platform-bun@4.0.0-beta.97(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(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.93(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 - optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.97(effect@4.0.0-beta.97) - '@effect/platform-node': 4.0.0-beta.93(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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1) - transitivePeerDependencies: - - rolldown - - workerd - - '@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.93(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.93(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.93(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) + '@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.93) - '@effect/platform-node': 4.0.0-beta.93(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.93(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.93(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.93(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.93(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)': - dependencies: - '@distilled.cloud/core': 0.27.0(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - - '@distilled.cloud/cloudflare@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.27.0(effect@4.0.0-beta.93)': - dependencies: - effect: 4.0.0-beta.93 - - '@distilled.cloud/core@0.27.0(effect@4.0.0-beta.97)': - dependencies: - effect: 4.0.0-beta.97 - - '@distilled.cloud/neon@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/neon@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/planetscale@0.27.0(effect@4.0.0-beta.93)': + '@distilled.cloud/neon@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/planetscale@0.27.0(effect@4.0.0-beta.97)': + '@distilled.cloud/planetscale@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 + '@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: @@ -6237,122 +6301,82 @@ snapshots: - msw - vite - '@effect/platform-bun@4.0.0-beta.93(effect@4.0.0-beta.93)': - dependencies: - '@effect/platform-node-shared': 4.0.0-beta.93(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.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)': + '@effect/platform-bun@4.0.0-beta.100(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 + '@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 - optional: true - '@effect/platform-node-shared@4.0.0-beta.93(effect@4.0.0-beta.93)': + '@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.93 + effect: 4.0.0-beta.100 ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.93(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-shared@4.0.0-beta.99(effect@4.0.0-beta.93)': + '@effect/platform-node@4.0.0-beta.100(effect@4.0.0-beta.100)(ioredis@5.11.1)': 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 + ioredis: 5.11.1 + mime: 4.1.0 + undici: 8.7.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.99(effect@4.0.0-beta.97)': + '@effect/sql-d1@4.0.0-beta.100(effect@4.0.0-beta.100)': dependencies: - '@types/ws': 8.18.1 - effect: 4.0.0-beta.97 - ws: 8.21.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - optional: true + '@cloudflare/workers-types': 5.20260801.1 + effect: 4.0.0-beta.100 - '@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(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.93(effect@4.0.0-beta.93) - effect: 4.0.0-beta.93 - ioredis: 5.11.1 - mime: 4.1.0 - undici: 8.7.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@cloudflare/workers-types': 5.20260801.1 + effect: 4.0.0-beta.100 - '@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.97)(ioredis@5.11.1)': + '@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/platform-node-shared': 4.0.0-beta.93(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 - - '@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)))': - 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)))': + '@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.97 + 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.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)))': + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: - 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)) + '@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: @@ -6366,11 +6390,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 @@ -6381,11 +6400,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 @@ -6469,6 +6483,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 @@ -6632,8 +6650,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 @@ -6652,13 +6692,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 @@ -6794,8 +6827,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': {} @@ -7226,6 +7257,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 @@ -7506,121 +7559,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 @@ -7635,18 +7645,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 @@ -7655,6 +7659,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 @@ -7887,6 +7897,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 @@ -7895,6 +7911,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 @@ -8081,6 +8116,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): @@ -8097,6 +8138,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -8104,164 +8147,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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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): - 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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93))(@effect/platform-node@4.0.0-beta.93(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.93(effect@4.0.0-beta.93) - '@effect/platform-node': 4.0.0-beta.93(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.93))(@effect/platform-node@4.0.0-beta.93(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.93(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.93(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.93(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.93(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.93(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.93(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.93(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.93(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.93(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 @@ -8296,25 +8244,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 @@ -8326,6 +8278,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 @@ -8348,12 +8301,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 @@ -8394,6 +8357,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 @@ -8457,6 +8429,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.2: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} @@ -8595,20 +8569,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 @@ -8677,6 +8638,8 @@ snapshots: escape-string-regexp@2.0.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -8745,6 +8708,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 @@ -8787,6 +8752,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 @@ -8821,6 +8792,8 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.11.4: {} + hono@4.12.31: {} hookable@6.1.1: {} @@ -8844,6 +8817,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: @@ -8864,7 +8846,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 @@ -8879,13 +8861,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 @@ -9050,6 +9032,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 @@ -9071,6 +9055,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + lodash@4.17.21: {} + lodash@4.18.1: {} log-update@6.1.0: @@ -9083,6 +9069,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lru.min@1.1.4: {} magic-string@0.30.21: @@ -9114,6 +9102,8 @@ snapshots: mdurl@2.0.0: {} + memory-pager@1.5.0: {} + merge2@1.4.1: {} micromark-util-character@2.1.1: @@ -9144,6 +9134,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: {} @@ -9152,6 +9146,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: @@ -9255,13 +9262,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: {} @@ -9307,6 +9324,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: {} @@ -9436,6 +9458,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + pure-rand@6.1.0: {} pure-rand@8.4.2: {} @@ -9459,9 +9483,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: {} @@ -9502,12 +9526,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: @@ -9551,27 +9579,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 @@ -9640,8 +9647,7 @@ snapshots: semver@7.8.1: {} - semver@7.8.5: - optional: true + semver@7.8.5: {} seq-queue@0.0.5: {} @@ -9724,6 +9730,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: {} @@ -9750,11 +9760,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 @@ -9829,6 +9834,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: {} @@ -10016,6 +10027,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 @@ -10029,13 +10054,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 0dd249913..0dd6d3a64 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/scripts/check-npm-effect-resolution.mjs b/scripts/check-npm-effect-resolution.mjs index 0d5e9d3f5..79034ed92 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` diff --git a/scripts/ci-cleanup-utils.ts b/scripts/ci-cleanup-utils.ts index cae20ad5c..0e14d271f 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/package.json b/test/integration/package.json index ad0a970e8..8f218dc69 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -14,7 +14,7 @@ "@prisma/example-store": "workspace:0.6.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/test/integration/test/local-dev.integration.ts b/test/integration/test/local-dev.integration.ts index 57addca0d..a93eefdba 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), diff --git a/website/package.json b/website/package.json index f0aef9e6a..20b7d36a3 100644 --- a/website/package.json +++ b/website/package.json @@ -16,7 +16,7 @@ "dependencies": { "@prisma/composer": "workspace:0.6.0", "@prisma/composer-prisma-cloud": "workspace:0.6.0", - "alchemy": "2.0.0-beta.59" + "alchemy": "2.0.0-beta.67" }, "devDependencies": { "@prisma/composer": "workspace:0.6.0",