806 lines
35 KiB
Markdown
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.
|