Files

35 KiB

name, metadata, description
name metadata description
prisma-composer
library library_version
@prisma/composer 0.16.0
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:

{
  "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:

// 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:

// 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:

// 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():

// 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: '..' }),
});
// 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:

// 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:

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:

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:

// 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:

// 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:

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:

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:

// 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:

// 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 (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:

// 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):

// 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:

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:

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.

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.

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.