Files

806 lines
35 KiB
Markdown

---
name: prisma-composer
metadata:
library: "@prisma/composer"
library_version: "0.16.0"
description: >-
How to write, test, and deploy an app with Prisma Composer
(`@prisma/composer`): declare services with `compute()` and typed
dependencies, define RPC contracts, compose Modules, declare the service
input (config and secrets as one schema, read back with `input()`),
compose the ready-made cron/storage/streams Modules, provision a
raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
run the whole app locally with `prisma-composer dev` and tail its logs with
`prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
destroy). Use when building a Prisma App, wiring a service dependency, adding
a Postgres database, adding scheduled jobs / blob storage / event streams / a
raw bucket, writing tests for composed services, running an app locally,
reading its logs, or deploying/tearing down an environment. Triggers on
"prisma composer", "@prisma/composer", "prisma app", "compute()",
"service.load()", "module()", "contract()", "mockService",
"bootstrapService", "prisma-composer dev", "prisma-composer log",
"prisma-composer deploy", "--stage", "--fresh", "--tail",
"prisma-composer destroy", "prisma-composer-", "bucket()".
---
# Writing apps with Prisma Composer
A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
module wires them together by their typed ports. Your code receives everything
from exactly one place — the service node:
- `service.load()` — dependencies (typed RPC clients, database bindings)
- `service.input()` — the service's whole input, one schema-validated typed
object; credentials in it are redacting `SecretString` boxes
- `service.port()` — the reserved port to bind (default 3000), typed; never
`process.env`
The framework never bundles or transforms your code. You build your app with
whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
Postgres).
Two things make building here fast and hard to get wrong — lean on both:
- **Compose before you write.** Reach for an existing Module (below) before
implementing a capability yourself; wiring one in is a couple of lines.
- **The compiler checks the wiring.** A dependency wired to the wrong
producer, a missing RPC handler, a config value of the wrong shape — all of
it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
reach for the cloud to find out whether the app is correct.
Two packages, and only two, appear in your `package.json`:
| Package | Provides |
| --- | --- |
| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
## tsconfig and import specifiers
Within the entry graph (everything reachable from `module.ts`) relative
imports may use `./service.js` or extensionless `./service`. The CLI maps
`.js` and extensionless specifiers to the matching `.ts` source under Node;
Bun does this natively.
A minimal tsconfig:
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["module.ts", "src"]
}
```
## Anatomy of a service
A service is four small files. Worked example: an `auth` service that owns a
Postgres database and serves an RPC contract, consumed by a `storefront`
Next.js app.
**The contract** lives with the service that owns it. Any Standard Schema
validator types the messages; arktype is the house choice:
```ts
// auth/src/contract.ts
import { contract, rpc } from '@prisma/composer/service-rpc';
import { type } from 'arktype';
export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
});
```
**The service declaration** is pure data — name, dependencies, build, exposed
ports. No behavior, no platform keys:
```ts
// auth/src/service.ts
import node from '@prisma/composer/node';
import { compute, postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
export default compute({
name: 'auth',
deps: { db: rawPostgres() },
build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
expose: { rpc: authContract },
});
```
**The server entry** is what your build produces and the platform boots. It
reads its dependencies through `load()` and serves the contract with
`serve()` — the handler map is keyed by the expose port's name and is
exhaustive at compile time:
```ts
// auth/src/server.ts
import { serve } from '@prisma/composer/service-rpc';
import { SQL } from 'bun';
import service from './service.ts';
const { db } = service.load(); // { url } — you build your own client
const port = service.port(); // the reserved port, resolved (default 3000)
const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
const handler = serve(service, {
rpc: {
verify: async ({ token }) => ({ ok: token.length > 0 }),
},
});
export default handler;
// Bind all interfaces — Compute routes external HTTP to the VM; a
// loopback-only listener is unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
```
**The consumer** declares the dependency as `rpc(contract)` and gets a typed
client back from `load()`:
```ts
// storefront/src/service.ts
import nextjs from '@prisma/composer/nextjs';
import { rpc } from '@prisma/composer/service-rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { authContract } from '@my-app/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
build: nextjs({ module: import.meta.url, appDir: '..' }),
});
```
```tsx
// storefront/app/page.tsx
import service from '../src/service.ts';
// load() reads the runtime environment, which doesn't exist at build time —
// render per request instead of prerendering.
export const dynamic = 'force-dynamic';
export default async function Home() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo-token' });
return <p>Signed in: {String(ok)}</p>;
}
```
**Service-to-service calls are authenticated for you.** At deploy the
framework mints a distinct, unguessable **service key** per consumer→provider
binding: the consumer's client sends it on every call, and `serve()` returns
`401` to anything else *before* the handler runs. Nothing declares it — no key
in the contract, the service, the module, or the app's code.
Two rules follow for you specifically: **don't build your own
service-to-service auth** on top of this, and **don't tell a user to `curl` a
deployed `/rpc/<method>` to check it works** — an unwired caller always gets
`401`, which looks like a broken deploy and isn't. Debug through a consumer,
or locally.
**Calls carry an idempotency key and retry safely for you.** Every call the
generated client makes carries an `Idempotency-Key`; a call dropped while the
target cold-starts is retried with a backoff, and `serve()` runs one call per
key — a retry that arrives after the first completed replays that answer
instead of re-running the handler. So every method is safely retryable and no
contract declares anything about it (do not add an "is this idempotent" flag —
the framework does not have one). Two consequences for you: a handler may take
an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
beyond one instance's memory (most don't); and a request without the header is
served once without deduplication rather than rejected, so a hand-rolled probe
works but gets no retry safety.
| | |
| --- | --- |
| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
It's a capability token ("I'm a service this app wired to you"), not a secret,
and its value lives in deploy state — deliberately unlike `secret()`, whose
value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
prisma/composer repo carries the reasoning.
## The root module
The root module provisions the pieces and wires exposed ports into dependency
slots. It is the app — `prisma-composer deploy` loads its default export:
```ts
// module.ts
import { module } from '@prisma/composer';
import authModule from '@my-app/auth';
import storefrontService from '@my-app/storefront';
export default module('my-app', ({ provision }) => {
const auth = provision(authModule);
provision(storefrontService, { deps: { auth: auth.rpc } });
});
```
`provision(node, opts?)` accepts `id` (defaults to the node's own name),
`deps` (wire each declared dependency to a provisioned ref or exposed port),
`input` (the service's input binding — required exactly when it declares an
input schema, see § Service input), and `secrets` (bind a module boundary's
forwarded secret needs).
## Builds are yours
The framework assembles only what you built — users build, the framework
assembles. For a plain server process, `entry` must point at a single
self-contained ESM file: everything inlined except runtime built-ins (`bun`,
`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
and never ships `node_modules`, so anything left un-inlined fails at boot. Any
bundler that produces such a file works. With bun:
```sh
bun build src/server.ts --target=bun --outfile dist/server.mjs
```
Two services in one package means two separate builds, one per entry — not one
multi-entry build, which would split shared code into a chunk neither output
contains.
If the build emits a directory rather than one file — a server plus the client
bundle, CSS and images it serves, as Bun's HTML import produces — name the
directory with `dir` and the booting file inside it with `entry`:
```ts
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
```
`dir` resolves relative to the service module; `entry` resolves inside `dir`
and may be nested. Deploy copies the tree verbatim and boots the named file,
so the server must resolve its siblings against `import.meta.url`, not the
working directory. Nothing is inferred, and two rules bite: the tree must
contain no symlinks (the packager rejects them — assembly fails and names the
link), and `entry` must be a file inside `dir` (`../` is an error, not an
escape). Omit `dir` for the single-file form.
For Next.js, `next build` with `output: 'standalone'` is the whole build;
`nextjs({ module, appDir })` tells the deploy where the app root is.
Always build before deploying — `prisma-composer deploy` does not build for
you.
## Deploy config
`prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
any ancestor directory: the CLI searches the entry's directory first, then each
parent, and uses the nearest one. It is read only by `prisma-composer
deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
`.mjs`, `.js`:
```ts
// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(), // deploy state, in its own database on the stage's branch
});
```
Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
when the app contains a Next.js service.
## Databases
Two kinds of Postgres dependency:
**`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
Construct it in your server entry, as in the auth example above.
**`postgres(...)`** — a Prisma-ORM-typed database: `load()`
returns the typed client the framework constructs from your data contract, so
queries like `db.orm.public.Product.all()` are compile-time checked. The
contract is emitted from `contract.prisma` by `prisma contract emit` and
wrapped once, referenced by both ends:
```ts
// src/data.ts — the ONE value both ends reference
import { dataContract } from '@prisma/composer-prisma-cloud/orm';
import type { Contract } from '../contract.d.ts';
import contractJson from '../contract.json' with { type: 'json' };
export const catalogData = dataContract<Contract>(contractJson);
```
The dependency end is `deps: { db: postgres(catalogData) }`. The resource
end (inside the module that owns the database) also names the
`prisma.config.ts` path, which the deploy's migration step loads to find
`migrations/` — committed migrations are replayed at deploy, before the
service starts:
```ts
const db = provision(
postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
);
```
(`postgres` is both ends: the contract alone is the dependency end; the
options object is the resource end.)
The deploy is replay-only: it applies the migrations committed under
`migrations/` and never creates schema itself. Every schema change (including
the very first schema of a new database) follows the same loop:
1. Edit `contract.prisma`.
2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
3. `prisma migration plan --name <slug>` — authors the migration into
`migrations/` (on an empty graph this authors the baseline,
empty → your schema).
4. Commit `migrations/` with the change, then deploy. A fresh database
replays the whole path from empty.
If no authored path reaches the target contract, the deploy (and
`prisma-composer dev` against a stale local database) refuses with
`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
as above, or — when iterating against a local database only — bring it along
directly with `prisma db update`. Never skip step 3 before a deploy.
See `examples/store/modules/catalog` in the prisma/composer repo for the
complete pattern.
## Object Storage
`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
```ts
import { bucket, compute } from '@prisma/composer-prisma-cloud';
// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
export default compute({ name: 'uploads', deps: { store: bucket() } });
// module.ts — resource end: provisions the bucket and mints a keypair
const store = provision(bucket({ name: 'uploads' }));
provision(uploadsService, { deps: { store } });
```
Use any S3-compatible client with the binding: the shape matches the standard S3
config and is also compatible with the `s3()` dependency from `/storage`, so any
service wired to `s3()` can be rewired to a `bucket` resource without changing
the service declaration.
## Reusable Modules
A Module is the unit of reuse: it owns its internals (its database, its
services) and exposes only typed ports. Declare the boundary in the second
argument; wire internals in the builder; return the exposed ports:
```ts
// auth/src/module.ts — a Module that owns its own Postgres
import { module, secret } from '@prisma/composer';
import { postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';
export default module(
'auth',
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(rawPostgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
});
return { rpc: service.rpc };
},
);
```
Naming rules that bite: a provision id shorter than 3 characters is rejected
by the platform (name the database `'database'`, not `'db'`), and a service
whose name equals its enclosing module's reads as `auth.auth` unless you give
it an explicit `id`.
A module can also declare boundary `deps` — inputs the parent wires exactly as
it would wire a service's. The consumer never sees the module's internals.
### The building blocks you can compose
Modules are the building blocks: provision one, wire its exposed port, and
you're done — you never reimplement what a Module already owns. The
first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
growing:
| Import | What it provisions | Exposes |
| --- | --- | --- |
| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
**Finding more.** A Composer extension — a package that brings its own
Modules, resources, or deploy target — is published on npm under the name
`prisma-composer-*`. That name is the convention, so it's how you look for
one. The ecosystem is new: today the blocks above plus the app Modules you
write are the whole set, so don't reach for a `prisma-composer-*` package
without checking that it actually exists on npm first.
Cron end to end — the schedule is one source of truth; `serveSchedule` is
exhaustive over its job ids at compile time:
```ts
// service.ts
import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
export const schedule = defineSchedule({ tick: '60s' });
// the runner service exposes { trigger: triggerContract }
// server.ts
import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
const handler = serveSchedule(service, schedule, {
tick: (deps) => deps.worker.tick({}),
});
// module.ts — the cron module's boundary deps mirror the runner's own
provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
```
## Service input
Choosing the channel is most of the decision:
| The value is… | Declare | Provide | Read |
| --- | --- | --- | --- |
| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
The service declares its whole incoming configuration — plain values and
credentials together — as **one
[Standard Schema](https://standardschema.dev)** (arktype is the house
choice). A credential is a field typed as the redacting `SecretString` box;
conditional legality ("no stripe key unless billing is on") is an ordinary
schema union:
```ts
// service.ts — the shapes that are legal
import { secretString } from '@prisma/composer/arktype';
import { type } from 'arktype';
compute({
name: 'scheduler',
input: type({
jobs: type({ jobId: 'string', every: 'string' }).array(),
'region?': 'string',
apiKey: secretString(),
}),
// ...
});
// module.ts — where each value comes from; the binding mirrors the schema's shape
import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
provision(scheduler, {
input: {
jobs: [{ jobId: 'tick', every: '60s' }], // a literal
region: envParam('REGION'), // a per-stage platform variable
apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
},
});
// server.ts — one call, one validated typed object
const input = service.input();
input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
```
Rules that bite:
- **Secretness is enforced by validation**: a literal bound where the schema
expects `SecretString` fails the deploy, and `envSecret` bound to a plain
string field fails the same way. Don't put credentials in plain fields.
- **`envParam` values arrive as raw strings** — bind them to string fields.
The stage's platform variable is the store; the deploying shell only seeds
it (preflight copies a missing name up from the shell, and fails early,
naming the variable, when both lack it). Changing the platform value needs
a redeploy.
- **Absence is the schema's call**: an env-bound field whose variable is
unset (or empty) resolves to *key omitted* — legal only if the schema says
so (optional field, union arm). The deploy report prints the serialized
input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
and every key that resolved absent.
- **The reserved `port` (default 3000) is outside the schema** — read it
through `service.port()` (a sibling of `service.origin()`), never
`process.env`. The framework also exports `PORT` for Next.js standalone,
which binds it itself.
- A module forwards a secret need without learning the platform name
(the auth Module above); the forwarded ref is a binding leaf.
`examples/env-param` and `examples/storefront-auth` in the prisma/composer
repo are the working versions.
## Testing
You test by deciding what `load()` gives the code, never by editing the code
under test:
| You want to… | Use | From |
| --- | --- | --- |
| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
**Unit — `mockService`.** Returns a copy of the service whose `load()` yields
your doubles (type-checked against the declared deps) and whose `input()`
yields the object you pass under the reserved `input` key, in one flat
object (required exactly when the service declares an input schema; handed
over as-is, not validated). Wiring the module substitution is your runner's
job (`vi.mock` in Vitest, `mock.module` in bun test):
```tsx
// page.test.tsx
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
}),
}));
import Page from './page.tsx';
expect(renderToString(await Page())).toContain('Signed in: true');
```
**Integration — `bootstrapService`.** Boots the service's real built entry
in-process against a config you choose, exactly as a deployed boot would;
drive it over real HTTP. Run under `bun test`:
```ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
import storefront from '../src/service.ts';
const fake = Bun.serve({ port: 0, fetch: fakeAuth });
const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } },
});
const res = await app.fetch(new Request(app.url));
```
- **`service.port` must be concrete** — the entry self-listens; no OS-assigned
port is reported back.
- **No `close()`** — run each integration-test file in its own process (bun
test does).
- **Next.js services take a third argument**, a boot thunk, because the built
entry lives in Next's standalone output — resolve it with
`standaloneServerPath` from `@prisma/composer/nextjs/control`.
`bootstrapService` exports the resolved port as `process.env.PORT` before
booting, which is what Next's standalone server binds.
- **A service with an input schema takes `input`** in the config — a binding
exactly like `provision()`'s, run through the real serialize/read path, so
`input()` in the booted entry sees what a deploy would produce.
**The fake you pass.** A dependency's type is its contract, so any value of
that shape is a valid double: a bare object (fastest), the real client over an
in-memory handler, or a real local server (what `bootstrapService` drives).
Ship a dependency's fake from its own package as a `/fake` entry point,
outside `src/`, so the fake and the real service always share one contract.
## Running locally
`prisma-composer dev module.ts` runs the whole app on this machine — every
service, its Postgres and buckets, wired as they deploy — with **no cloud
credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
local emulators, so build first, exactly like deploy:
```sh
turbo run build && prisma-composer dev module.ts
```
It prints each service's local URL (the "front door"), watches built output
and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
stops the app's processes but leaves the local databases, buckets, and their
data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
instances and data first.
`dev` does **not** print service logs — that would bury the front door once
several services run. Logs are their own command:
| You want to… | Run |
| --- | --- |
| Run the app locally | `prisma-composer dev module.ts` |
| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
| Tail every service's logs | `prisma-composer log module.ts` |
| Tail one service | `prisma-composer log module.ts <address>` |
| Show more history first | `prisma-composer log module.ts --tail <n>` |
`prisma-composer log` follows the merged logs of the already-running app, each
line prefixed with its service (`[catalog.service] …`); pass a dotted address
to narrow to one. It only reads — it never builds, provisions, starts, or
stops anything. `--tail <n>` sets how much recent history to show before live
output (default 20; `0` for live-only). An unset secret doesn't block a local
run: it becomes a placeholder plus a warning, and only the code path that
spends it fails, at the real external service it calls. Windows isn't
supported yet.
## Deploying
Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
command line, never in code:
| You want to… | Run |
| --- | --- |
| Deploy to production | `prisma-composer deploy module.ts` |
| Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
| Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
| Tear down production's resources | `prisma-composer destroy module.ts --production` |
A Prisma App is one Project; a stage is a Branch of it — its
own compute, its own empty database, its own configuration. Deploys are
idempotent: re-deploying a stage updates the resources inside it. A stage name
must be a valid git ref name; an invalid name is a hard error.
Destroy always requires an explicit target — a bare `prisma-composer destroy`
is an error, and `--stage` with `--production` is too. Destroying a stage
deletes its Branch after removing its resources; the production Branch itself
is never deleted, only the resources inside it. Destroying production also
deletes the Project itself once it's empty, so hand-run stacks don't leave
behind empty Projects — but a Project still holding another stage's resources
is kept. Destroy never creates anything: destroying a never-deployed stage
fails rather than standing one up.
```sh
turbo run build && prisma-composer deploy module.ts --stage pr-42
```
### What a deploy prints
A deploy ends by printing the app's own topology — authored names, the
platform resource each became, and public URLs. The tree is the module
structure (`auth.api` is the `api` service inside the `auth` module):
```
storefront-auth
├─ auth
│ └─ api compute-service cps_abc123
│ https://xyz.ewr.prisma.build
├─ db postgres-database db_def456
└─ web compute-service cps_ghi789
https://uvw.ewr.prisma.build
```
Read ids out of this rather than telling the user to go hunting in the
Console. A URL appears only where the address is genuinely public — a compute
service prints one, a database never does (it has a connection string, not a
public endpoint), and a node whose product is secret material (an
`s3-credentials` keypair) reports no resource line at all. A node that
published nothing reportable still appears, marked `(no entities reported)`.
Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
always empty, never about the app. It is gone; nothing configured it and
nothing consumed it.
### The connection contract is checked at deploy
A connection declares the values it needs by name, and the producer on the
other end must supply them. A producer that omits one fails the deploy, naming
the edge, the param, and what the producer did supply:
```
Connection input "auth.db" declares param "url", but its producer "db" did not
supply it — the producer's outputs carry [host].
```
Fix it at whichever end is wrong: add the name to the outputs the producer
returns from its lowering, or mark the param `optional` on the connection if absent is
genuinely legal (the consumer then reads `undefined`).
This is a deploy-time refusal, not a broken deploy — and it can appear on an
app whose code didn't change. The gap used to pass silently: the value reached
the consumer as `undefined`, went into its environment, and crashed *that*
service at boot, blaming the reader instead of the supplier. Don't route around
it by making the param optional unless absent really is valid; that reinstates
the silent `undefined`.
Only reachable if you authored the connection or the extension on one side —
every shipped block supplies what it declares.
### Driving deploys from code
`@prisma/composer/control` exposes the CLI's operations in-process: typed
`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
no CLI rendering, no exit codes (the spawned deploy engine's own inherited
output can still reach the host terminal). The CLI itself is a renderer over
them.
```ts
import { deploy } from '@prisma/composer/control';
const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
// result: { ok: true, value: { summary? } } | { ok: false, failure }
```
- Failures come back as `{ ok: false, failure }` where `failure` is a
structured error: branch on its dotted `failure.code` (e.g.
`ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
An engine failure's `meta.diagnostics` (exit code, reproduce command; read
it with the exported `executionDiagnostics(failure)`) describes the current
execution mechanism — branch on `code`/`message`/`cause` for anything
durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
importing the module executes nothing until an operation runs. A
non-structured rejection out of an operation is a bug in composer, not an
expected failure.
- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
— explicit, never defaulted.
- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
a successful deploy is normal.
- The deploy engine's live output still streams to the host process's stdio —
the current mechanism; the operations don't capture it.
- `dev` resolves to `{ ok: true, value: session }` or a failure; the
session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
the host owns signal handling. `log` resolves to
`{ ok: true, value: { appName, services, lines } }` or a failure, where
`lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
the consumer stopping early); zero running services is a valid result, not
an error.
## Production pitfalls
- **Scale-to-zero closes idle database connections.** A persistent client
crashes into a 502 restart loop unless you keep the pool small and
reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
log `uncaughtException`/`unhandledRejection` instead of dying.
- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
- **Next.js pages that call `load()` need `export const dynamic =
'force-dynamic'`** — the runtime environment doesn't exist at build time,
and Next ignores runtime env for prerendered routes.
- **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
Every RPC binding carries an auto-provisioned service key, so a hand-rolled
`curl` is never authorized, and a provider with no wired consumers rejects
everything. Not a broken deploy — reach it through a consumer, or run it
locally where nothing is enforced.
- **Cold starts reset service-to-service connections.** A call into a
scaled-to-zero service can get `ECONNRESET`; retry it.
- **Every `prisma-composer` command stops at start-up on an `effect` version
conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
dependency floated a newer `effect` and the package manager hoisted it over
Composer's pin. Do what the error says: pin the whole `effect`
constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
`@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
bug: its own effect-family ranges float past what its code supports. The
repo's examples carry the block.)
- **The ingress buffers streaming responses.** An open SSE tail delivers
nothing and times out at 60s — don't build on streamed HTTP responses.
## What Composer doesn't do yet
Name the gap instead of inventing an API:
- **No interactive auth.** Deploys authenticate only via a static
`PRISMA_SERVICE_TOKEN`; there is no `login` flow.
- **No in-memory contract bindings.** A dependency can't yet be wired to a
co-located handler without HTTP; use `bootstrapService` with a loopback
fake.
- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
streaming contracts.
For anything else missing, check the examples and design docs in the
prisma/composer repo (`examples/`, `docs/design/10-domains/`,
`docs/design/90-decisions/`), then file an issue there rather than guessing.